feat: finish bilingual learning auth cockpit

This commit is contained in:
Codex
2026-03-23 13:05:44 +08:00
parent 09574c3400
commit bde4f6b9cf
56 changed files with 5043 additions and 1377 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/surefire-reports/*.dumpstream

View File

@@ -16,7 +16,7 @@ public record ApiResponse<T>(
return new ApiResponse<>(0, message, data, Instant.now());
}
public static ApiResponse<Void> fail(int code, String message) {
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(code, message, null, Instant.now());
}
}

View File

@@ -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<Map<String, Object>> reflectionRoutes() {
List<Map<String, Object>> routes = handlerMapping.getHandlerMethods().entrySet().stream()
.map(this::toRouteView)
.sorted((left, right) -> left.get("path").toString().compareTo(right.get("path").toString()))
.toList();
List<Map<String, Object>> 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<Map<String, Object>> reflectUserModel() {
List<Map<String, Object>> 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<Map<String, Object>> 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<CompletableFuture<Map<String, Object>>> jobs = createJobs(safeTasks, counter, executor);
CompletableFuture.allOf(jobs.toArray(CompletableFuture[]::new)).join();
List<Map<String, Object>> 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<Map<String, Object>> 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<CompletableFuture<Map<String, Object>>> 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.<String, Object>of(
"task", index,
"thread", Thread.currentThread().getName(),
"counterAfterIncrement", current
);
}, executor))
.toList();
}
private Map<String, Object> toRouteView(Map.Entry<RequestMappingInfo, HandlerMethod> 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<String, Object> summarizeClass(Class<?> type) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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()
);
}
}

View File

@@ -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,18 +26,18 @@ public class LearningAuthController {
}
@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());
public ApiResponse<Map<String, Object>> 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 <token> 访问 /api/secure/**"
"username", request.username(),
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs."
));
}
@@ -38,8 +45,29 @@ public class LearningAuthController {
public ApiResponse<Map<String, Object>> mode() {
return ApiResponse.ok(Map.of(
"mode", "learning-jwt",
"protectedPath", "/api/secure/**",
"defaultAccounts", "admin/admin123, user/user123"
"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<Map<String, Object>> 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."
));
}
}

View File

@@ -26,7 +26,14 @@ 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
@@ -34,17 +41,13 @@ public class LearningJwtFilter extends OncePerRequestFilter {
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;
}
@@ -59,4 +62,10 @@ public class LearningJwtFilter extends OncePerRequestFilter {
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 + "\"}");
}
}

View File

@@ -48,6 +48,10 @@ public class LearningJwtUtil {
return parse(token).getSubject();
}
public Map<String, Object> claims(String token) {
return Map.copyOf(parse(token));
}
private Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
}

View File

@@ -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);

View File

@@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Learning Login</title>
<style>
:root {
--bg: linear-gradient(135deg, #eef5ff 0%, #f8fff4 100%);
--card: rgba(255, 255, 255, 0.94);
--line: #dbe6f2;
--text: #122033;
--muted: #5d7288;
--brand: #177245;
--accent: #0f67b5;
--shadow: 0 20px 48px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(23, 114, 69, 0.13), transparent 30%),
radial-gradient(circle at bottom right, rgba(15, 103, 181, 0.12), transparent 24%),
var(--bg);
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.hero,
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero {
padding: 28px;
margin-bottom: 18px;
}
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(23, 114, 69, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p {
margin: 0;
color: var(--muted);
line-height: 1.8;
}
.layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
}
.card {
padding: 22px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
}
label {
font-size: 13px;
font-weight: 700;
color: #21384f;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
font: inherit;
}
.actions,
.chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn,
.btn-soft {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
font-weight: 700;
}
.btn {
color: #fff;
background: linear-gradient(135deg, var(--brand), #35a465);
}
.btn-soft {
color: var(--accent);
background: #eaf3ff;
}
.note-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.note {
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.5);
}
.status {
min-height: 22px;
margin-top: 12px;
color: var(--muted);
}
.status.error { color: #d64545; }
.status.success { color: #177245; }
pre {
margin: 16px 0 0;
min-height: 220px;
padding: 16px;
border-radius: 18px;
background: #0f1621;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
.chips span {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 920px) {
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventsLink"></a>
</div>
</section>
<div class="layout">
<section class="card">
<div class="eyebrow" data-i18n="loginBadge"></div>
<h2 data-i18n="loginTitle"></h2>
<p data-i18n="loginText"></p>
<div class="field">
<label for="username" data-i18n="usernameLabel"></label>
<input id="username" value="admin" autocomplete="username">
</div>
<div class="field">
<label for="password" data-i18n="passwordLabel"></label>
<input id="password" type="password" value="admin123" autocomplete="current-password">
</div>
<div class="actions" style="margin-top:18px;">
<button class="btn" type="button" id="loginButton" data-i18n="loginButton" onclick="login()"></button>
<button class="btn-soft" type="button" id="checkButton" data-i18n="checkButton" onclick="introspectToken()"></button>
<button class="btn-soft" type="button" id="logoutButton" data-i18n="logoutButton" onclick="logout()"></button>
</div>
<div class="status" id="statusBox"></div>
<pre id="resultBox"></pre>
</section>
<aside class="card">
<div class="eyebrow" data-i18n="guideBadge"></div>
<h2 data-i18n="guideTitle"></h2>
<div class="note-list">
<div class="note">
<strong data-i18n="note1Title"></strong>
<p data-i18n="note1Text"></p>
</div>
<div class="note">
<strong data-i18n="note2Title"></strong>
<p data-i18n="note2Text"></p>
</div>
<div class="note">
<strong data-i18n="note3Title"></strong>
<p data-i18n="note3Text"></p>
</div>
</div>
<div class="eyebrow" style="margin-top:20px;" data-i18n="accountBadge"></div>
<div class="chips" style="margin-top:12px;">
<span>admin / admin123</span>
<span>user / user123</span>
</div>
</aside>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "Spring \u5b66\u4e60\u767b\u5f55\u9875",
heroBadge: "\u5b66\u4e60\u9274\u6743\u5165\u53e3",
heroTitle: "\u5148\u5b8c\u6210\u6f14\u793a\u767b\u5f55\uff0c\u518d\u8fdb\u5165\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u3002",
heroText: "\u8fd9\u4e2a\u9875\u9762\u628a\u540e\u7aef\u5df2\u6709\u7684 JWT \u767b\u5f55\u80fd\u529b\u53d8\u6210\u771f\u6b63\u53ef\u89c1\u7684\u5b66\u4e60\u5165\u53e3\uff0c\u65b9\u4fbf\u4f60\u5728\u516c\u5f00 VPS \u73af\u5883\u91cc\u5148\u767b\u5f55\uff0c\u518d\u4f53\u9a8c\u7528\u6237\u3001AOP\u3001\u4e8b\u4ef6\u548c\u9ad8\u7ea7\u5b9e\u9a8c\u3002",
homeLink: "\u8fd4\u56de\u9996\u9875",
usersLink: "\u6253\u5f00\u7528\u6237\u5b9e\u9a8c",
aopLink: "\u6253\u5f00 AOP \u5b9e\u9a8c",
eventsLink: "\u6253\u5f00\u4e8b\u4ef6\u5b9e\u9a8c",
loginBadge: "\u767b\u5f55\u8868\u5355",
loginTitle: "\u4f7f\u7528\u6f14\u793a\u8d26\u53f7\u6362\u53d6 Bearer Token",
loginText: "\u767b\u5f55\u6210\u529f\u540e\uff0ctoken \u4f1a\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u672c\u5730\u3002\u4e4b\u540e\u672c\u9879\u76ee\u4e2d\u7684\u524d\u7aef\u9875\u9762\u4f1a\u81ea\u52a8\u643a\u5e26 Authorization \u5934\u8bbf\u95ee\u53d7\u4fdd\u62a4\u63a5\u53e3\u3002",
usernameLabel: "\u7528\u6237\u540d",
passwordLabel: "\u5bc6\u7801",
loginButton: "\u7acb\u5373\u767b\u5f55",
checkButton: "\u68c0\u67e5\u5f53\u524d\u4ee4\u724c",
logoutButton: "\u6e05\u9664\u4ee4\u724c",
guideBadge: "\u5b66\u4e60\u63d0\u793a",
guideTitle: "\u8fd9\u4e00\u6b65\u89e3\u51b3\u4e86\u4ec0\u4e48\u95ee\u9898",
note1Title: "1. \u8ba9\u767b\u5f55\u4e0d\u518d\u53ea\u662f\u540e\u7aef\u63a5\u53e3",
note1Text: "\u4e4b\u524d\u540e\u7aef\u63a5\u53e3\u5df2\u7ecf\u5b58\u5728\uff0c\u4f46\u9875\u9762\u91cc\u6ca1\u6709\u5165\u53e3\u3002\u73b0\u5728\u4f60\u53ef\u4ee5\u4ece\u524d\u7aef\u5b8c\u6210\u767b\u5f55\u3001\u4ee4\u724c\u6301\u4e45\u5316\u548c\u4ee4\u724c\u68c0\u67e5\u3002",
note2Title: "2. \u628a\u53d7\u4fdd\u62a4\u8def\u7531\u53d8\u6210\u53ef\u89c2\u5bdf\u6982\u5ff5",
note2Text: "\u767b\u5f55\u540e\u518d\u8bbf\u95ee /api/users/**\u3001/aop/**\u3001/learn/** \u548c /api/lab/**\uff0c\u5c31\u80fd\u6e05\u6670\u5bf9\u6bd4\u767b\u5f55\u524d\u540e\u7684\u884c\u4e3a\u5dee\u5f02\u3002",
note3Title: "3. \u4e3a\u4e2d\u82f1\u5207\u6362\u9884\u7559\u7edf\u4e00\u5165\u53e3",
note3Text: "\u9875\u9762\u9876\u90e8\u5df2\u7ecf\u63a5\u4e0a\u5171\u4eab\u5de5\u4f5c\u53f0\u811a\u672c\uff0c\u8bed\u8a00\u504f\u597d\u548c\u767b\u5f55\u72b6\u6001\u4f1a\u5728\u9875\u9762\u95f4\u540c\u6b65\u3002",
accountBadge: "\u6f14\u793a\u8d26\u53f7",
ready: "\u51c6\u5907\u5c31\u7eea\u3002\u4f60\u53ef\u4ee5\u76f4\u63a5\u4f7f\u7528\u9ed8\u8ba4\u8d26\u53f7\u767b\u5f55\u3002",
loginSuccess: "\u767b\u5f55\u6210\u529f\uff0c\u4ee4\u724c\u5df2\u4fdd\u5b58\u5230\u672c\u5730\u3002",
logoutSuccess: "\u672c\u5730\u4ee4\u724c\u5df2\u6e05\u9664\u3002",
introspectSuccess: "\u4ee4\u724c\u81ea\u68c0\u5b8c\u6210\u3002",
missingToken: "\u5f53\u524d\u6ca1\u6709\u53ef\u68c0\u67e5\u7684\u4ee4\u724c\uff0c\u8bf7\u5148\u767b\u5f55\u3002"
},
en: {
title: "Spring Learning Login",
heroBadge: "Auth Entry",
heroTitle: "Complete demo login before entering protected labs.",
heroText: "This page turns the existing backend JWT capability into a visible learning entry so you can authenticate first and then explore users, AOP, events, and advanced labs on a public VPS.",
homeLink: "Back home",
usersLink: "Open user lab",
aopLink: "Open AOP lab",
eventsLink: "Open event lab",
loginBadge: "Login Form",
loginTitle: "Exchange demo credentials for a Bearer token",
loginText: "After login, the token is stored locally. Frontend pages in this project will automatically attach the Authorization header when calling protected APIs.",
usernameLabel: "Username",
passwordLabel: "Password",
loginButton: "Login now",
checkButton: "Inspect token",
logoutButton: "Clear token",
guideBadge: "Study Notes",
guideTitle: "What this step solves",
note1Title: "1. Login is no longer only a backend endpoint",
note1Text: "The backend endpoint already existed, but there was no visible entry on the frontend. Now you can complete login, token persistence, and token inspection in the browser.",
note2Title: "2. Protected routes become observable",
note2Text: "After login, revisit /api/users/**, /aop/**, /learn/**, and /api/lab/** to compare protected behavior.",
note3Title: "3. Shared groundwork for language switching",
note3Text: "The shared workspace script is already mounted at the top of the page, so language preference and login state now sync across pages.",
accountBadge: "Demo Accounts",
ready: "Ready. You can sign in with the default accounts.",
loginSuccess: "Login succeeded and the token was saved locally.",
logoutSuccess: "Local token has been cleared.",
introspectSuccess: "Token introspection completed.",
missingToken: "There is no token to inspect yet. Please log in first."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
const resultBox = document.getElementById("resultBox");
if (!resultBox.dataset.state || resultBox.dataset.state === "idle") {
resultBox.textContent = text.ready;
resultBox.dataset.state = "idle";
}
}
function setStatus(message, type) {
const box = document.getElementById("statusBox");
box.textContent = message;
box.className = type ? "status " + type : "status";
}
async function login() {
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
try {
const payload = await window.learningShell.requestJson("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
});
window.learningShell.saveAuth(payload.data.token, payload.data.username);
setStatus(pageText().loginSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify(payload, null, 2);
document.getElementById("resultBox").dataset.state = "live";
} catch (error) {
setStatus(window.learningShell.describeError(error), "error");
document.getElementById("resultBox").textContent = JSON.stringify(
error.payload || { message: error.message },
null,
2
);
document.getElementById("resultBox").dataset.state = "live";
}
}
async function introspectToken() {
const token = window.learningShell.getToken();
if (!token) {
setStatus(pageText().missingToken, "error");
return;
}
try {
const payload = await window.learningShell.requestJson("/api/auth/introspect");
setStatus(pageText().introspectSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify(payload, null, 2);
document.getElementById("resultBox").dataset.state = "live";
} catch (error) {
setStatus(window.learningShell.describeError(error), "error");
document.getElementById("resultBox").textContent = JSON.stringify(
error.payload || { message: error.message },
null,
2
);
document.getElementById("resultBox").dataset.state = "live";
}
}
function logout() {
window.learningShell.clearAuth();
setStatus(pageText().logoutSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify({ cleared: true }, null, 2);
document.getElementById("resultBox").dataset.state = "live";
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -114,39 +114,37 @@
<body>
<div class="page">
<section class="hero">
<div class="eyebrow">AOP Lab</div>
<h1>Make cross-cutting behavior visible instead of abstract.</h1>
<p>
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.
</p>
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/">Back home</a>
<a class="btn-soft" href="/users.html">Open user lab</a>
<a class="btn-soft" href="/events.html">Open event lab</a>
<a class="btn-soft" href="/access.html" data-i18n="loginLink"></a>
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventsLink"></a>
</div>
</section>
<div class="grid">
<aside class="card">
<div class="eyebrow">Experiment Path</div>
<h2>Recommended AOP sequence</h2>
<div class="eyebrow" data-i18n="pathBadge"></div>
<h2 data-i18n="pathTitle"></h2>
<div class="list">
<div class="list-item">
<strong>1. Trigger controller calls</strong>
<p>Run the list and stats endpoints to make sure both controller and service methods execute.</p>
<strong data-i18n="step1Title"></strong>
<p data-i18n="step1Text"></p>
</div>
<div class="list-item">
<strong>2. Trigger a validation failure</strong>
<p>Send an invalid user payload and compare the failed request with the successful ones.</p>
<strong data-i18n="step2Title"></strong>
<p data-i18n="step2Text"></p>
</div>
<div class="list-item">
<strong>3. Inspect aspect output</strong>
<p>Load the stats endpoint and compare call counts, total time, and average time.</p>
<strong data-i18n="step3Title"></strong>
<p data-i18n="step3Text"></p>
</div>
</div>
<div class="eyebrow" style="margin-top:18px;">Advice Map</div>
<div class="eyebrow" style="margin-top:18px;" data-i18n="adviceBadge"></div>
<div class="chips" style="margin-top:12px;">
<span class="pill">@Before</span>
<span class="pill">@After</span>
@@ -156,74 +154,196 @@
</div>
<div class="flow">
<div class="flow-step"><strong>Before</strong><p>Inspect inputs or auth context.</p></div>
<div class="flow-step"><strong>Around</strong><p>Start timer and continue the join point.</p></div>
<div class="flow-step"><strong>Controller</strong><p>Delegate work to the service.</p></div>
<div class="flow-step"><strong>Service</strong><p>Apply business logic.</p></div>
<div class="flow-step"><strong>After*</strong><p>Record result or failure details.</p></div>
<div class="flow-step"><strong data-i18n="flow1Title"></strong><p data-i18n="flow1Text"></p></div>
<div class="flow-step"><strong data-i18n="flow2Title"></strong><p data-i18n="flow2Text"></p></div>
<div class="flow-step"><strong data-i18n="flow3Title"></strong><p data-i18n="flow3Text"></p></div>
<div class="flow-step"><strong data-i18n="flow4Title"></strong><p data-i18n="flow4Text"></p></div>
<div class="flow-step"><strong data-i18n="flow5Title"></strong><p data-i18n="flow5Text"></p></div>
</div>
</aside>
<main class="card">
<div class="eyebrow">Live Runs</div>
<h2>Run requests and inspect collected metrics</h2>
<p>The buttons below help you create real traffic, then inspect the aspect output without switching tools.</p>
<div class="eyebrow" data-i18n="liveBadge"></div>
<h2 data-i18n="liveTitle"></h2>
<p data-i18n="liveText"></p>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="loadUsers()">Load users</button>
<button class="btn-soft" type="button" onclick="loadUserStats()">Load user stats</button>
<button class="btn-soft" type="button" onclick="sendInvalidUser()">Send invalid user</button>
<button class="btn-soft" type="button" onclick="loadAopStats()">Load AOP stats</button>
<button class="btn" type="button" onclick="loadUsers()" data-i18n="loadUsersButton"></button>
<button class="btn-soft" type="button" onclick="loadUserStats()" data-i18n="loadStatsButton"></button>
<button class="btn-soft" type="button" onclick="sendInvalidUser()" data-i18n="invalidUserButton"></button>
<button class="btn-soft" type="button" onclick="loadAopStats()" data-i18n="aopStatsButton"></button>
</div>
<div class="list" style="margin-top:18px;">
<div class="list-item">
<strong>What to observe in the console</strong>
<p>Look for controller and service timing lines. Compare success and failure paths to see how around advice still records method duration.</p>
<strong data-i18n="observeTitle"></strong>
<p data-i18n="observeText"></p>
</div>
<div class="list-item">
<strong>What to inspect in code</strong>
<p>Read <code>PerformanceAspect</code> first, then compare it with the user endpoints that it wraps.</p>
<strong data-i18n="codeTitle"></strong>
<p data-i18n="codeText"></p>
</div>
</div>
<pre id="resultBox">Run one of the experiments above to inspect live JSON output.</pre>
<pre id="resultBox"></pre>
</main>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
async function renderRequest(path, options = {}) {
const box = document.getElementById('resultBox');
box.textContent = 'Loading ' + path + ' ...';
const I18N = {
zh: {
title: "Spring AOP 实验",
heroBadge: "AOP 实验",
heroTitle: "把横切行为从抽象概念变成可观察现象。",
heroText: "这一页专门展示学生最容易忽略的部分:通知是怎样包裹控制器和服务方法的、耗时如何被收集、以及校验失败为什么同样属于可观测的运行时行为。",
loginLink: "打开登录实验",
homeLink: "返回首页",
usersLink: "打开用户实验",
eventsLink: "打开事件实验",
pathBadge: "实验路径",
pathTitle: "推荐的 AOP 学习顺序",
step1Title: "1. 先触发控制器调用",
step1Text: "运行用户列表和统计接口,确保控制器和服务层方法都被执行到。",
step2Title: "2. 再触发一次校验失败",
step2Text: "发送一个非法用户载荷,对比失败请求和成功请求在切面统计上的表现。",
step3Title: "3. 最后查看切面输出",
step3Text: "加载统计接口,对比调用次数、总耗时和平均耗时。",
adviceBadge: "通知地图",
flow1Title: "Before",
flow1Text: "观察入参或鉴权上下文。",
flow2Title: "Around",
flow2Text: "开启计时器并继续执行连接点。",
flow3Title: "Controller",
flow3Text: "把业务交给服务层。",
flow4Title: "Service",
flow4Text: "执行具体业务规则。",
flow5Title: "After*",
flow5Text: "记录结果或失败细节。",
liveBadge: "实时运行区",
liveTitle: "发起请求并查看采集到的指标",
liveText: "下面的按钮会帮你制造真实流量,再直接查看切面输出,不需要再切换到别的工具。",
loadUsersButton: "加载用户",
loadStatsButton: "加载用户统计",
invalidUserButton: "发送非法用户",
aopStatsButton: "加载 AOP 统计",
observeTitle: "控制台里该观察什么",
observeText: "重点看控制器和服务层的耗时输出,对比成功路径和失败路径,理解 around 通知为什么仍然会记录方法执行时长。",
codeTitle: "代码里该先看什么",
codeText: "先读 PerformanceAspect再回头比对它包裹的用户接口。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
placeholder: "请先运行上方任意实验,再查看实时 JSON 输出。"
},
en: {
title: "Spring AOP Lab",
heroBadge: "AOP Lab",
heroTitle: "Make cross-cutting behavior visible instead of abstract.",
heroText: "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 observable runtime behavior.",
loginLink: "Open login lab",
homeLink: "Back home",
usersLink: "Open user lab",
eventsLink: "Open event lab",
pathBadge: "Experiment Path",
pathTitle: "Recommended AOP sequence",
step1Title: "1. Trigger controller calls",
step1Text: "Run the list and stats endpoints so both controller and service methods execute.",
step2Title: "2. Trigger a validation failure",
step2Text: "Send an invalid user payload and compare the failed request with successful ones in the aspect metrics.",
step3Title: "3. Inspect aspect output",
step3Text: "Load the stats endpoint and compare call counts, total time, and average time.",
adviceBadge: "Advice Map",
flow1Title: "Before",
flow1Text: "Inspect inputs or auth context.",
flow2Title: "Around",
flow2Text: "Start the timer and continue the join point.",
flow3Title: "Controller",
flow3Text: "Delegate work to the service.",
flow4Title: "Service",
flow4Text: "Apply business logic.",
flow5Title: "After*",
flow5Text: "Record result or failure details.",
liveBadge: "Live Runs",
liveTitle: "Run requests and inspect collected metrics",
liveText: "The buttons below create real traffic and then surface the aspect output without switching tools.",
loadUsersButton: "Load users",
loadStatsButton: "Load user stats",
invalidUserButton: "Send invalid user",
aopStatsButton: "Load AOP stats",
observeTitle: "What to observe in the console",
observeText: "Look for controller and service timing lines. Compare success and failure paths to see why around advice still records method duration.",
codeTitle: "What to inspect in code",
codeText: "Read PerformanceAspect first, then compare it with the user endpoints that it wraps.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
placeholder: "Run one of the experiments above to inspect live JSON output."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
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 response = await fetch(path, options);
const data = await response.json();
const data = await window.learningShell.requestJson(path, options || {});
box.textContent = JSON.stringify(data, null, 2);
box.dataset.state = "live";
} catch (error) {
box.textContent = 'Request failed: ' + error.message;
box.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
box.dataset.state = "live";
}
}
async function loadUsers() {
await renderRequest('/api/users');
await renderRequest("/api/users");
}
async function loadUserStats() {
await renderRequest('/api/users/stats');
await renderRequest("/api/users/stats");
}
async function loadAopStats() {
await renderRequest('/aop/aop/stats');
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 })
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();
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -129,54 +129,52 @@
<body>
<div class="page">
<section class="hero">
<div class="eyebrow">Event Lab</div>
<h1>Understand publisher-listener decoupling through a visible event timeline.</h1>
<p>
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.
</p>
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/">Back home</a>
<a class="btn-soft" href="/aop.html">Open AOP lab</a>
<a class="btn-soft" href="/users.html">Open user lab</a>
<a class="btn-soft" href="/access.html" data-i18n="loginLink"></a>
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
</div>
</section>
<div class="grid">
<aside class="card">
<div class="eyebrow">Event Story</div>
<h2>What to watch for</h2>
<div class="eyebrow" data-i18n="storyBadge"></div>
<h2 data-i18n="storyTitle"></h2>
<div class="timeline">
<div class="timeline-step"><strong>Controller</strong><p>Receives the publish request.</p></div>
<div class="timeline-step"><strong>Publisher</strong><p>Builds a `UserEvent` and emits it.</p></div>
<div class="timeline-step"><strong>Listeners</strong><p>Track history and optional async work.</p></div>
<div class="timeline-step"><strong>History API</strong><p>Lets you inspect what actually happened.</p></div>
<div class="timeline-step"><strong data-i18n="timeline1Title"></strong><p data-i18n="timeline1Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline2Title"></strong><p data-i18n="timeline2Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline3Title"></strong><p data-i18n="timeline3Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline4Title"></strong><p data-i18n="timeline4Text"></p></div>
</div>
<div class="list">
<div class="item">
<strong>Experiment 1</strong>
<p>Publish LOGIN twice with different users and confirm the history list grows without changing controller code.</p>
<strong data-i18n="exp1Title"></strong>
<p data-i18n="exp1Text"></p>
</div>
<div class="item">
<strong>Experiment 2</strong>
<p>Publish CREATED and then refresh history to see the same endpoint support multiple listener paths.</p>
<strong data-i18n="exp2Title"></strong>
<p data-i18n="exp2Text"></p>
</div>
<div class="item">
<strong>Code pairing tip</strong>
<p>Read `UserEventPublisher` and `UserEventListener` side by side to see the decoupling boundary.</p>
<strong data-i18n="pairingTitle"></strong>
<p data-i18n="pairingText"></p>
</div>
</div>
</aside>
<main class="card">
<div class="eyebrow">Live Event Console</div>
<h2>Publish events and inspect history</h2>
<p>Use the form to publish events, then reload history to inspect type, user, detail, and timestamp.</p>
<div class="eyebrow" data-i18n="liveBadge"></div>
<h2 data-i18n="liveTitle"></h2>
<p data-i18n="liveText"></p>
<div class="fields">
<label>
Event type
<span data-i18n="eventTypeLabel"></span>
<select id="eventType">
<option value="LOGIN">LOGIN</option>
<option value="CREATED">CREATED</option>
@@ -185,61 +183,177 @@
</select>
</label>
<label>
User id
<span data-i18n="userIdLabel"></span>
<input id="userId" value="21">
</label>
<label>
User name
<span data-i18n="userNameLabel"></span>
<input id="userName" value="observer-demo">
</label>
</div>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="publishEvent()">Publish event</button>
<button class="btn-soft" type="button" onclick="loadHistory()">Load history</button>
<button class="btn-soft" type="button" onclick="loadInfo()">Load event notes</button>
<button class="btn" type="button" onclick="publishEvent()" data-i18n="publishButton"></button>
<button class="btn-soft" type="button" onclick="loadHistory()" data-i18n="historyButton"></button>
<button class="btn-soft" type="button" onclick="loadInfo()" data-i18n="notesButton"></button>
</div>
<div class="list" style="margin-top:18px;">
<div class="item">
<strong>Interpretation hint</strong>
<p>If the controller returns immediately but history keeps growing, you are seeing decoupled follow-up behavior in action.</p>
<strong data-i18n="hintTitle"></strong>
<p data-i18n="hintText"></p>
</div>
</div>
<pre id="resultBox">Publish an event or load history to inspect live output.</pre>
<pre id="resultBox"></pre>
</main>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
async function renderRequest(path, options = {}) {
const box = document.getElementById('resultBox');
box.textContent = 'Loading ' + path + ' ...';
const I18N = {
zh: {
title: "Spring 事件实验",
heroBadge: "事件实验",
heroTitle: "通过可视化时间线理解发布者与监听器的解耦。",
heroText: "这个页面把平时隐藏起来的行为展现出来:请求可以先返回,而监听器还会在后台继续处理。你可以连续发布多种事件,然后再查看共享事件历史。",
loginLink: "打开登录实验",
homeLink: "返回首页",
aopLink: "打开 AOP 实验",
usersLink: "打开用户实验",
storyBadge: "事件故事线",
storyTitle: "应该重点观察什么",
timeline1Title: "Controller",
timeline1Text: "接收事件发布请求。",
timeline2Title: "Publisher",
timeline2Text: "组装 UserEvent 并发出事件。",
timeline3Title: "Listeners",
timeline3Text: "记录历史并执行异步后续工作。",
timeline4Title: "History API",
timeline4Text: "帮助你回看刚刚到底发生了什么。",
exp1Title: "实验 1",
exp1Text: "用不同用户连续发布两次 LOGIN确认历史记录会增长但控制器代码无需改动。",
exp2Title: "实验 2",
exp2Text: "发布 CREATED 再刷新历史,观察同一个入口如何支持多种监听路径。",
pairingTitle: "代码联读建议",
pairingText: "把 UserEventPublisher 和 UserEventListener 放在一起读,会更容易看出解耦边界。",
liveBadge: "实时事件台",
liveTitle: "发布事件并查看历史",
liveText: "通过这个表单发布事件,再刷新历史,观察类型、用户、说明和时间戳。",
eventTypeLabel: "事件类型",
userIdLabel: "用户 ID",
userNameLabel: "用户名",
publishButton: "发布事件",
historyButton: "加载历史",
notesButton: "加载事件说明",
hintTitle: "理解提示",
hintText: "如果控制器已经返回,但历史还在继续增长,你看到的就是解耦后的后续行为。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
placeholder: "先发布一个事件,或直接加载历史查看实时输出。"
},
en: {
title: "Spring Event Lab",
heroBadge: "Event Lab",
heroTitle: "Understand publisher-listener decoupling through a visible event timeline.",
heroText: "This page surfaces what normally stays hidden: a request can return quickly while listeners keep reacting in the background. Publish multiple event types and then inspect shared event history.",
loginLink: "Open login lab",
homeLink: "Back home",
aopLink: "Open AOP lab",
usersLink: "Open user lab",
storyBadge: "Event Story",
storyTitle: "What to watch for",
timeline1Title: "Controller",
timeline1Text: "Receives the publish request.",
timeline2Title: "Publisher",
timeline2Text: "Builds a UserEvent and emits it.",
timeline3Title: "Listeners",
timeline3Text: "Track history and optional async work.",
timeline4Title: "History API",
timeline4Text: "Lets you inspect what actually happened.",
exp1Title: "Experiment 1",
exp1Text: "Publish LOGIN twice with different users and confirm the history list grows without changing controller code.",
exp2Title: "Experiment 2",
exp2Text: "Publish CREATED and then refresh history to see the same endpoint support multiple listener paths.",
pairingTitle: "Code pairing tip",
pairingText: "Read UserEventPublisher and UserEventListener side by side to see the decoupling boundary.",
liveBadge: "Live Event Console",
liveTitle: "Publish events and inspect history",
liveText: "Use the form to publish events, then reload history to inspect type, user, detail, and timestamp.",
eventTypeLabel: "Event type",
userIdLabel: "User id",
userNameLabel: "User name",
publishButton: "Publish event",
historyButton: "Load history",
notesButton: "Load event notes",
hintTitle: "Interpretation hint",
hintText: "If the controller returns immediately but history keeps growing, you are seeing decoupled follow-up behavior in action.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
placeholder: "Publish an event or load history to inspect live output."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
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 response = await fetch(path, options);
const data = await response.json();
const data = await window.learningShell.requestJson(path, options || {});
box.textContent = JSON.stringify(data, null, 2);
box.dataset.state = "live";
} catch (error) {
box.textContent = 'Request failed: ' + error.message;
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, userName, eventType: type });
await renderRequest('/aop/event/publish?' + params.toString(), { method: 'POST' });
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');
await renderRequest("/aop/event/history");
}
async function loadInfo() {
await renderRequest('/aop/event');
await renderRequest("/aop/event");
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -192,41 +192,39 @@
<section class="hero">
<div class="hero-grid">
<div>
<div class="eyebrow">Spring Boot Learning Cockpit</div>
<h1>Use one workspace to understand MVC, validation, security, AOP, and application events.</h1>
<p>
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.
</p>
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn" href="/users.html">Open user lab</a>
<a class="btn-soft" href="/aop.html">Open AOP lab</a>
<a class="btn-soft" href="/events.html">Open event lab</a>
<a class="btn-soft" href="/learn">Open MVC learn API</a>
<a class="btn-soft" href="/access.html" data-i18n="loginLabLink"></a>
<a class="btn" href="/users.html" data-i18n="userLabLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLabLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventLabLink"></a>
<a class="btn-soft" href="/learn" data-i18n="learnApiLink"></a>
</div>
<div class="stats">
<div class="stat"><span>Core labs</span><strong>4</strong></div>
<div class="stat"><span>Major layers</span><strong>6</strong></div>
<div class="stat"><span>Interactive pages</span><strong>4</strong></div>
<div class="stat"><span>Tested backend paths</span><strong>2+</strong></div>
<div class="stat"><span data-i18n="statLabs"></span><strong>5</strong></div>
<div class="stat"><span data-i18n="statLayers"></span><strong>6</strong></div>
<div class="stat"><span data-i18n="statPages"></span><strong>4</strong></div>
<div class="stat"><span data-i18n="statPaths"></span><strong>2+</strong></div>
</div>
</div>
<div class="card" style="padding:0;">
<div class="card" style="box-shadow:none; border:none; border-radius:28px;">
<div class="eyebrow">Start Here</div>
<h2>Recommended study order</h2>
<div class="eyebrow" data-i18n="startBadge"></div>
<h2 data-i18n="startTitle"></h2>
<div class="list">
<div class="list-item">
<strong>1. Learn endpoint binding</strong>
<p>Hit the live API explorer below and compare query, path, header, cookie, and JSON body patterns.</p>
<strong data-i18n="startStep1Title"></strong>
<p data-i18n="startStep1Text"></p>
</div>
<div class="list-item">
<strong>2. Study users and validation</strong>
<p>Open the user lab to inspect CRUD, duplicate email handling, and aggregate stats.</p>
<strong data-i18n="startStep2Title"></strong>
<p data-i18n="startStep2Text"></p>
</div>
<div class="list-item">
<strong>3. Watch cross-cutting behavior</strong>
<p>Move to AOP and events to see timing, rate limiting, and listener history.</p>
<strong data-i18n="startStep3Title"></strong>
<p data-i18n="startStep3Text"></p>
</div>
</div>
</div>
@@ -235,77 +233,88 @@
</section>
<section class="card" style="margin-bottom:18px;">
<div class="eyebrow">Architecture</div>
<h2>How one request travels through the demo</h2>
<div class="eyebrow" data-i18n="archBadge"></div>
<h2 data-i18n="archTitle"></h2>
<div class="flow">
<div class="step"><strong>Browser</strong><small>Form submit or fetch call starts the request.</small></div>
<div class="step"><strong>Security</strong><small>JWT demo routes decide whether the request is public or protected.</small></div>
<div class="step"><strong>Controller</strong><small>Spring binds params, body, headers, and cookies into method arguments.</small></div>
<div class="step"><strong>Service</strong><small>Business rules run, such as duplicate email checks or stats calculation.</small></div>
<div class="step"><strong>AOP / Events</strong><small>Cross-cutting timing or event listeners react without cluttering core logic.</small></div>
<div class="step"><strong>Response</strong><small>Structured JSON or HTML returns to the page and becomes visible feedback.</small></div>
<div class="step"><strong data-i18n="flow1Title"></strong><small data-i18n="flow1Text"></small></div>
<div class="step"><strong data-i18n="flow2Title"></strong><small data-i18n="flow2Text"></small></div>
<div class="step"><strong data-i18n="flow3Title"></strong><small data-i18n="flow3Text"></small></div>
<div class="step"><strong data-i18n="flow4Title"></strong><small data-i18n="flow4Text"></small></div>
<div class="step"><strong data-i18n="flow5Title"></strong><small data-i18n="flow5Text"></small></div>
<div class="step"><strong data-i18n="flow6Title"></strong><small data-i18n="flow6Text"></small></div>
</div>
</section>
<div class="workspace">
<aside class="card">
<div class="eyebrow">Lab Tracks</div>
<h2>What to practice in each area</h2>
<div class="eyebrow" data-i18n="tracksBadge"></div>
<h2 data-i18n="tracksTitle"></h2>
<div class="list">
<div class="lab">
<strong>User management</strong>
<small>Create a user, trigger duplicate email protection, search records, and compare stats before and after changes.</small>
<strong data-i18n="track1Title"></strong>
<small data-i18n="track1Text"></small>
</div>
<div class="lab">
<strong>MVC parameter binding</strong>
<small>Use `/learn` routes to compare `@RequestParam`, `@PathVariable`, `@RequestBody`, `@RequestHeader`, and `@CookieValue`.</small>
<strong data-i18n="track2Title"></strong>
<small data-i18n="track2Text"></small>
</div>
<div class="lab">
<strong>AOP tracing</strong>
<small>Trigger `/api/users` and `/api/users/stats`, then inspect `/aop/aop/stats` to see which methods were counted.</small>
<strong data-i18n="track3Title"></strong>
<small data-i18n="track3Text"></small>
</div>
<div class="lab">
<strong>Application events</strong>
<small>Publish LOGIN or CREATED events and refresh event history to understand publisher-listener decoupling.</small>
<strong data-i18n="track4Title"></strong>
<small data-i18n="track4Text"></small>
</div>
<div class="lab">
<strong data-i18n="track5Title"></strong>
<small data-i18n="track5Text"></small>
</div>
</div>
<div class="eyebrow" style="margin-top:20px;">Code Reading Map</div>
<h2>Files worth reading next</h2>
<div class="eyebrow" style="margin-top:20px;" data-i18n="readingBadge"></div>
<h2 data-i18n="readingTitle"></h2>
<div class="list">
<div class="code-card">
<strong>UserController -> UserService</strong>
<small>Shows validation, CRUD orchestration, search, and stats aggregation.</small>
<strong data-i18n="reading1Title"></strong>
<small data-i18n="reading1Text"></small>
</div>
<div class="code-card">
<strong>LearningSecurityConfig + LearningJwtFilter</strong>
<small>Explains why most labs stay public while `/api/secure/**` stays protected.</small>
<strong data-i18n="reading2Title"></strong>
<small data-i18n="reading2Text"></small>
</div>
<div class="code-card">
<strong>PerformanceAspect + UserEventPublisher + UserEventListener</strong>
<small>Shows the two cleanest examples of cross-cutting and event-driven behavior.</small>
<strong data-i18n="reading3Title"></strong>
<small data-i18n="reading3Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading4Title"></strong>
<small data-i18n="reading4Text"></small>
</div>
</div>
</aside>
<main class="triple">
<section class="card" style="grid-column: 1 / -1;">
<div class="eyebrow">Live Explorer</div>
<h2>Call real endpoints without leaving the page</h2>
<p>Use these controls to inspect real JSON responses while you read the code. This makes the project easier to connect to concrete behavior.</p>
<div class="eyebrow" data-i18n="explorerBadge"></div>
<h2 data-i18n="explorerTitle"></h2>
<p data-i18n="explorerText"></p>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="loadEndpoint('/actuator/health')">Health</button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users/stats')">User stats</button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/learn')">Learn overview</button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/aop/stats')">AOP stats</button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/event/history')">Event history</button>
<button class="btn" type="button" onclick="loadEndpoint('/actuator/health')" data-i18n="healthButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users/stats')" data-i18n="userStatsButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/learn')" data-i18n="learnButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/aop/stats')" data-i18n="aopStatsButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/event/history')" data-i18n="eventHistoryButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/lab/reflection/routes')" data-i18n="reflectionButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/lab/concurrency/simulate?tasks=8&poolSize=4')" data-i18n="concurrencyButton"></button>
</div>
<p style="margin-top:12px;" data-i18n="protectedHint"></p>
<div class="triple" style="margin-top:16px;">
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventType">Publish demo event</label>
<label for="eventType" data-i18n="eventTypeLabel"></label>
<select id="eventType">
<option value="LOGIN">LOGIN</option>
<option value="CREATED">CREATED</option>
@@ -316,32 +325,32 @@
</div>
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventUserId">User id</label>
<label for="eventUserId" data-i18n="eventUserIdLabel"></label>
<input id="eventUserId" value="99">
</div>
</div>
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventUserName">User name</label>
<label for="eventUserName" data-i18n="eventUserNameLabel"></label>
<input id="eventUserName" value="learning-user">
</div>
</div>
</div>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="publishEvent()">Publish event</button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users')">Load users</button>
<button class="btn" type="button" onclick="publishEvent()" data-i18n="publishButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users')" data-i18n="loadUsersButton"></button>
</div>
<div class="console">
<div class="console-head">Endpoint output</div>
<pre id="consoleOutput">Select an experiment above to load live output.</pre>
<div class="console-head" data-i18n="consoleTitle"></div>
<pre id="consoleOutput"></pre>
</div>
</section>
<section class="card">
<div class="eyebrow">Experiment 1</div>
<h2>Trace validation</h2>
<p>Open the user lab, create a user, then repeat with the same email. Compare the frontend error with `DuplicateEmailException` and the global exception handler.</p>
<div class="eyebrow" data-i18n="exp1Badge"></div>
<h2 data-i18n="exp1Title"></h2>
<p data-i18n="exp1Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">UserController</span>
<span class="pill">UserService</span>
@@ -350,9 +359,9 @@
</section>
<section class="card">
<div class="eyebrow">Experiment 2</div>
<h2>Trace AOP timing</h2>
<p>Load users and stats several times, then call `/aop/aop/stats`. Watch how controller and service methods accumulate timing and call count data.</p>
<div class="eyebrow" data-i18n="exp2Badge"></div>
<h2 data-i18n="exp2Title"></h2>
<p data-i18n="exp2Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">PerformanceAspect</span>
<span class="pill">@Around</span>
@@ -361,9 +370,9 @@
</section>
<section class="card">
<div class="eyebrow">Experiment 3</div>
<h2>Trace event decoupling</h2>
<p>Publish CREATED and LOGIN events, then reload event history. This shows how the request can finish while listeners keep handling side effects.</p>
<div class="eyebrow" data-i18n="exp3Badge"></div>
<h2 data-i18n="exp3Title"></h2>
<p data-i18n="exp3Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">Publisher</span>
<span class="pill">Listener</span>
@@ -374,35 +383,263 @@
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
async function loadEndpoint(path, options = {}) {
const output = document.getElementById('consoleOutput');
output.textContent = 'Loading ' + path + ' ...';
const I18N = {
zh: {
title: "Spring Boot 学习总控台",
heroBadge: "Spring Boot 学习总控台",
heroTitle: "用一个工作台串起 MVC、校验、安全、AOP 和应用事件。",
heroText: "首页现在不只是导航页,而是一个带实验顺序、请求路径说明和实时接口调用能力的学习驾驶舱,帮助你把代码结构和真实行为对应起来。",
loginLabLink: "打开登录实验",
userLabLink: "打开用户实验",
aopLabLink: "打开 AOP 实验",
eventLabLink: "打开事件实验",
learnApiLink: "打开 MVC Learn API",
statLabs: "核心实验",
statLayers: "关键层次",
statPages: "交互页面",
statPaths: "已验证后端路径",
startBadge: "从这里开始",
startTitle: "推荐学习顺序",
startStep1Title: "1. 先看参数绑定",
startStep1Text: "先用下面的实时接口区观察 query、path、header、cookie 和 JSON body 的绑定方式,再对照控制器代码。",
startStep2Title: "2. 再学用户和校验",
startStep2Text: "进入用户实验页,验证 CRUD、重复邮箱保护、聚合统计和错误回传。",
startStep3Title: "3. 最后看横切与事件",
startStep3Text: "切到 AOP 与事件页,理解计时、限流、发布订阅和监听器历史。",
archBadge: "架构路径",
archTitle: "一个请求如何穿过整个 Demo",
flow1Title: "浏览器",
flow1Text: "表单提交或 fetch 调用发起请求。",
flow2Title: "安全层",
flow2Text: "JWT 学习路由判断请求是公开还是受保护。",
flow3Title: "控制器",
flow3Text: "Spring 把参数、请求体、请求头和 Cookie 绑定到方法参数。",
flow4Title: "服务层",
flow4Text: "这里运行重复邮箱校验、统计计算等业务规则。",
flow5Title: "AOP / 事件",
flow5Text: "横切逻辑和监听器在不污染主流程的前提下响应请求。",
flow6Title: "响应",
flow6Text: "最终以结构化 JSON 或 HTML 的形式回到页面。",
tracksBadge: "实验赛道",
tracksTitle: "每个区域该练什么",
track1Title: "用户管理",
track1Text: "创建用户、触发重复邮箱保护、搜索记录,并比较变更前后的统计数据。",
track2Title: "MVC 参数绑定",
track2Text: "使用 /learn 路由对比 @RequestParam、@PathVariable、@RequestBody、@RequestHeader 和 @CookieValue。",
track3Title: "AOP 追踪",
track3Text: "触发 /api/users 与 /api/users/stats再查看 /aop/aop/stats 里哪些方法被统计了。",
track4Title: "应用事件",
track4Text: "发布 LOGIN 或 CREATED 事件后刷新历史,理解发布者与监听器的解耦。",
track5Title: "高级实验",
track5Text: "直接调用 /api/lab 下的反射、并发和 JWT claims 实验接口,把高级知识点和当前 Demo 串起来。",
readingBadge: "代码阅读地图",
readingTitle: "接下来值得重点看的文件",
reading1Title: "UserController -> UserService",
reading1Text: "展示校验、CRUD 编排、搜索以及统计聚合。",
reading2Title: "LearningSecurityConfig + LearningJwtFilter",
reading2Text: "解释为什么大部分实验页保持公开,而 /api/secure/** 仍然受保护。",
reading3Title: "PerformanceAspect + UserEventPublisher + UserEventListener",
reading3Text: "这是横切逻辑与事件驱动最直观的两组示例。",
reading4Title: "AdvancedLabController + LearningJwtUtil",
reading4Text: "用反射、并发和 JWT claims 解析,把高级 Java 和安全实验纳入同一个学习工作台。",
explorerBadge: "实时接口区",
explorerTitle: "不离开页面,直接调用真实接口",
explorerText: "一边读代码,一边在这里看真实返回结果,会更容易把项目结构和运行行为建立联系。",
healthButton: "健康检查",
userStatsButton: "用户统计",
learnButton: "Learn 总览",
aopStatsButton: "AOP 统计",
eventHistoryButton: "事件历史",
reflectionButton: "反射路由图",
concurrencyButton: "并发实验",
protectedHint: "受保护接口已经接入前端令牌持久化。如果你还没登录,请先进入登录实验页。",
eventTypeLabel: "发布演示事件",
eventUserIdLabel: "用户 ID",
eventUserNameLabel: "用户名",
publishButton: "发布事件",
loadUsersButton: "加载用户",
consoleTitle: "接口输出",
consolePlaceholder: "请选择上方一个实验动作来加载实时输出。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
exp1Badge: "实验 1",
exp1Title: "追踪校验链路",
exp1Text: "打开用户实验先创建一个用户再用同样邮箱重复创建。对比前端错误提示、DuplicateEmailException 和全局异常处理器。",
exp2Badge: "实验 2",
exp2Title: "追踪 AOP 计时",
exp2Text: "多次加载用户列表和统计,再调用 /aop/aop/stats观察控制器与服务方法如何累计耗时与调用次数。",
exp3Badge: "实验 3",
exp3Title: "追踪事件解耦",
exp3Text: "连续发布 CREATED 和 LOGIN 事件,再刷新历史,体会请求结束后监听器仍在处理副作用。"
},
en: {
title: "Spring Boot Learning Cockpit",
heroBadge: "Spring Boot Learning Cockpit",
heroTitle: "Use one workspace to understand MVC, validation, security, AOP, and application events.",
heroText: "This homepage is now a guided study cockpit. Instead of only linking to pages, it explains request paths, suggests experiment sequences, and lets you call real endpoints to observe behavior.",
loginLabLink: "Open login lab",
userLabLink: "Open user lab",
aopLabLink: "Open AOP lab",
eventLabLink: "Open event lab",
learnApiLink: "Open MVC Learn API",
statLabs: "Core labs",
statLayers: "Major layers",
statPages: "Interactive pages",
statPaths: "Tested backend paths",
startBadge: "Start Here",
startTitle: "Recommended study order",
startStep1Title: "1. Learn endpoint binding",
startStep1Text: "Use the live explorer below and compare query, path, header, cookie, and JSON body patterns before reading controller code.",
startStep2Title: "2. Study users and validation",
startStep2Text: "Open the user lab to inspect CRUD, duplicate email handling, aggregate stats, and error responses.",
startStep3Title: "3. Watch cross-cutting behavior",
startStep3Text: "Move to AOP and events to understand timing, rate limiting, publishing, and listener history.",
archBadge: "Architecture",
archTitle: "How one request travels through the demo",
flow1Title: "Browser",
flow1Text: "A form submit or fetch call starts the request.",
flow2Title: "Security",
flow2Text: "JWT learning routes decide whether the request is public or protected.",
flow3Title: "Controller",
flow3Text: "Spring binds params, body, headers, and cookies into method arguments.",
flow4Title: "Service",
flow4Text: "Business rules run here, such as duplicate email checks and stats calculation.",
flow5Title: "AOP / Events",
flow5Text: "Cross-cutting logic and listeners react without cluttering the core flow.",
flow6Title: "Response",
flow6Text: "The result returns to the page as structured JSON or HTML feedback.",
tracksBadge: "Lab Tracks",
tracksTitle: "What to practice in each area",
track1Title: "User management",
track1Text: "Create a user, trigger duplicate email protection, search records, and compare stats before and after changes.",
track2Title: "MVC parameter binding",
track2Text: "Use /learn routes to compare @RequestParam, @PathVariable, @RequestBody, @RequestHeader, and @CookieValue.",
track3Title: "AOP tracing",
track3Text: "Trigger /api/users and /api/users/stats, then inspect /aop/aop/stats to see which methods were counted.",
track4Title: "Application events",
track4Text: "Publish LOGIN or CREATED events and refresh history to understand publisher-listener decoupling.",
track5Title: "Advanced labs",
track5Text: "Call the reflection, concurrency, and JWT claims labs under /api/lab to connect advanced topics back to this demo.",
readingBadge: "Code Reading Map",
readingTitle: "Files worth reading next",
reading1Title: "UserController -> UserService",
reading1Text: "Shows validation, CRUD orchestration, search, and stats aggregation.",
reading2Title: "LearningSecurityConfig + LearningJwtFilter",
reading2Text: "Explains why most labs stay public while /api/secure/** remains protected.",
reading3Title: "PerformanceAspect + UserEventPublisher + UserEventListener",
reading3Text: "These are the clearest examples of cross-cutting and event-driven behavior.",
reading4Title: "AdvancedLabController + LearningJwtUtil",
reading4Text: "Use reflection, concurrency, and JWT claim parsing to fold advanced Java and security experiments into the same workspace.",
explorerBadge: "Live Explorer",
explorerTitle: "Call real endpoints without leaving the page",
explorerText: "Inspect real responses while you read the code so the project is easier to connect to concrete runtime behavior.",
healthButton: "Health",
userStatsButton: "User stats",
learnButton: "Learn overview",
aopStatsButton: "AOP stats",
eventHistoryButton: "Event history",
reflectionButton: "Reflection routes",
concurrencyButton: "Concurrency lab",
protectedHint: "Protected endpoints now support frontend token persistence. If you are not logged in yet, open the login lab first.",
eventTypeLabel: "Publish demo event",
eventUserIdLabel: "User id",
eventUserNameLabel: "User name",
publishButton: "Publish event",
loadUsersButton: "Load users",
consoleTitle: "Endpoint output",
consolePlaceholder: "Select one experiment above to load live output.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
exp1Badge: "Experiment 1",
exp1Title: "Trace validation",
exp1Text: "Open the user lab, create a user, then repeat with the same email. Compare the frontend error with DuplicateEmailException and the global exception handler.",
exp2Badge: "Experiment 2",
exp2Title: "Trace AOP timing",
exp2Text: "Load users and stats several times, then call /aop/aop/stats. Watch how controller and service methods accumulate timing and call count data.",
exp3Badge: "Experiment 3",
exp3Title: "Trace event decoupling",
exp3Text: "Publish CREATED and LOGIN events, then reload event history to see how listeners keep handling follow-up work."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
const output = document.getElementById("consoleOutput");
if (!output.dataset.state || output.dataset.state === "idle") {
output.textContent = text.consolePlaceholder;
output.dataset.state = "idle";
}
}
async function loadEndpoint(path, options) {
const output = document.getElementById("consoleOutput");
const text = pageText();
output.textContent = text.loadingPrefix + path + " ...";
output.dataset.state = "loading";
try {
const response = await fetch(path, options);
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const response = await window.learningShell.fetchWithAuth(path, options || {});
const contentType = response.headers.get("content-type") || "";
if (!response.ok) {
const failedContent = contentType.includes("application/json")
? await response.json()
: await response.text();
throw {
status: response.status,
message: typeof failedContent === "object" && failedContent && failedContent.message
? failedContent.message
: window.learningShell.describeError({ status: response.status }),
payload: failedContent
};
}
if (contentType.includes("application/json")) {
const json = await response.json();
output.textContent = JSON.stringify(json, null, 2);
} else {
output.textContent = await response.text();
}
output.dataset.state = "live";
} catch (error) {
output.textContent = 'Request failed: ' + error.message;
output.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
output.dataset.state = "live";
}
}
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 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,
userName,
userId: userId,
userName: userName,
eventType: type
});
await loadEndpoint('/aop/event/publish?' + params.toString(), { method: 'POST' });
await loadEndpoint("/aop/event/publish?" + params.toString(), { method: "POST" });
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>

View File

@@ -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 = [
'<div class="learning-shell-card">',
' <div class="learning-shell-brand">',
' <strong data-role="brand"></strong>',
' <span data-role="status"></span>',
" </div>",
' <div class="learning-shell-actions">',
' <a class="learning-shell-link" href="/" data-role="home"></a>',
' <a class="learning-shell-link" href="/access.html" data-role="access"></a>',
' <span class="learning-shell-badge" data-role="user"></span>',
' <button class="learning-shell-button alt" type="button" data-role="language"></button>',
' <button class="learning-shell-button alt" type="button" data-role="login"></button>',
' <button class="learning-shell-button alt" type="button" data-role="logout"></button>',
" </div>",
"</div>"
].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
};
})();

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -212,57 +212,58 @@
<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>
<span class="eyebrow" data-i18n="heroBadge"></span>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></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>
<button class="btn btn-primary" onclick="openCreateModal()" data-i18n="createButton"></button>
<button class="btn btn-secondary" onclick="refreshDashboard(pageText().dashboardUpdated, 'success')" data-i18n="refreshButton"></button>
<button class="btn btn-secondary" onclick="window.location.href='/access.html'" data-i18n="loginLabButton"></button>
<button class="btn btn-secondary" onclick="window.location.href='/'" data-i18n="homeButton"></button>
</div>
</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>
<h3 data-i18n="highlightsTitle"></h3>
<p data-i18n="highlightsText"></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>
<div class="card stat"><small data-i18n="statTotal"></small><strong id="totalUsers">0</strong></div>
<div class="card stat"><small data-i18n="statAdults"></small><strong id="adultUsers">0</strong></div>
<div class="card stat"><small data-i18n="statYoung"></small><strong id="youngUsers">0</strong></div>
<div class="card stat"><small data-i18n="statAverage"></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>
<h2 data-i18n="directoryTitle"></h2>
<p data-i18n="directoryText"></p>
</div>
<span class="pill" id="resultCount">0 users</span>
<span class="pill" id="resultCount"></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>
<h2 data-i18n="searchTitle"></h2>
<p data-i18n="searchText"></p>
</div>
</div>
<form onsubmit="searchUsers(event)">
<label>
Keyword
<input id="searchInput" type="text" placeholder="Try: alice or example.com">
<span data-i18n="keywordLabel"></span>
<input id="searchInput" type="text" data-i18n-placeholder="searchPlaceholder">
</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>
<button class="btn btn-primary" type="submit" data-i18n="searchButton"></button>
<button class="btn btn-secondary" type="button" onclick="clearSearch()" data-i18n="clearButton"></button>
</div>
</form>
<p>The frontend now explains server-side validation and duplicate email errors instead of silently failing.</p>
<p data-i18n="searchHint"></p>
<div class="status" id="pageStatus"></div>
</div>
</section>
@@ -272,195 +273,389 @@
<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>
<h2 id="modalTitle"></h2>
<p id="modalSubtitle"></p>
</div>
<button class="close" onclick="closeModal()" aria-label="Close">&times;</button>
<button class="close" onclick="closeModal()" data-i18n-aria="closeLabel">&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>
<label><span data-i18n="nameLabel"></span><input id="userName" maxlength="40" required></label>
<label><span data-i18n="emailLabel"></span><input id="userEmail" type="email" maxlength="80" required></label>
<label><span data-i18n="ageLabel"></span><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>
<button class="btn btn-primary" type="submit" data-i18n="saveButton"></button>
<button class="btn btn-secondary" type="button" onclick="closeModal()" data-i18n="cancelButton"></button>
</div>
</form>
</div>
</div>
<script src="/learning-shell.js"></script>
<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;
const I18N = {
zh: {
title: "用户管理实验",
heroBadge: "Spring Boot Demo",
heroTitle: "让用户管理实验更接近真实项目。",
heroText: "这一页不再只是静态 CRUD 示例,而是接入了后端统计、关键字搜索、重复邮箱保护和清晰的错误处理。",
createButton: "创建用户",
refreshButton: "刷新看板",
loginLabButton: "登录实验",
homeButton: "返回首页",
highlightsTitle: "学习亮点",
highlightsText: "重复邮箱会返回 409校验错误会直接展示搜索同时匹配用户名和邮箱。",
statTotal: "总用户数",
statAdults: "成年人",
statYoung: "30 岁以下",
statAverage: "平均年龄",
directoryTitle: "用户目录",
directoryText: "表格结果与后端 API 实时同步。",
searchTitle: "搜索",
searchText: "输入关键字,按姓名或邮箱匹配。",
keywordLabel: "关键字",
searchPlaceholder: "例如alice 或 example.com",
searchButton: "搜索",
clearButton: "清空",
searchHint: "前端现在会明确解释服务端校验失败和重复邮箱错误,而不是静默失败。",
closeLabel: "关闭",
nameLabel: "姓名",
emailLabel: "邮箱",
ageLabel: "年龄",
saveButton: "保存",
cancelButton: "取消",
modalCreateTitle: "创建用户",
modalCreateSubtitle: "向内存数据仓中新增一个用户。",
modalEditTitle: "编辑用户",
modalEditSubtitle: "更新用户信息,同时保持邮箱唯一。",
emptyUsers: "当前查询没有匹配到任何用户。",
tableId: "ID",
tableName: "姓名",
tableEmail: "邮箱",
tableAge: "年龄",
tableSegment: "分组",
tableActions: "操作",
segmentAdult: "成年",
segmentMinor: "未成年",
editButton: "编辑",
deleteButton: "删除",
dashboardUpdated: "看板已刷新。",
showingAll: "正在显示全部用户。",
latestLoaded: "已加载最新用户数据。",
searchCompleted: function (keyword) {
return "已完成关键字搜索:" + keyword;
},
deleteSuccess: "用户删除成功。",
updateSuccess: "用户更新成功。",
createSuccess: "用户创建成功。",
resultCount: function (count) {
return count + " 个用户";
},
confirmDelete: function (id) {
return "确认删除用户 #" + id + " 吗?";
}
return payload;
},
en: {
title: "User Management Lab",
heroBadge: "Spring Boot Demo",
heroTitle: "User management that feels production-ready.",
heroText: "This page is no longer a static CRUD sample. It now uses backend stats, keyword search, duplicate email protection, and clear error handling.",
createButton: "Create user",
refreshButton: "Refresh",
loginLabButton: "Login lab",
homeButton: "Back home",
highlightsTitle: "Highlights",
highlightsText: "Duplicate emails return 409, validation errors are shown directly, and search matches both names and emails.",
statTotal: "Total users",
statAdults: "Adults",
statYoung: "Under 30",
statAverage: "Average age",
directoryTitle: "User directory",
directoryText: "Table results stay in sync with the backend API.",
searchTitle: "Search",
searchText: "Use a keyword to match names or emails.",
keywordLabel: "Keyword",
searchPlaceholder: "Try: alice or example.com",
searchButton: "Search",
clearButton: "Clear",
searchHint: "The frontend now explains server-side validation and duplicate email errors instead of failing silently.",
closeLabel: "Close",
nameLabel: "Name",
emailLabel: "Email",
ageLabel: "Age",
saveButton: "Save",
cancelButton: "Cancel",
modalCreateTitle: "Create user",
modalCreateSubtitle: "Add a new user to the in-memory store.",
modalEditTitle: "Edit user",
modalEditSubtitle: "Update the user and keep the email unique.",
emptyUsers: "No users matched the current query.",
tableId: "ID",
tableName: "Name",
tableEmail: "Email",
tableAge: "Age",
tableSegment: "Segment",
tableActions: "Actions",
segmentAdult: "Adult",
segmentMinor: "Minor",
editButton: "Edit",
deleteButton: "Delete",
dashboardUpdated: "Dashboard updated.",
showingAll: "Showing all users.",
latestLoaded: "Latest users loaded.",
searchCompleted: function (keyword) {
return 'Search completed for "' + keyword + '".';
},
deleteSuccess: "User deleted successfully.",
updateSuccess: "User updated successfully.",
createSuccess: "User created successfully.",
resultCount: function (count) {
return count + " users";
},
confirmDelete: function (id) {
return "Delete user #" + id + "?";
}
}
};
const state = {
users: [],
modalMode: "create"
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function setStatus(id, message, type = '') {
const el = document.getElementById(id);
el.textContent = message;
el.className = type ? `status ${type}` : 'status';
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
document.querySelectorAll("[data-i18n-placeholder]").forEach(function (element) {
const key = element.getAttribute("data-i18n-placeholder");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.setAttribute("placeholder", text[key]);
}
});
document.querySelectorAll("[data-i18n-aria]").forEach(function (element) {
const key = element.getAttribute("data-i18n-aria");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.setAttribute("aria-label", text[key]);
}
});
}
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);
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 renderModalText() {
const text = pageText();
document.getElementById("modalTitle").textContent = state.modalMode === "edit"
? text.modalEditTitle
: text.modalCreateTitle;
document.getElementById("modalSubtitle").textContent = state.modalMode === "edit"
? text.modalEditSubtitle
: text.modalCreateSubtitle;
}
function renderTable() {
const area = document.getElementById('tableArea');
document.getElementById('resultCount').textContent = `${state.users.length} users`;
const text = pageText();
const area = document.getElementById("tableArea");
document.getElementById("resultCount").textContent = text.resultCount(state.users.length);
if (!state.users.length) {
area.innerHTML = '<div class="empty">No users matched the current query.</div>';
area.innerHTML = '<div class="empty">' + text.emptyUsers + "</div>";
return;
}
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>
`;
area.innerHTML = [
"<table>",
" <thead>",
" <tr>",
" <th>" + text.tableId + "</th>",
" <th>" + text.tableName + "</th>",
" <th>" + text.tableEmail + "</th>",
" <th>" + text.tableAge + "</th>",
" <th>" + text.tableSegment + "</th>",
" <th>" + text.tableActions + "</th>",
" </tr>",
" </thead>",
" <tbody>",
state.users.map(function (user) {
const segment = user.age >= 18 ? text.segmentAdult : text.segmentMinor;
return [
" <tr>",
" <td>" + user.id + "</td>",
" <td>" + user.name + "</td>",
" <td>" + user.email + "</td>",
" <td>" + user.age + "</td>",
' <td><span class="segment">' + segment + "</span></td>",
" <td>",
' <button class="btn btn-secondary" onclick="openEditModal(' + user.id + ')">' + text.editButton + "</button>",
' <button class="btn btn-danger" onclick="removeUser(' + user.id + ')">' + text.deleteButton + "</button>",
" </td>",
" </tr>"
].join("");
}).join(""),
" </tbody>",
"</table>"
].join("");
}
function renderLanguage() {
applyTranslations(pageText());
renderModalText();
renderTable();
}
async function request(url, options) {
return window.learningShell.requestJson(url, options);
}
function setStatus(id, message, type) {
const element = document.getElementById(id);
element.textContent = message;
element.className = type ? "status " + type : "status";
}
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');
document.getElementById("userForm").reset();
document.getElementById("userId").value = "";
state.modalMode = "create";
renderModalText();
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');
const user = state.users.find(function (item) {
return 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;
state.modalMode = "edit";
renderModalText();
setStatus("formStatus", "");
document.getElementById("userModal").classList.add("active");
}
function closeModal() {
document.getElementById('userModal').classList.remove('active');
document.getElementById("userModal").classList.remove("active");
}
async function refreshDashboard(message = 'Dashboard updated.', type = 'success') {
async function refreshDashboard(message, type) {
try {
const [usersPayload, statsPayload] = await Promise.all([
request('/api/users'),
request('/api/users/stats')
const payloads = await Promise.all([
request("/api/users"),
request("/api/users/stats")
]);
state.users = usersPayload.data;
renderStats(statsPayload.data);
state.users = payloads[0].data;
renderStats(payloads[1].data);
renderTable();
setStatus('pageStatus', message, type);
setStatus("pageStatus", message, type || "success");
} catch (error) {
setStatus('pageStatus', error.message, 'error');
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
async function searchUsers(event) {
event.preventDefault();
const keyword = document.getElementById('searchInput').value.trim();
const keyword = document.getElementById("searchInput").value.trim();
const text = pageText();
try {
if (!keyword) {
await refreshDashboard('Showing all users.', 'success');
await refreshDashboard(text.showingAll, "success");
return;
}
const payload = await request(`/api/users/search?keyword=${encodeURIComponent(keyword)}`);
const payload = await request("/api/users/search?keyword=" + encodeURIComponent(keyword));
state.users = payload.data;
renderTable();
setStatus('pageStatus', `Search completed for "${keyword}".`, 'success');
setStatus("pageStatus", text.searchCompleted(keyword), "success");
} catch (error) {
setStatus('pageStatus', error.message, 'error');
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
async function clearSearch() {
document.getElementById('searchInput').value = '';
await refreshDashboard('Showing all users.', 'success');
document.getElementById("searchInput").value = "";
await refreshDashboard(pageText().showingAll, "success");
}
async function removeUser(id) {
if (!confirm(`Delete user #${id}?`)) return;
if (!confirm(pageText().confirmDelete(id))) {
return;
}
try {
await request(`/api/users/${id}`, { method: 'DELETE' });
await refreshDashboard('User deleted successfully.', 'success');
await request("/api/users/" + id, { method: "DELETE" });
await refreshDashboard(pageText().deleteSuccess, "success");
} catch (error) {
setStatus('pageStatus', error.message, 'error');
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
document.getElementById('userForm').addEventListener('submit', async (event) => {
document.getElementById("userForm").addEventListener("submit", async function (event) {
event.preventDefault();
const id = document.getElementById('userId').value;
const id = document.getElementById("userId").value;
const payload = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
age: Number(document.getElementById('userAge').value)
name: document.getElementById("userName").value,
email: document.getElementById("userEmail").value,
age: Number(document.getElementById("userAge").value)
};
try {
if (id) {
await request(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
await request("/api/users/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard('User updated successfully.', 'success');
await refreshDashboard(pageText().updateSuccess, "success");
} else {
await request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
await request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard('User created successfully.', 'success');
await refreshDashboard(pageText().createSuccess, "success");
}
} catch (error) {
if (error.details && typeof error.details === 'object') {
if (error.details && typeof error.details === "object") {
const firstIssue = Object.values(error.details)[0];
setStatus('formStatus', String(firstIssue), 'error');
setStatus("formStatus", String(firstIssue), "error");
} else {
setStatus('formStatus', error.message, 'error');
setStatus("formStatus", window.learningShell.describeError(error), "error");
}
}
});
refreshDashboard('Latest users loaded.', 'success');
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
refreshDashboard(pageText().latestLoaded, "success");
</script>
</body>
</html>

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Learning Login</title>
<style>
:root {
--bg: linear-gradient(135deg, #eef5ff 0%, #f8fff4 100%);
--card: rgba(255, 255, 255, 0.94);
--line: #dbe6f2;
--text: #122033;
--muted: #5d7288;
--brand: #177245;
--accent: #0f67b5;
--shadow: 0 20px 48px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(23, 114, 69, 0.13), transparent 30%),
radial-gradient(circle at bottom right, rgba(15, 103, 181, 0.12), transparent 24%),
var(--bg);
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.hero,
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero {
padding: 28px;
margin-bottom: 18px;
}
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(23, 114, 69, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p {
margin: 0;
color: var(--muted);
line-height: 1.8;
}
.layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
}
.card {
padding: 22px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
}
label {
font-size: 13px;
font-weight: 700;
color: #21384f;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
font: inherit;
}
.actions,
.chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn,
.btn-soft {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
font-weight: 700;
}
.btn {
color: #fff;
background: linear-gradient(135deg, var(--brand), #35a465);
}
.btn-soft {
color: var(--accent);
background: #eaf3ff;
}
.note-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.note {
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.5);
}
.status {
min-height: 22px;
margin-top: 12px;
color: var(--muted);
}
.status.error { color: #d64545; }
.status.success { color: #177245; }
pre {
margin: 16px 0 0;
min-height: 220px;
padding: 16px;
border-radius: 18px;
background: #0f1621;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
.chips span {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 920px) {
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventsLink"></a>
</div>
</section>
<div class="layout">
<section class="card">
<div class="eyebrow" data-i18n="loginBadge"></div>
<h2 data-i18n="loginTitle"></h2>
<p data-i18n="loginText"></p>
<div class="field">
<label for="username" data-i18n="usernameLabel"></label>
<input id="username" value="admin" autocomplete="username">
</div>
<div class="field">
<label for="password" data-i18n="passwordLabel"></label>
<input id="password" type="password" value="admin123" autocomplete="current-password">
</div>
<div class="actions" style="margin-top:18px;">
<button class="btn" type="button" id="loginButton" data-i18n="loginButton" onclick="login()"></button>
<button class="btn-soft" type="button" id="checkButton" data-i18n="checkButton" onclick="introspectToken()"></button>
<button class="btn-soft" type="button" id="logoutButton" data-i18n="logoutButton" onclick="logout()"></button>
</div>
<div class="status" id="statusBox"></div>
<pre id="resultBox"></pre>
</section>
<aside class="card">
<div class="eyebrow" data-i18n="guideBadge"></div>
<h2 data-i18n="guideTitle"></h2>
<div class="note-list">
<div class="note">
<strong data-i18n="note1Title"></strong>
<p data-i18n="note1Text"></p>
</div>
<div class="note">
<strong data-i18n="note2Title"></strong>
<p data-i18n="note2Text"></p>
</div>
<div class="note">
<strong data-i18n="note3Title"></strong>
<p data-i18n="note3Text"></p>
</div>
</div>
<div class="eyebrow" style="margin-top:20px;" data-i18n="accountBadge"></div>
<div class="chips" style="margin-top:12px;">
<span>admin / admin123</span>
<span>user / user123</span>
</div>
</aside>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "Spring \u5b66\u4e60\u767b\u5f55\u9875",
heroBadge: "\u5b66\u4e60\u9274\u6743\u5165\u53e3",
heroTitle: "\u5148\u5b8c\u6210\u6f14\u793a\u767b\u5f55\uff0c\u518d\u8fdb\u5165\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u3002",
heroText: "\u8fd9\u4e2a\u9875\u9762\u628a\u540e\u7aef\u5df2\u6709\u7684 JWT \u767b\u5f55\u80fd\u529b\u53d8\u6210\u771f\u6b63\u53ef\u89c1\u7684\u5b66\u4e60\u5165\u53e3\uff0c\u65b9\u4fbf\u4f60\u5728\u516c\u5f00 VPS \u73af\u5883\u91cc\u5148\u767b\u5f55\uff0c\u518d\u4f53\u9a8c\u7528\u6237\u3001AOP\u3001\u4e8b\u4ef6\u548c\u9ad8\u7ea7\u5b9e\u9a8c\u3002",
homeLink: "\u8fd4\u56de\u9996\u9875",
usersLink: "\u6253\u5f00\u7528\u6237\u5b9e\u9a8c",
aopLink: "\u6253\u5f00 AOP \u5b9e\u9a8c",
eventsLink: "\u6253\u5f00\u4e8b\u4ef6\u5b9e\u9a8c",
loginBadge: "\u767b\u5f55\u8868\u5355",
loginTitle: "\u4f7f\u7528\u6f14\u793a\u8d26\u53f7\u6362\u53d6 Bearer Token",
loginText: "\u767b\u5f55\u6210\u529f\u540e\uff0ctoken \u4f1a\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u672c\u5730\u3002\u4e4b\u540e\u672c\u9879\u76ee\u4e2d\u7684\u524d\u7aef\u9875\u9762\u4f1a\u81ea\u52a8\u643a\u5e26 Authorization \u5934\u8bbf\u95ee\u53d7\u4fdd\u62a4\u63a5\u53e3\u3002",
usernameLabel: "\u7528\u6237\u540d",
passwordLabel: "\u5bc6\u7801",
loginButton: "\u7acb\u5373\u767b\u5f55",
checkButton: "\u68c0\u67e5\u5f53\u524d\u4ee4\u724c",
logoutButton: "\u6e05\u9664\u4ee4\u724c",
guideBadge: "\u5b66\u4e60\u63d0\u793a",
guideTitle: "\u8fd9\u4e00\u6b65\u89e3\u51b3\u4e86\u4ec0\u4e48\u95ee\u9898",
note1Title: "1. \u8ba9\u767b\u5f55\u4e0d\u518d\u53ea\u662f\u540e\u7aef\u63a5\u53e3",
note1Text: "\u4e4b\u524d\u540e\u7aef\u63a5\u53e3\u5df2\u7ecf\u5b58\u5728\uff0c\u4f46\u9875\u9762\u91cc\u6ca1\u6709\u5165\u53e3\u3002\u73b0\u5728\u4f60\u53ef\u4ee5\u4ece\u524d\u7aef\u5b8c\u6210\u767b\u5f55\u3001\u4ee4\u724c\u6301\u4e45\u5316\u548c\u4ee4\u724c\u68c0\u67e5\u3002",
note2Title: "2. \u628a\u53d7\u4fdd\u62a4\u8def\u7531\u53d8\u6210\u53ef\u89c2\u5bdf\u6982\u5ff5",
note2Text: "\u767b\u5f55\u540e\u518d\u8bbf\u95ee /api/users/**\u3001/aop/**\u3001/learn/** \u548c /api/lab/**\uff0c\u5c31\u80fd\u6e05\u6670\u5bf9\u6bd4\u767b\u5f55\u524d\u540e\u7684\u884c\u4e3a\u5dee\u5f02\u3002",
note3Title: "3. \u4e3a\u4e2d\u82f1\u5207\u6362\u9884\u7559\u7edf\u4e00\u5165\u53e3",
note3Text: "\u9875\u9762\u9876\u90e8\u5df2\u7ecf\u63a5\u4e0a\u5171\u4eab\u5de5\u4f5c\u53f0\u811a\u672c\uff0c\u8bed\u8a00\u504f\u597d\u548c\u767b\u5f55\u72b6\u6001\u4f1a\u5728\u9875\u9762\u95f4\u540c\u6b65\u3002",
accountBadge: "\u6f14\u793a\u8d26\u53f7",
ready: "\u51c6\u5907\u5c31\u7eea\u3002\u4f60\u53ef\u4ee5\u76f4\u63a5\u4f7f\u7528\u9ed8\u8ba4\u8d26\u53f7\u767b\u5f55\u3002",
loginSuccess: "\u767b\u5f55\u6210\u529f\uff0c\u4ee4\u724c\u5df2\u4fdd\u5b58\u5230\u672c\u5730\u3002",
logoutSuccess: "\u672c\u5730\u4ee4\u724c\u5df2\u6e05\u9664\u3002",
introspectSuccess: "\u4ee4\u724c\u81ea\u68c0\u5b8c\u6210\u3002",
missingToken: "\u5f53\u524d\u6ca1\u6709\u53ef\u68c0\u67e5\u7684\u4ee4\u724c\uff0c\u8bf7\u5148\u767b\u5f55\u3002"
},
en: {
title: "Spring Learning Login",
heroBadge: "Auth Entry",
heroTitle: "Complete demo login before entering protected labs.",
heroText: "This page turns the existing backend JWT capability into a visible learning entry so you can authenticate first and then explore users, AOP, events, and advanced labs on a public VPS.",
homeLink: "Back home",
usersLink: "Open user lab",
aopLink: "Open AOP lab",
eventsLink: "Open event lab",
loginBadge: "Login Form",
loginTitle: "Exchange demo credentials for a Bearer token",
loginText: "After login, the token is stored locally. Frontend pages in this project will automatically attach the Authorization header when calling protected APIs.",
usernameLabel: "Username",
passwordLabel: "Password",
loginButton: "Login now",
checkButton: "Inspect token",
logoutButton: "Clear token",
guideBadge: "Study Notes",
guideTitle: "What this step solves",
note1Title: "1. Login is no longer only a backend endpoint",
note1Text: "The backend endpoint already existed, but there was no visible entry on the frontend. Now you can complete login, token persistence, and token inspection in the browser.",
note2Title: "2. Protected routes become observable",
note2Text: "After login, revisit /api/users/**, /aop/**, /learn/**, and /api/lab/** to compare protected behavior.",
note3Title: "3. Shared groundwork for language switching",
note3Text: "The shared workspace script is already mounted at the top of the page, so language preference and login state now sync across pages.",
accountBadge: "Demo Accounts",
ready: "Ready. You can sign in with the default accounts.",
loginSuccess: "Login succeeded and the token was saved locally.",
logoutSuccess: "Local token has been cleared.",
introspectSuccess: "Token introspection completed.",
missingToken: "There is no token to inspect yet. Please log in first."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
const resultBox = document.getElementById("resultBox");
if (!resultBox.dataset.state || resultBox.dataset.state === "idle") {
resultBox.textContent = text.ready;
resultBox.dataset.state = "idle";
}
}
function setStatus(message, type) {
const box = document.getElementById("statusBox");
box.textContent = message;
box.className = type ? "status " + type : "status";
}
async function login() {
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
try {
const payload = await window.learningShell.requestJson("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
});
window.learningShell.saveAuth(payload.data.token, payload.data.username);
setStatus(pageText().loginSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify(payload, null, 2);
document.getElementById("resultBox").dataset.state = "live";
} catch (error) {
setStatus(window.learningShell.describeError(error), "error");
document.getElementById("resultBox").textContent = JSON.stringify(
error.payload || { message: error.message },
null,
2
);
document.getElementById("resultBox").dataset.state = "live";
}
}
async function introspectToken() {
const token = window.learningShell.getToken();
if (!token) {
setStatus(pageText().missingToken, "error");
return;
}
try {
const payload = await window.learningShell.requestJson("/api/auth/introspect");
setStatus(pageText().introspectSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify(payload, null, 2);
document.getElementById("resultBox").dataset.state = "live";
} catch (error) {
setStatus(window.learningShell.describeError(error), "error");
document.getElementById("resultBox").textContent = JSON.stringify(
error.payload || { message: error.message },
null,
2
);
document.getElementById("resultBox").dataset.state = "live";
}
}
function logout() {
window.learningShell.clearAuth();
setStatus(pageText().logoutSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify({ cleared: true }, null, 2);
document.getElementById("resultBox").dataset.state = "live";
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>

View File

@@ -3,220 +3,347 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AOP 切面编程 - Spring Boot</title>
<title>Spring AOP 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; border-bottom: 2px solid #6DB33F; padding-bottom: 10px; margin: 20px 0 15px; }
h3 { color: #6DB33F; margin: 15px 0 10px; }
.card { background: white; padding: 25px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.btn { display: inline-block; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 500; cursor: pointer; border: none; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-info { background: #17a2b8; color: white; }
code { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-family: 'Fira Code', monospace; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
pre code { background: none; color: inherit; }
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; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #6DB33F; }
.warn { background: #fff3cd; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #ffc107; }
.result-box { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; font-family: monospace; white-space: pre-wrap; margin-top: 10px; }
.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; }
:root {
--bg: #eef7ef;
--panel: rgba(255,255,255,0.95);
--line: #d8e7d8;
--text: #102033;
--muted: #5c7184;
--brand: #3f8f2c;
--accent: #0f67b5;
--shadow: 0 20px 48px rgba(16, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(63, 143, 44, 0.14), transparent 30%),
radial-gradient(circle at bottom left, rgba(15, 103, 181, 0.1), transparent 24%),
var(--bg);
}
.page { max-width: 1280px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.8; }
.actions, .toolbar, .flow, .chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
}
.btn { background: linear-gradient(135deg, var(--brand), #62b049); color: #fff; }
.btn-soft { background: #eaf3ff; color: var(--accent); }
.grid {
display: grid;
gap: 18px;
grid-template-columns: 420px minmax(0, 1fr);
}
.card { padding: 22px; }
.list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.list-item, .flow-step {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.45);
}
.flow {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 14px;
}
.pill {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
pre {
margin: 0;
min-height: 280px;
padding: 16px;
border-radius: 18px;
background: #0f1621;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
@media (max-width: 1060px) {
.grid, .flow { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="nav">
<a href="/">← 返回首页</a>
<a href="/users.html">用户管理</a>
<a href="/events.html">事件机制</a>
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/access.html" data-i18n="loginLink"></a>
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventsLink"></a>
</div>
</section>
<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 class="grid">
<aside class="card">
<div class="eyebrow" data-i18n="pathBadge"></div>
<h2 data-i18n="pathTitle"></h2>
<div class="list">
<div class="list-item">
<strong data-i18n="step1Title"></strong>
<p data-i18n="step1Text"></p>
</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 class="list-item">
<strong data-i18n="step2Title"></strong>
<p data-i18n="step2Text"></p>
</div>
<h2>📚 AOP 核心概念</h2>
<div class="card">
<h3>1. 什么是 AOP</h3>
<p>AOP (Aspect-Oriented Programming) 面向切面编程,是将<strong>横切关注点</strong><strong>业务逻辑</strong>分离的编程范式。</p>
<div class="tip">
<strong>横切关注点:</strong>日志、事务、安全、性能监控等,散布在多个模块中的公共功能。
<div class="list-item">
<strong data-i18n="step3Title"></strong>
<p data-i18n="step3Text"></p>
</div>
</div>
<div class="card">
<h3>2. 核心术语</h3>
<table>
<tr><th>术语</th><th>说明</th></tr>
<tr><td><strong>Aspect (切面)</strong></td><td>横切关注点的模块化封装</td></tr>
<tr><td><strong>JoinPoint (连接点)</strong></td><td>程序执行的某个点(方法调用、异常抛出等)</td></tr>
<tr><td><strong>Pointcut (切入点)</strong></td><td>匹配连接点的表达式</td></tr>
<tr><td><strong>Advice (通知)</strong></td><td>在连接点执行的动作</td></tr>
<tr><td><strong>Weaving (织入)</strong></td><td>将切面应用到目标对象的过程</td></tr>
</table>
<div class="eyebrow" style="margin-top:18px;" data-i18n="adviceBadge"></div>
<div class="chips" style="margin-top:12px;">
<span class="pill">@Before</span>
<span class="pill">@After</span>
<span class="pill">@AfterReturning</span>
<span class="pill">@AfterThrowing</span>
<span class="pill">@Around</span>
</div>
<div class="card">
<h3>3. 五种通知类型</h3>
<table>
<tr><th>注解</th><th>执行时机</th><th>用途</th></tr>
<tr><td><code>@Before</code></td><td>方法执行前</td><td>参数校验、权限检查</td></tr>
<tr><td><code>@After</code></td><td>方法执行后(无论成功或异常)</td><td>资源清理</td></tr>
<tr><td><code>@AfterReturning</code></td><td>方法成功返回后</td><td>结果处理、日志记录</td></tr>
<tr><td><code>@AfterThrowing</code></td><td>方法抛出异常后</td><td>异常处理、错误日志</td></tr>
<tr><td><code>@Around</code></td><td>环绕方法执行(最强大)</td><td>性能统计、事务管理</td></tr>
</table>
<div class="flow">
<div class="flow-step"><strong data-i18n="flow1Title"></strong><p data-i18n="flow1Text"></p></div>
<div class="flow-step"><strong data-i18n="flow2Title"></strong><p data-i18n="flow2Text"></p></div>
<div class="flow-step"><strong data-i18n="flow3Title"></strong><p data-i18n="flow3Text"></p></div>
<div class="flow-step"><strong data-i18n="flow4Title"></strong><p data-i18n="flow4Text"></p></div>
<div class="flow-step"><strong data-i18n="flow5Title"></strong><p data-i18n="flow5Text"></p></div>
</div>
</aside>
<main class="card">
<div class="eyebrow" data-i18n="liveBadge"></div>
<h2 data-i18n="liveTitle"></h2>
<p data-i18n="liveText"></p>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="loadUsers()" data-i18n="loadUsersButton"></button>
<button class="btn-soft" type="button" onclick="loadUserStats()" data-i18n="loadStatsButton"></button>
<button class="btn-soft" type="button" onclick="sendInvalidUser()" data-i18n="invalidUserButton"></button>
<button class="btn-soft" type="button" onclick="loadAopStats()" data-i18n="aopStatsButton"></button>
</div>
<h2>💻 代码示例</h2>
<div class="card">
<h3>日志切面示例</h3>
<pre><code>@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);
}
}</code></pre>
<div class="list" style="margin-top:18px;">
<div class="list-item">
<strong data-i18n="observeTitle"></strong>
<p data-i18n="observeText"></p>
</div>
<div class="list-item">
<strong data-i18n="codeTitle"></strong>
<p data-i18n="codeText"></p>
</div>
</div>
<div class="card">
<h3>性能监控切面 (@Around)</h3>
<pre><code>@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;
}
}
}</code></pre>
<pre id="resultBox"></pre>
</main>
</div>
</div>
<div class="card">
<h3>切入点表达式语法</h3>
<pre><code>// 匹配任意公共方法
execution(public * *(..))
// 匹配 com.example 包下所有方法
execution(* com.example.*.*(..))
// 匹配 Controller 层所有方法
execution(* com.example.demo.controller.*.*(..))
// 匹配所有 Service 层的 save 开头的方法
execution(* com.example.demo.service.*.save*(..))
// 匹配带有 @Service 注解的类
@within(org.springframework.stereotype.Service)
// 匹配带有自定义注解的方法
@annotation(com.example.demo.aop.RateLimited)</code></pre>
</div>
<h2>🎯 实际应用场景</h2>
<div class="card">
<table>
<tr><th>场景</th><th>实现方式</th></tr>
<tr><td>日志记录</td><td>@Before + @AfterReturning</td></tr>
<tr><td>性能监控</td><td>@Around</td></tr>
<tr><td>事务管理</td><td>@Around (Spring 已内置)</td></tr>
<tr><td>权限检查</td><td>@Before</td></tr>
<tr><td>限流控制</td><td>@Around + 自定义注解</td></tr>
<tr><td>缓存</td><td>@Around (Spring Cache)</td></tr>
</table>
</div>
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script src="/learning-shell.js"></script>
<script>
const AOP_TASK_KEY = 'task.aop.done';
const I18N = {
zh: {
title: "Spring AOP 实验",
heroBadge: "AOP 实验",
heroTitle: "把横切行为从抽象概念变成可观察现象。",
heroText: "这一页专门展示学生最容易忽略的部分:通知是怎样包裹控制器和服务方法的、耗时如何被收集、以及校验失败为什么同样属于可观测的运行时行为。",
loginLink: "打开登录实验",
homeLink: "返回首页",
usersLink: "打开用户实验",
eventsLink: "打开事件实验",
pathBadge: "实验路径",
pathTitle: "推荐的 AOP 学习顺序",
step1Title: "1. 先触发控制器调用",
step1Text: "运行用户列表和统计接口,确保控制器和服务层方法都被执行到。",
step2Title: "2. 再触发一次校验失败",
step2Text: "发送一个非法用户载荷,对比失败请求和成功请求在切面统计上的表现。",
step3Title: "3. 最后查看切面输出",
step3Text: "加载统计接口,对比调用次数、总耗时和平均耗时。",
adviceBadge: "通知地图",
flow1Title: "Before",
flow1Text: "观察入参或鉴权上下文。",
flow2Title: "Around",
flow2Text: "开启计时器并继续执行连接点。",
flow3Title: "Controller",
flow3Text: "把业务交给服务层。",
flow4Title: "Service",
flow4Text: "执行具体业务规则。",
flow5Title: "After*",
flow5Text: "记录结果或失败细节。",
liveBadge: "实时运行区",
liveTitle: "发起请求并查看采集到的指标",
liveText: "下面的按钮会帮你制造真实流量,再直接查看切面输出,不需要再切换到别的工具。",
loadUsersButton: "加载用户",
loadStatsButton: "加载用户统计",
invalidUserButton: "发送非法用户",
aopStatsButton: "加载 AOP 统计",
observeTitle: "控制台里该观察什么",
observeText: "重点看控制器和服务层的耗时输出,对比成功路径和失败路径,理解 around 通知为什么仍然会记录方法执行时长。",
codeTitle: "代码里该先看什么",
codeText: "先读 PerformanceAspect再回头比对它包裹的用户接口。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
placeholder: "请先运行上方任意实验,再查看实时 JSON 输出。"
},
en: {
title: "Spring AOP Lab",
heroBadge: "AOP Lab",
heroTitle: "Make cross-cutting behavior visible instead of abstract.",
heroText: "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 observable runtime behavior.",
loginLink: "Open login lab",
homeLink: "Back home",
usersLink: "Open user lab",
eventsLink: "Open event lab",
pathBadge: "Experiment Path",
pathTitle: "Recommended AOP sequence",
step1Title: "1. Trigger controller calls",
step1Text: "Run the list and stats endpoints so both controller and service methods execute.",
step2Title: "2. Trigger a validation failure",
step2Text: "Send an invalid user payload and compare the failed request with successful ones in the aspect metrics.",
step3Title: "3. Inspect aspect output",
step3Text: "Load the stats endpoint and compare call counts, total time, and average time.",
adviceBadge: "Advice Map",
flow1Title: "Before",
flow1Text: "Inspect inputs or auth context.",
flow2Title: "Around",
flow2Text: "Start the timer and continue the join point.",
flow3Title: "Controller",
flow3Text: "Delegate work to the service.",
flow4Title: "Service",
flow4Text: "Apply business logic.",
flow5Title: "After*",
flow5Text: "Record result or failure details.",
liveBadge: "Live Runs",
liveTitle: "Run requests and inspect collected metrics",
liveText: "The buttons below create real traffic and then surface the aspect output without switching tools.",
loadUsersButton: "Load users",
loadStatsButton: "Load user stats",
invalidUserButton: "Send invalid user",
aopStatsButton: "Load AOP stats",
observeTitle: "What to observe in the console",
observeText: "Look for controller and service timing lines. Compare success and failure paths to see why around advice still records method duration.",
codeTitle: "What to inspect in code",
codeText: "Read PerformanceAspect first, then compare it with the user endpoints that it wraps.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
placeholder: "Run one of the experiments above to inspect live JSON output."
}
};
function toggleAopTaskDone(el) {
localStorage.setItem(AOP_TASK_KEY, el.checked ? '1' : '0');
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function initAopTaskState() {
const done = localStorage.getItem(AOP_TASK_KEY) === '1';
const checkbox = document.getElementById('aopTaskDone');
if (checkbox) checkbox.checked = done;
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
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();
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
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);
} catch (e) {
box.textContent = '错误: ' + e.message;
box.dataset.state = "live";
} catch (error) {
box.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
box.dataset.state = "live";
}
}
async function loadStats() {
const res = await fetch('/aop/stats');
const data = await res.json();
document.getElementById('statsResult').textContent = JSON.stringify(data, null, 2);
async function loadUsers() {
await renderRequest("/api/users");
}
initAopTaskState();
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();
</script>
</body>
</html>

View File

@@ -3,251 +3,357 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>事件机制 - Spring Boot</title>
<title>Spring Event 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; border-bottom: 2px solid #6DB33F; padding-bottom: 10px; margin: 20px 0 15px; }
h3 { color: #6DB33F; margin: 15px 0 10px; }
.card { background: white; padding: 25px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.btn { display: inline-block; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 500; cursor: pointer; border: none; margin: 5px; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-info { background: #17a2b8; color: white; }
.btn-warning { background: #ffc107; color: #333; }
code { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-family: 'Fira Code', monospace; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
pre code { background: none; color: inherit; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #6DB33F; }
.result-box { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; font-family: monospace; white-space: pre-wrap; margin-top: 10px; max-height: 300px; overflow-y: auto; }
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; }
.event-flow { display: flex; align-items: center; justify-content: center; margin: 20px 0; }
.event-flow span { padding: 15px 25px; background: #6DB33F; color: white; border-radius: 8px; margin: 0 10px; }
.event-flow .arrow { font-size: 24px; color: #6DB33F; }
.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; }
:root {
--bg: #fff4ea;
--panel: rgba(255,255,255,0.95);
--line: #eed9c9;
--text: #132238;
--muted: #6a7484;
--brand: #dd6c1f;
--accent: #0f67b5;
--shadow: 0 22px 52px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(221, 108, 31, 0.16), transparent 28%),
radial-gradient(circle at bottom left, rgba(15, 103, 181, 0.1), transparent 22%),
var(--bg);
}
.page { max-width: 1280px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(221, 108, 31, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.8; }
.grid {
display: grid;
gap: 18px;
grid-template-columns: 420px minmax(0, 1fr);
}
.card { padding: 22px; }
.list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.item, .timeline-step {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.46);
}
.toolbar, .actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
}
.btn { background: linear-gradient(135deg, var(--brand), #f39a55); color: #fff; }
.btn-soft { background: #edf5ff; color: var(--accent); }
.fields {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 14px;
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #22394f;
}
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
}
.timeline {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
pre {
margin: 0;
min-height: 280px;
padding: 16px;
border-radius: 18px;
background: #111926;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
@media (max-width: 1060px) {
.grid, .timeline, .fields { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="nav">
<a href="/">← 返回首页</a>
<a href="/users.html">用户管理</a>
<a href="/aop.html">AOP 切面</a>
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/access.html" data-i18n="loginLink"></a>
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
</div>
</section>
<div class="grid">
<aside class="card">
<div class="eyebrow" data-i18n="storyBadge"></div>
<h2 data-i18n="storyTitle"></h2>
<div class="timeline">
<div class="timeline-step"><strong data-i18n="timeline1Title"></strong><p data-i18n="timeline1Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline2Title"></strong><p data-i18n="timeline2Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline3Title"></strong><p data-i18n="timeline3Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline4Title"></strong><p data-i18n="timeline4Text"></p></div>
</div>
<h1>📡 Spring 事件机制</h1>
<div class="list">
<div class="item">
<strong data-i18n="exp1Title"></strong>
<p data-i18n="exp1Text"></p>
</div>
<div class="item">
<strong data-i18n="exp2Title"></strong>
<p data-i18n="exp2Text"></p>
</div>
<div class="item">
<strong data-i18n="pairingTitle"></strong>
<p data-i18n="pairingText"></p>
</div>
</div>
</aside>
<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>
<main class="card">
<div class="eyebrow" data-i18n="liveBadge"></div>
<h2 data-i18n="liveTitle"></h2>
<p data-i18n="liveText"></p>
<div class="fields">
<label>
<span data-i18n="eventTypeLabel"></span>
<select id="eventType">
<option value="LOGIN">LOGIN</option>
<option value="CREATED">CREATED</option>
<option value="UPDATED">UPDATED</option>
<option value="DELETED">DELETED</option>
</select>
</label>
<label>
<span data-i18n="userIdLabel"></span>
<input id="userId" value="21">
</label>
<label>
<span data-i18n="userNameLabel"></span>
<input id="userName" value="observer-demo">
</label>
</div>
<div class="card">
<h3>🎉 事件发布演示</h3>
<p>模拟用户登录事件,观察事件发布和监听过程</p>
<div style="margin: 15px 0;">
<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 class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="publishEvent()" data-i18n="publishButton"></button>
<button class="btn-soft" type="button" onclick="loadHistory()" data-i18n="historyButton"></button>
<button class="btn-soft" type="button" onclick="loadInfo()" data-i18n="notesButton"></button>
</div>
<h2>🔄 事件机制流程</h2>
<div class="event-flow">
<span>发布者</span>
<span class="arrow"></span>
<span>ApplicationEventPublisher</span>
<span class="arrow"></span>
<span>事件</span>
<span class="arrow"></span>
<span>监听者</span>
<div class="list" style="margin-top:18px;">
<div class="item">
<strong data-i18n="hintTitle"></strong>
<p data-i18n="hintText"></p>
</div>
</div>
<div class="tip">
<strong>核心优势:</strong>
<ul style="margin-top: 10px; padding-left: 20px;">
<li><strong>解耦:</strong>发布者和监听者互不依赖</li>
<li><strong>扩展:</strong>新增监听器无需修改发布者</li>
<li><strong>异步:</strong>耗时操作不阻塞主流程</li>
</ul>
<pre id="resultBox"></pre>
</main>
</div>
</div>
<h2>💻 代码实现</h2>
<div class="card">
<h3>1. 定义事件</h3>
<pre><code>public class UserEvent {
public enum Type { CREATED, UPDATED, DELETED, LOGIN }
private Type type;
private Long userId;
private String userName;
private LocalDateTime timestamp;
// constructor, getters...
}</code></pre>
</div>
<div class="card">
<h3>2. 发布事件</h3>
<pre><code>@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);
}
}</code></pre>
</div>
<div class="card">
<h3>3. 监听事件</h3>
<pre><code>@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) {
// 发送邮件...
}
}</code></pre>
</div>
<div class="card">
<h3>4. 控制器中使用</h3>
<pre><code>@RestController
@RequestMapping("/aop")
public class AopEventController {
@Autowired
private UserEventPublisher eventPublisher;
@PostMapping("/event/publish")
public Map&lt;String, Object&gt; publishEvent(
@RequestParam Long userId,
@RequestParam String userName) {
// 发布事件
eventPublisher.publishUserLogin(userId, userName);
return Map.of(
"message", "事件已发布",
"userId", userId,
"userName", userName
);
}
}</code></pre>
</div>
<h2>🎯 应用场景</h2>
<div class="card">
<table>
<tr><th>场景</th><th>事件类型</th><th>处理逻辑</th></tr>
<tr><td>用户注册</td><td>UserCreatedEvent</td><td>发送欢迎邮件、初始化数据</td></tr>
<tr><td>订单创建</td><td>OrderCreatedEvent</td><td>扣库存、发送通知</td></tr>
<tr><td>支付成功</td><td>PaymentSuccessEvent</td><td>更新订单状态、发送短信</td></tr>
<tr><td>用户登录</td><td>UserLoginEvent</td><td>记录登录日志、更新在线状态</td></tr>
</table>
</div>
<div class="card">
<h3>💡 最佳实践</h3>
<ul style="line-height: 2; padding-left: 20px;">
<li>事件类应该是<strong>不可变</strong>的(只读属性)</li>
<li>使用 <code>@Async</code> 处理耗时操作</li>
<li>避免在监听器中抛出异常</li>
<li>使用 <code>condition</code> 过滤不需要的事件</li>
<li>复杂场景考虑使用消息队列RabbitMQ/Kafka</li>
</ul>
</div>
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script src="/learning-shell.js"></script>
<script>
const EVENT_TASK_KEY = 'task.event.done';
const I18N = {
zh: {
title: "Spring 事件实验",
heroBadge: "事件实验",
heroTitle: "通过可视化时间线理解发布者与监听器的解耦。",
heroText: "这个页面把平时隐藏起来的行为展现出来:请求可以先返回,而监听器还会在后台继续处理。你可以连续发布多种事件,然后再查看共享事件历史。",
loginLink: "打开登录实验",
homeLink: "返回首页",
aopLink: "打开 AOP 实验",
usersLink: "打开用户实验",
storyBadge: "事件故事线",
storyTitle: "应该重点观察什么",
timeline1Title: "Controller",
timeline1Text: "接收事件发布请求。",
timeline2Title: "Publisher",
timeline2Text: "组装 UserEvent 并发出事件。",
timeline3Title: "Listeners",
timeline3Text: "记录历史并执行异步后续工作。",
timeline4Title: "History API",
timeline4Text: "帮助你回看刚刚到底发生了什么。",
exp1Title: "实验 1",
exp1Text: "用不同用户连续发布两次 LOGIN确认历史记录会增长但控制器代码无需改动。",
exp2Title: "实验 2",
exp2Text: "发布 CREATED 再刷新历史,观察同一个入口如何支持多种监听路径。",
pairingTitle: "代码联读建议",
pairingText: "把 UserEventPublisher 和 UserEventListener 放在一起读,会更容易看出解耦边界。",
liveBadge: "实时事件台",
liveTitle: "发布事件并查看历史",
liveText: "通过这个表单发布事件,再刷新历史,观察类型、用户、说明和时间戳。",
eventTypeLabel: "事件类型",
userIdLabel: "用户 ID",
userNameLabel: "用户名",
publishButton: "发布事件",
historyButton: "加载历史",
notesButton: "加载事件说明",
hintTitle: "理解提示",
hintText: "如果控制器已经返回,但历史还在继续增长,你看到的就是解耦后的后续行为。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
placeholder: "先发布一个事件,或直接加载历史查看实时输出。"
},
en: {
title: "Spring Event Lab",
heroBadge: "Event Lab",
heroTitle: "Understand publisher-listener decoupling through a visible event timeline.",
heroText: "This page surfaces what normally stays hidden: a request can return quickly while listeners keep reacting in the background. Publish multiple event types and then inspect shared event history.",
loginLink: "Open login lab",
homeLink: "Back home",
aopLink: "Open AOP lab",
usersLink: "Open user lab",
storyBadge: "Event Story",
storyTitle: "What to watch for",
timeline1Title: "Controller",
timeline1Text: "Receives the publish request.",
timeline2Title: "Publisher",
timeline2Text: "Builds a UserEvent and emits it.",
timeline3Title: "Listeners",
timeline3Text: "Track history and optional async work.",
timeline4Title: "History API",
timeline4Text: "Lets you inspect what actually happened.",
exp1Title: "Experiment 1",
exp1Text: "Publish LOGIN twice with different users and confirm the history list grows without changing controller code.",
exp2Title: "Experiment 2",
exp2Text: "Publish CREATED and then refresh history to see the same endpoint support multiple listener paths.",
pairingTitle: "Code pairing tip",
pairingText: "Read UserEventPublisher and UserEventListener side by side to see the decoupling boundary.",
liveBadge: "Live Event Console",
liveTitle: "Publish events and inspect history",
liveText: "Use the form to publish events, then reload history to inspect type, user, detail, and timestamp.",
eventTypeLabel: "Event type",
userIdLabel: "User id",
userNameLabel: "User name",
publishButton: "Publish event",
historyButton: "Load history",
notesButton: "Load event notes",
hintTitle: "Interpretation hint",
hintText: "If the controller returns immediately but history keeps growing, you are seeing decoupled follow-up behavior in action.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
placeholder: "Publish an event or load history to inspect live output."
}
};
function toggleEventTaskDone(el) {
localStorage.setItem(EVENT_TASK_KEY, el.checked ? '1' : '0');
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function initEventTaskState() {
const done = localStorage.getItem(EVENT_TASK_KEY) === '1';
const checkbox = document.getElementById('eventTaskDone');
if (checkbox) checkbox.checked = done;
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
async function demoEventError() {
const resultBox = document.getElementById('eventResult');
function renderLanguage() {
const text = pageText();
applyTranslations(text);
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 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;
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 userId = document.getElementById('userId').value;
const userName = document.getElementById('userName').value;
try {
const res = await fetch(`/aop/event/publish?userId=${userId}&userName=${encodeURIComponent(userName)}`, {
method: 'POST'
});
const data = await res.json();
const resultBox = document.getElementById('eventResult');
resultBox.textContent = `[${new Date().toLocaleTimeString()}] 事件已发布\n\n` +
JSON.stringify(data, null, 2) + '\n\n' +
'📊 查看控制台日志可以看到监听器的输出:\n' +
'[EventPublisher] 发布事件: LOGIN - ' + userName + '\n' +
'[EventListener] 收到事件: LOGIN - 用户: ' + userName + '\n' +
'[LoginTracker] 记录用户登录: ' + userName;
} catch (e) {
document.getElementById('eventResult').textContent = '错误: ' + e.message;
}
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" });
}
initEventTaskState();
async function loadHistory() {
await renderRequest("/aop/event/history");
}
async function loadInfo() {
await renderRequest("/aop/event");
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>

View File

@@ -3,223 +3,643 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Boot 学习中心</title>
<title>Spring Boot Learning Cockpit</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: #eef5ff;
--panel: rgba(255, 255, 255, 0.94);
--line: #d8e4f0;
--text: #122033;
--muted: #5d7288;
--brand: #177245;
--accent: #0f67b5;
--warm: #f08c2b;
--shadow: 0 22px 54px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(23, 114, 69, 0.14), transparent 28%),
radial-gradient(circle at bottom right, rgba(15, 103, 181, 0.14), transparent 26%),
var(--bg);
}
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; }
.page { max-width: 1380px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(23, 114, 69, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.8; }
.hero-grid, .workspace, .triple {
display: grid;
gap: 18px;
}
.hero-grid {
grid-template-columns: 1.3fr 0.9fr;
align-items: start;
}
.actions, .chip-list, .toolbar, .demo-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
font-weight: 700;
}
.btn {
color: #fff;
background: linear-gradient(135deg, var(--brand), #35a465);
}
.btn-soft {
color: var(--accent);
background: #eaf3ff;
}
.card { padding: 22px; }
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.stat {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.5);
}
.stat span { display: block; color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.stat strong { font-size: 28px; }
.workspace {
grid-template-columns: 380px minmax(0, 1fr);
align-items: start;
}
.triple {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.flow {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.step, .lab, .note, .code-card {
border: 1px solid var(--line);
border-radius: 20px;
padding: 16px;
background: rgba(255,255,255,0.45);
}
.step strong, .lab strong, .code-card strong { display: block; margin-bottom: 8px; }
.lab small, .step small, .note small { color: var(--muted); display: block; line-height: 1.7; }
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
label { font-size: 13px; font-weight: 700; color: #21384f; }
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
}
.console {
margin-top: 14px;
border-radius: 18px;
overflow: hidden;
border: 1px solid #d6e2ef;
background: #0e1723;
}
.console-head {
padding: 12px 14px;
border-bottom: 1px solid rgba(216, 228, 240, 0.14);
color: #bdd2ea;
font-weight: 700;
}
pre {
margin: 0;
min-height: 240px;
padding: 16px;
color: #dceaff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
.pill {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.list-item {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.45);
}
.list-item p { margin-top: 8px; }
@media (max-width: 1180px) {
.hero-grid, .workspace, .triple, .flow, .stats { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 780px) {
.page { padding: 14px; }
.hero-grid, .workspace, .triple, .flow, .stats { grid-template-columns: 1fr; }
}
</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 class="page">
<section class="hero">
<div class="hero-grid">
<div>
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/access.html" data-i18n="loginLabLink"></a>
<a class="btn" href="/users.html" data-i18n="userLabLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLabLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventLabLink"></a>
<a class="btn-soft" href="/learn" data-i18n="learnApiLink"></a>
</div>
<div class="stats">
<div class="stat"><span data-i18n="statLabs"></span><strong>5</strong></div>
<div class="stat"><span data-i18n="statLayers"></span><strong>6</strong></div>
<div class="stat"><span data-i18n="statPages"></span><strong>4</strong></div>
<div class="stat"><span data-i18n="statPaths"></span><strong>2+</strong></div>
</div>
</div>
<div class="card" style="padding:0;">
<div class="card" style="box-shadow:none; border:none; border-radius:28px;">
<div class="eyebrow" data-i18n="startBadge"></div>
<h2 data-i18n="startTitle"></h2>
<div class="list">
<div class="list-item">
<strong data-i18n="startStep1Title"></strong>
<p data-i18n="startStep1Text"></p>
</div>
<div class="list-item">
<strong data-i18n="startStep2Title"></strong>
<p data-i18n="startStep2Text"></p>
</div>
<div class="list-item">
<strong data-i18n="startStep3Title"></strong>
<p data-i18n="startStep3Text"></p>
</div>
</div>
</div>
</div>
</div>
</section>
<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>
<a href="/learn" class="feature-item">
<h4>🔐 鉴权演示(学习用)</h4>
<p>最小 JWT 流程:登录、携带 Token、访问受保护接口</p>
</a>
<section class="card" style="margin-bottom:18px;">
<div class="eyebrow" data-i18n="archBadge"></div>
<h2 data-i18n="archTitle"></h2>
<div class="flow">
<div class="step"><strong data-i18n="flow1Title"></strong><small data-i18n="flow1Text"></small></div>
<div class="step"><strong data-i18n="flow2Title"></strong><small data-i18n="flow2Text"></small></div>
<div class="step"><strong data-i18n="flow3Title"></strong><small data-i18n="flow3Text"></small></div>
<div class="step"><strong data-i18n="flow4Title"></strong><small data-i18n="flow4Text"></small></div>
<div class="step"><strong data-i18n="flow5Title"></strong><small data-i18n="flow5Text"></small></div>
<div class="step"><strong data-i18n="flow6Title"></strong><small data-i18n="flow6Text"></small></div>
</div>
</section>
<div class="workspace">
<aside class="card">
<div class="eyebrow" data-i18n="tracksBadge"></div>
<h2 data-i18n="tracksTitle"></h2>
<div class="list">
<div class="lab">
<strong data-i18n="track1Title"></strong>
<small data-i18n="track1Text"></small>
</div>
<div class="lab">
<strong data-i18n="track2Title"></strong>
<small data-i18n="track2Text"></small>
</div>
<div class="lab">
<strong data-i18n="track3Title"></strong>
<small data-i18n="track3Text"></small>
</div>
<div class="lab">
<strong data-i18n="track4Title"></strong>
<small data-i18n="track4Text"></small>
</div>
<div class="lab">
<strong data-i18n="track5Title"></strong>
<small data-i18n="track5Text"></small>
</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 class="eyebrow" style="margin-top:20px;" data-i18n="readingBadge"></div>
<h2 data-i18n="readingTitle"></h2>
<div class="list">
<div class="code-card">
<strong data-i18n="reading1Title"></strong>
<small data-i18n="reading1Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading2Title"></strong>
<small data-i18n="reading2Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading3Title"></strong>
<small data-i18n="reading3Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading4Title"></strong>
<small data-i18n="reading4Text"></small>
</div>
</div>
</aside>
<main class="triple">
<section class="card" style="grid-column: 1 / -1;">
<div class="eyebrow" data-i18n="explorerBadge"></div>
<h2 data-i18n="explorerTitle"></h2>
<p data-i18n="explorerText"></p>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="loadEndpoint('/actuator/health')" data-i18n="healthButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users/stats')" data-i18n="userStatsButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/learn')" data-i18n="learnButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/aop/stats')" data-i18n="aopStatsButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/event/history')" data-i18n="eventHistoryButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/lab/reflection/routes')" data-i18n="reflectionButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/lab/concurrency/simulate?tasks=8&poolSize=4')" data-i18n="concurrencyButton"></button>
</div>
<p style="margin-top:12px;" data-i18n="protectedHint"></p>
<div class="triple" style="margin-top:16px;">
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventType" data-i18n="eventTypeLabel"></label>
<select id="eventType">
<option value="LOGIN">LOGIN</option>
<option value="CREATED">CREATED</option>
<option value="UPDATED">UPDATED</option>
<option value="DELETED">DELETED</option>
</select>
</div>
</div>
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventUserId" data-i18n="eventUserIdLabel"></label>
<input id="eventUserId" value="99">
</div>
</div>
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventUserName" data-i18n="eventUserNameLabel"></label>
<input id="eventUserName" value="learning-user">
</div>
</div>
</div>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="publishEvent()" data-i18n="publishButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users')" data-i18n="loadUsersButton"></button>
</div>
<div class="console">
<div class="console-head" data-i18n="consoleTitle"></div>
<pre id="consoleOutput"></pre>
</div>
</section>
<section class="card">
<div class="eyebrow" data-i18n="exp1Badge"></div>
<h2 data-i18n="exp1Title"></h2>
<p data-i18n="exp1Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">UserController</span>
<span class="pill">UserService</span>
<span class="pill">GlobalExceptionHandler</span>
</div>
</section>
<section class="card">
<div class="eyebrow" data-i18n="exp2Badge"></div>
<h2 data-i18n="exp2Title"></h2>
<p data-i18n="exp2Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">PerformanceAspect</span>
<span class="pill">@Around</span>
<span class="pill">Cross-cutting</span>
</div>
</section>
<section class="card">
<div class="eyebrow" data-i18n="exp3Badge"></div>
<h2 data-i18n="exp3Title"></h2>
<p data-i18n="exp3Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">Publisher</span>
<span class="pill">Listener</span>
<span class="pill">@Async</span>
</div>
</section>
</main>
</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(...));
// 监听事件
@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 src="/learning-shell.js"></script>
<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>';
const I18N = {
zh: {
title: "Spring Boot 学习总控台",
heroBadge: "Spring Boot 学习总控台",
heroTitle: "用一个工作台串起 MVC、校验、安全、AOP 和应用事件。",
heroText: "首页现在不只是导航页,而是一个带实验顺序、请求路径说明和实时接口调用能力的学习驾驶舱,帮助你把代码结构和真实行为对应起来。",
loginLabLink: "打开登录实验",
userLabLink: "打开用户实验",
aopLabLink: "打开 AOP 实验",
eventLabLink: "打开事件实验",
learnApiLink: "打开 MVC Learn API",
statLabs: "核心实验",
statLayers: "关键层次",
statPages: "交互页面",
statPaths: "已验证后端路径",
startBadge: "从这里开始",
startTitle: "推荐学习顺序",
startStep1Title: "1. 先看参数绑定",
startStep1Text: "先用下面的实时接口区观察 query、path、header、cookie 和 JSON body 的绑定方式,再对照控制器代码。",
startStep2Title: "2. 再学用户和校验",
startStep2Text: "进入用户实验页,验证 CRUD、重复邮箱保护、聚合统计和错误回传。",
startStep3Title: "3. 最后看横切与事件",
startStep3Text: "切到 AOP 与事件页,理解计时、限流、发布订阅和监听器历史。",
archBadge: "架构路径",
archTitle: "一个请求如何穿过整个 Demo",
flow1Title: "浏览器",
flow1Text: "表单提交或 fetch 调用发起请求。",
flow2Title: "安全层",
flow2Text: "JWT 学习路由判断请求是公开还是受保护。",
flow3Title: "控制器",
flow3Text: "Spring 把参数、请求体、请求头和 Cookie 绑定到方法参数。",
flow4Title: "服务层",
flow4Text: "这里运行重复邮箱校验、统计计算等业务规则。",
flow5Title: "AOP / 事件",
flow5Text: "横切逻辑和监听器在不污染主流程的前提下响应请求。",
flow6Title: "响应",
flow6Text: "最终以结构化 JSON 或 HTML 的形式回到页面。",
tracksBadge: "实验赛道",
tracksTitle: "每个区域该练什么",
track1Title: "用户管理",
track1Text: "创建用户、触发重复邮箱保护、搜索记录,并比较变更前后的统计数据。",
track2Title: "MVC 参数绑定",
track2Text: "使用 /learn 路由对比 @RequestParam、@PathVariable、@RequestBody、@RequestHeader 和 @CookieValue。",
track3Title: "AOP 追踪",
track3Text: "触发 /api/users 与 /api/users/stats再查看 /aop/aop/stats 里哪些方法被统计了。",
track4Title: "应用事件",
track4Text: "发布 LOGIN 或 CREATED 事件后刷新历史,理解发布者与监听器的解耦。",
track5Title: "高级实验",
track5Text: "直接调用 /api/lab 下的反射、并发和 JWT claims 实验接口,把高级知识点和当前 Demo 串起来。",
readingBadge: "代码阅读地图",
readingTitle: "接下来值得重点看的文件",
reading1Title: "UserController -> UserService",
reading1Text: "展示校验、CRUD 编排、搜索以及统计聚合。",
reading2Title: "LearningSecurityConfig + LearningJwtFilter",
reading2Text: "解释为什么大部分实验页保持公开,而 /api/secure/** 仍然受保护。",
reading3Title: "PerformanceAspect + UserEventPublisher + UserEventListener",
reading3Text: "这是横切逻辑与事件驱动最直观的两组示例。",
reading4Title: "AdvancedLabController + LearningJwtUtil",
reading4Text: "用反射、并发和 JWT claims 解析,把高级 Java 和安全实验纳入同一个学习工作台。",
explorerBadge: "实时接口区",
explorerTitle: "不离开页面,直接调用真实接口",
explorerText: "一边读代码,一边在这里看真实返回结果,会更容易把项目结构和运行行为建立联系。",
healthButton: "健康检查",
userStatsButton: "用户统计",
learnButton: "Learn 总览",
aopStatsButton: "AOP 统计",
eventHistoryButton: "事件历史",
reflectionButton: "反射路由图",
concurrencyButton: "并发实验",
protectedHint: "受保护接口已经接入前端令牌持久化。如果你还没登录,请先进入登录实验页。",
eventTypeLabel: "发布演示事件",
eventUserIdLabel: "用户 ID",
eventUserNameLabel: "用户名",
publishButton: "发布事件",
loadUsersButton: "加载用户",
consoleTitle: "接口输出",
consolePlaceholder: "请选择上方一个实验动作来加载实时输出。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
exp1Badge: "实验 1",
exp1Title: "追踪校验链路",
exp1Text: "打开用户实验先创建一个用户再用同样邮箱重复创建。对比前端错误提示、DuplicateEmailException 和全局异常处理器。",
exp2Badge: "实验 2",
exp2Title: "追踪 AOP 计时",
exp2Text: "多次加载用户列表和统计,再调用 /aop/aop/stats观察控制器与服务方法如何累计耗时与调用次数。",
exp3Badge: "实验 3",
exp3Title: "追踪事件解耦",
exp3Text: "连续发布 CREATED 和 LOGIN 事件,再刷新历史,体会请求结束后监听器仍在处理副作用。"
},
en: {
title: "Spring Boot Learning Cockpit",
heroBadge: "Spring Boot Learning Cockpit",
heroTitle: "Use one workspace to understand MVC, validation, security, AOP, and application events.",
heroText: "This homepage is now a guided study cockpit. Instead of only linking to pages, it explains request paths, suggests experiment sequences, and lets you call real endpoints to observe behavior.",
loginLabLink: "Open login lab",
userLabLink: "Open user lab",
aopLabLink: "Open AOP lab",
eventLabLink: "Open event lab",
learnApiLink: "Open MVC Learn API",
statLabs: "Core labs",
statLayers: "Major layers",
statPages: "Interactive pages",
statPaths: "Tested backend paths",
startBadge: "Start Here",
startTitle: "Recommended study order",
startStep1Title: "1. Learn endpoint binding",
startStep1Text: "Use the live explorer below and compare query, path, header, cookie, and JSON body patterns before reading controller code.",
startStep2Title: "2. Study users and validation",
startStep2Text: "Open the user lab to inspect CRUD, duplicate email handling, aggregate stats, and error responses.",
startStep3Title: "3. Watch cross-cutting behavior",
startStep3Text: "Move to AOP and events to understand timing, rate limiting, publishing, and listener history.",
archBadge: "Architecture",
archTitle: "How one request travels through the demo",
flow1Title: "Browser",
flow1Text: "A form submit or fetch call starts the request.",
flow2Title: "Security",
flow2Text: "JWT learning routes decide whether the request is public or protected.",
flow3Title: "Controller",
flow3Text: "Spring binds params, body, headers, and cookies into method arguments.",
flow4Title: "Service",
flow4Text: "Business rules run here, such as duplicate email checks and stats calculation.",
flow5Title: "AOP / Events",
flow5Text: "Cross-cutting logic and listeners react without cluttering the core flow.",
flow6Title: "Response",
flow6Text: "The result returns to the page as structured JSON or HTML feedback.",
tracksBadge: "Lab Tracks",
tracksTitle: "What to practice in each area",
track1Title: "User management",
track1Text: "Create a user, trigger duplicate email protection, search records, and compare stats before and after changes.",
track2Title: "MVC parameter binding",
track2Text: "Use /learn routes to compare @RequestParam, @PathVariable, @RequestBody, @RequestHeader, and @CookieValue.",
track3Title: "AOP tracing",
track3Text: "Trigger /api/users and /api/users/stats, then inspect /aop/aop/stats to see which methods were counted.",
track4Title: "Application events",
track4Text: "Publish LOGIN or CREATED events and refresh history to understand publisher-listener decoupling.",
track5Title: "Advanced labs",
track5Text: "Call the reflection, concurrency, and JWT claims labs under /api/lab to connect advanced topics back to this demo.",
readingBadge: "Code Reading Map",
readingTitle: "Files worth reading next",
reading1Title: "UserController -> UserService",
reading1Text: "Shows validation, CRUD orchestration, search, and stats aggregation.",
reading2Title: "LearningSecurityConfig + LearningJwtFilter",
reading2Text: "Explains why most labs stay public while /api/secure/** remains protected.",
reading3Title: "PerformanceAspect + UserEventPublisher + UserEventListener",
reading3Text: "These are the clearest examples of cross-cutting and event-driven behavior.",
reading4Title: "AdvancedLabController + LearningJwtUtil",
reading4Text: "Use reflection, concurrency, and JWT claim parsing to fold advanced Java and security experiments into the same workspace.",
explorerBadge: "Live Explorer",
explorerTitle: "Call real endpoints without leaving the page",
explorerText: "Inspect real responses while you read the code so the project is easier to connect to concrete runtime behavior.",
healthButton: "Health",
userStatsButton: "User stats",
learnButton: "Learn overview",
aopStatsButton: "AOP stats",
eventHistoryButton: "Event history",
reflectionButton: "Reflection routes",
concurrencyButton: "Concurrency lab",
protectedHint: "Protected endpoints now support frontend token persistence. If you are not logged in yet, open the login lab first.",
eventTypeLabel: "Publish demo event",
eventUserIdLabel: "User id",
eventUserNameLabel: "User name",
publishButton: "Publish event",
loadUsersButton: "Load users",
consoleTitle: "Endpoint output",
consolePlaceholder: "Select one experiment above to load live output.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
exp1Badge: "Experiment 1",
exp1Title: "Trace validation",
exp1Text: "Open the user lab, create a user, then repeat with the same email. Compare the frontend error with DuplicateEmailException and the global exception handler.",
exp2Badge: "Experiment 2",
exp2Title: "Trace AOP timing",
exp2Text: "Load users and stats several times, then call /aop/aop/stats. Watch how controller and service methods accumulate timing and call count data.",
exp3Badge: "Experiment 3",
exp3Title: "Trace event decoupling",
exp3Text: "Publish CREATED and LOGIN events, then reload event history to see how listeners keep handling follow-up work."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
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>';
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
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>';
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
const output = document.getElementById("consoleOutput");
if (!output.dataset.state || output.dataset.state === "idle") {
output.textContent = text.consolePlaceholder;
output.dataset.state = "idle";
}
}
async function loadEndpoint(path, options) {
const output = document.getElementById("consoleOutput");
const text = pageText();
output.textContent = text.loadingPrefix + path + " ...";
output.dataset.state = "loading";
try {
const response = await window.learningShell.fetchWithAuth(path, options || {});
const contentType = response.headers.get("content-type") || "";
if (!response.ok) {
const failedContent = contentType.includes("application/json")
? await response.json()
: await response.text();
throw {
status: response.status,
message: typeof failedContent === "object" && failedContent && failedContent.message
? failedContent.message
: window.learningShell.describeError({ status: response.status }),
payload: failedContent
};
}
if (contentType.includes("application/json")) {
const json = await response.json();
output.textContent = JSON.stringify(json, null, 2);
} else {
output.textContent = await response.text();
}
output.dataset.state = "live";
} catch (error) {
output.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
output.dataset.state = "live";
}
}
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();
</script>
</body>
</html>

View File

@@ -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 = [
'<div class="learning-shell-card">',
' <div class="learning-shell-brand">',
' <strong data-role="brand"></strong>',
' <span data-role="status"></span>',
" </div>",
' <div class="learning-shell-actions">',
' <a class="learning-shell-link" href="/" data-role="home"></a>',
' <a class="learning-shell-link" href="/access.html" data-role="access"></a>',
' <span class="learning-shell-badge" data-role="user"></span>',
' <button class="learning-shell-button alt" type="button" data-role="language"></button>',
' <button class="learning-shell-button alt" type="button" data-role="login"></button>',
' <button class="learning-shell-button alt" type="button" data-role="logout"></button>',
" </div>",
"</div>"
].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
};
})();

View File

@@ -3,224 +3,659 @@
<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 class="page">
<section class="hero">
<div class="card hero-main">
<span class="eyebrow" data-i18n="heroBadge"></span>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="hero-actions">
<button class="btn btn-primary" onclick="openCreateModal()" data-i18n="createButton"></button>
<button class="btn btn-secondary" onclick="refreshDashboard(pageText().dashboardUpdated, 'success')" data-i18n="refreshButton"></button>
<button class="btn btn-secondary" onclick="window.location.href='/access.html'" data-i18n="loginLabButton"></button>
<button class="btn btn-secondary" onclick="window.location.href='/'" data-i18n="homeButton"></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 hero-side">
<h3 data-i18n="highlightsTitle"></h3>
<p data-i18n="highlightsText"></p>
</div>
</section>
<section class="stats">
<div class="card stat"><small data-i18n="statTotal"></small><strong id="totalUsers">0</strong></div>
<div class="card stat"><small data-i18n="statAdults"></small><strong id="adultUsers">0</strong></div>
<div class="card stat"><small data-i18n="statYoung"></small><strong id="youngUsers">0</strong></div>
<div class="card stat"><small data-i18n="statAverage"></small><strong id="averageAge">0.0</strong></div>
</section>
<section class="workspace">
<div class="card table-card">
<div class="section-head">
<div>
<h2 data-i18n="directoryTitle"></h2>
<p data-i18n="directoryText"></p>
</div>
<span class="pill" id="resultCount"></span>
</div>
<div id="tableArea"></div>
</div>
<div class="card tool-card">
<div class="section-head">
<div>
<h2 data-i18n="searchTitle"></h2>
<p data-i18n="searchText"></p>
</div>
</div>
<form onsubmit="searchUsers(event)">
<label>
<span data-i18n="keywordLabel"></span>
<input id="searchInput" type="text" data-i18n-placeholder="searchPlaceholder">
</label>
<div class="toolbar" style="margin-top:14px;">
<button class="btn btn-primary" type="submit" data-i18n="searchButton"></button>
<button class="btn btn-secondary" type="button" onclick="clearSearch()" data-i18n="clearButton"></button>
</div>
</form>
<p data-i18n="searchHint"></p>
<div class="status" id="pageStatus"></div>
</div>
</section>
</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="card modal-card">
<div class="section-head">
<div>
<h2 id="modalTitle"></h2>
<p id="modalSubtitle"></p>
</div>
<button class="close" onclick="closeModal()" data-i18n-aria="closeLabel">&times;</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="form-group">
<label>邮箱</label>
<input type="email" id="userEmail" required>
</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>
<label><span data-i18n="nameLabel"></span><input id="userName" maxlength="40" required></label>
<label><span data-i18n="emailLabel"></span><input id="userEmail" type="email" maxlength="80" required></label>
<label><span data-i18n="ageLabel"></span><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" data-i18n="saveButton"></button>
<button class="btn btn-secondary" type="button" onclick="closeModal()" data-i18n="cancelButton"></button>
</div>
</form>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
// 加载用户列表
async function loadUsers() {
const res = await fetch('/api/users');
const payload = await res.json();
const users = payload.data || [];
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('');
const I18N = {
zh: {
title: "用户管理实验",
heroBadge: "Spring Boot Demo",
heroTitle: "让用户管理实验更接近真实项目。",
heroText: "这一页不再只是静态 CRUD 示例,而是接入了后端统计、关键字搜索、重复邮箱保护和清晰的错误处理。",
createButton: "创建用户",
refreshButton: "刷新看板",
loginLabButton: "登录实验",
homeButton: "返回首页",
highlightsTitle: "学习亮点",
highlightsText: "重复邮箱会返回 409校验错误会直接展示搜索同时匹配用户名和邮箱。",
statTotal: "总用户数",
statAdults: "成年人",
statYoung: "30 岁以下",
statAverage: "平均年龄",
directoryTitle: "用户目录",
directoryText: "表格结果与后端 API 实时同步。",
searchTitle: "搜索",
searchText: "输入关键字,按姓名或邮箱匹配。",
keywordLabel: "关键字",
searchPlaceholder: "例如alice 或 example.com",
searchButton: "搜索",
clearButton: "清空",
searchHint: "前端现在会明确解释服务端校验失败和重复邮箱错误,而不是静默失败。",
closeLabel: "关闭",
nameLabel: "姓名",
emailLabel: "邮箱",
ageLabel: "年龄",
saveButton: "保存",
cancelButton: "取消",
modalCreateTitle: "创建用户",
modalCreateSubtitle: "向内存数据仓中新增一个用户。",
modalEditTitle: "编辑用户",
modalEditSubtitle: "更新用户信息,同时保持邮箱唯一。",
emptyUsers: "当前查询没有匹配到任何用户。",
tableId: "ID",
tableName: "姓名",
tableEmail: "邮箱",
tableAge: "年龄",
tableSegment: "分组",
tableActions: "操作",
segmentAdult: "成年",
segmentMinor: "未成年",
editButton: "编辑",
deleteButton: "删除",
dashboardUpdated: "看板已刷新。",
showingAll: "正在显示全部用户。",
latestLoaded: "已加载最新用户数据。",
searchCompleted: function (keyword) {
return "已完成关键字搜索:" + keyword;
},
deleteSuccess: "用户删除成功。",
updateSuccess: "用户更新成功。",
createSuccess: "用户创建成功。",
resultCount: function (count) {
return count + " 个用户";
},
confirmDelete: function (id) {
return "确认删除用户 #" + id + " 吗?";
}
// 打开模态框
function openModal() {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '添加用户';
document.getElementById('userForm').reset();
document.getElementById('userId').value = '';
},
en: {
title: "User Management Lab",
heroBadge: "Spring Boot Demo",
heroTitle: "User management that feels production-ready.",
heroText: "This page is no longer a static CRUD sample. It now uses backend stats, keyword search, duplicate email protection, and clear error handling.",
createButton: "Create user",
refreshButton: "Refresh",
loginLabButton: "Login lab",
homeButton: "Back home",
highlightsTitle: "Highlights",
highlightsText: "Duplicate emails return 409, validation errors are shown directly, and search matches both names and emails.",
statTotal: "Total users",
statAdults: "Adults",
statYoung: "Under 30",
statAverage: "Average age",
directoryTitle: "User directory",
directoryText: "Table results stay in sync with the backend API.",
searchTitle: "Search",
searchText: "Use a keyword to match names or emails.",
keywordLabel: "Keyword",
searchPlaceholder: "Try: alice or example.com",
searchButton: "Search",
clearButton: "Clear",
searchHint: "The frontend now explains server-side validation and duplicate email errors instead of failing silently.",
closeLabel: "Close",
nameLabel: "Name",
emailLabel: "Email",
ageLabel: "Age",
saveButton: "Save",
cancelButton: "Cancel",
modalCreateTitle: "Create user",
modalCreateSubtitle: "Add a new user to the in-memory store.",
modalEditTitle: "Edit user",
modalEditSubtitle: "Update the user and keep the email unique.",
emptyUsers: "No users matched the current query.",
tableId: "ID",
tableName: "Name",
tableEmail: "Email",
tableAge: "Age",
tableSegment: "Segment",
tableActions: "Actions",
segmentAdult: "Adult",
segmentMinor: "Minor",
editButton: "Edit",
deleteButton: "Delete",
dashboardUpdated: "Dashboard updated.",
showingAll: "Showing all users.",
latestLoaded: "Latest users loaded.",
searchCompleted: function (keyword) {
return 'Search completed for "' + keyword + '".';
},
deleteSuccess: "User deleted successfully.",
updateSuccess: "User updated successfully.",
createSuccess: "User created successfully.",
resultCount: function (count) {
return count + " users";
},
confirmDelete: function (id) {
return "Delete user #" + id + "?";
}
// 关闭模态框
function closeModal() {
document.getElementById('userModal').classList.remove('active');
}
// 编辑用户
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;
}
// 保存用户
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)
};
const state = {
users: [],
modalMode: "create"
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
document.querySelectorAll("[data-i18n-placeholder]").forEach(function (element) {
const key = element.getAttribute("data-i18n-placeholder");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.setAttribute("placeholder", text[key]);
}
});
document.querySelectorAll("[data-i18n-aria]").forEach(function (element) {
const key = element.getAttribute("data-i18n-aria");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.setAttribute("aria-label", text[key]);
}
});
}
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 renderModalText() {
const text = pageText();
document.getElementById("modalTitle").textContent = state.modalMode === "edit"
? text.modalEditTitle
: text.modalCreateTitle;
document.getElementById("modalSubtitle").textContent = state.modalMode === "edit"
? text.modalEditSubtitle
: text.modalCreateSubtitle;
}
function renderTable() {
const text = pageText();
const area = document.getElementById("tableArea");
document.getElementById("resultCount").textContent = text.resultCount(state.users.length);
if (!state.users.length) {
area.innerHTML = '<div class="empty">' + text.emptyUsers + "</div>";
return;
}
area.innerHTML = [
"<table>",
" <thead>",
" <tr>",
" <th>" + text.tableId + "</th>",
" <th>" + text.tableName + "</th>",
" <th>" + text.tableEmail + "</th>",
" <th>" + text.tableAge + "</th>",
" <th>" + text.tableSegment + "</th>",
" <th>" + text.tableActions + "</th>",
" </tr>",
" </thead>",
" <tbody>",
state.users.map(function (user) {
const segment = user.age >= 18 ? text.segmentAdult : text.segmentMinor;
return [
" <tr>",
" <td>" + user.id + "</td>",
" <td>" + user.name + "</td>",
" <td>" + user.email + "</td>",
" <td>" + user.age + "</td>",
' <td><span class="segment">' + segment + "</span></td>",
" <td>",
' <button class="btn btn-secondary" onclick="openEditModal(' + user.id + ')">' + text.editButton + "</button>",
' <button class="btn btn-danger" onclick="removeUser(' + user.id + ')">' + text.deleteButton + "</button>",
" </td>",
" </tr>"
].join("");
}).join(""),
" </tbody>",
"</table>"
].join("");
}
function renderLanguage() {
applyTranslations(pageText());
renderModalText();
renderTable();
}
async function request(url, options) {
return window.learningShell.requestJson(url, options);
}
function setStatus(id, message, type) {
const element = document.getElementById(id);
element.textContent = message;
element.className = type ? "status " + type : "status";
}
function openCreateModal() {
document.getElementById("userForm").reset();
document.getElementById("userId").value = "";
state.modalMode = "create";
renderModalText();
setStatus("formStatus", "");
document.getElementById("userModal").classList.add("active");
}
function openEditModal(id) {
const user = state.users.find(function (item) {
return 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;
state.modalMode = "edit";
renderModalText();
setStatus("formStatus", "");
document.getElementById("userModal").classList.add("active");
}
function closeModal() {
document.getElementById("userModal").classList.remove("active");
}
async function refreshDashboard(message, type) {
try {
const payloads = await Promise.all([
request("/api/users"),
request("/api/users/stats")
]);
state.users = payloads[0].data;
renderStats(payloads[1].data);
renderTable();
setStatus("pageStatus", message, type || "success");
} catch (error) {
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
async function searchUsers(event) {
event.preventDefault();
const keyword = document.getElementById("searchInput").value.trim();
const text = pageText();
try {
if (!keyword) {
await refreshDashboard(text.showingAll, "success");
return;
}
const payload = await request("/api/users/search?keyword=" + encodeURIComponent(keyword));
state.users = payload.data;
renderTable();
setStatus("pageStatus", text.searchCompleted(keyword), "success");
} catch (error) {
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
async function clearSearch() {
document.getElementById("searchInput").value = "";
await refreshDashboard(pageText().showingAll, "success");
}
async function removeUser(id) {
if (!confirm(pageText().confirmDelete(id))) {
return;
}
try {
await request("/api/users/" + id, { method: "DELETE" });
await refreshDashboard(pageText().deleteSuccess, "success");
} catch (error) {
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
document.getElementById("userForm").addEventListener("submit", async function (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}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
await request("/api/users/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
}
closeModal();
loadUsers();
await refreshDashboard(pageText().updateSuccess, "success");
} else {
await request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard(pageText().createSuccess, "success");
}
} catch (error) {
if (error.details && typeof error.details === "object") {
const firstIssue = Object.values(error.details)[0];
setStatus("formStatus", String(firstIssue), "error");
} else {
setStatus("formStatus", window.learningShell.describeError(error), "error");
}
}
});
// 删除用户
async function deleteUser(id) {
if (confirm('确定删除此用户?')) {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
loadUsers();
}
}
// 初始化
loadUsers();
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
refreshDashboard(pageText().latestLoaded, "success");
</script>
</body>
</html>

View File

@@ -1,25 +1,29 @@
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/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/event/UserEventListener.class
com/example/demo/controller/UserController.class
com/example/demo/aop/RateLimitAspect.class
com/example/demo/security/LearningJwtFilter.class
com\example\demo\controller\LearnController.class
com\example\demo\DemoApplication.class
com\example\demo\security\LearningJwtUtil.class
com\example\demo\service\UserService.class
com\example\demo\event\UserEventPublisher.class
com\example\demo\exception\ResourceNotFoundException.class
com\example\demo\controller\AopEventController$1.class
com\example\demo\controller\auth\LearningAuthController.class
com\example\demo\common\ApiResponse.class
com\example\demo\dto\UserRequest.class
com\example\demo\controller\UserController.class
com\example\demo\dto\UserStatsResponse.class
com\example\demo\exception\DuplicateEmailException.class
com\example\demo\exception\GlobalExceptionHandler.class
com\example\demo\aop\RateLimited.class
com\example\demo\model\UserEvent.class
com\example\demo\aop\LoggingAspect.class
com\example\demo\aop\PerformanceAspect.class
com\example\demo\security\LearningSecurityConfig.class
com\example\demo\aop\RateLimitAspect.class
com\example\demo\model\User.class
com\example\demo\dto\auth\LoginRequest.class
com\example\demo\controller\AdvancedLabController.class
com\example\demo\security\LearningJwtFilter.class
com\example\demo\controller\auth\SecureDemoController.class
com\example\demo\controller\PageController.class
com\example\demo\controller\AopEventController.class
com\example\demo\event\UserEventListener.class
com\example\demo\model\UserEvent$Type.class

View File

@@ -1,24 +1,27 @@
/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
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\common\ApiResponse.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\AdvancedLabController.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\exception\ResourceNotFoundException.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\security\LearningJwtFilter.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\event\UserEventPublisher.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\auth\LearningAuthController.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\model\User.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\aop\RateLimited.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\UserController.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\dto\auth\LoginRequest.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\event\UserEventListener.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\aop\RateLimitAspect.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\DemoApplication.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\exception\GlobalExceptionHandler.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\dto\UserStatsResponse.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\AopEventController.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\LearnController.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\aop\LoggingAspect.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\model\UserEvent.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\service\UserService.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\aop\PerformanceAspect.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\dto\UserRequest.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\security\LearningSecurityConfig.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\exception\DuplicateEmailException.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\PageController.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\security\LearningJwtUtil.java
D:\qoderhome\gitcode\springboot-demo-clean\src\main\java\com\example\demo\controller\auth\SecureDemoController.java

View File

@@ -1,2 +1,3 @@
com/example/demo/controller/UserControllerTest.class
com/example/demo/controller/AuthFlowTest.class
com\example\demo\controller\AuthFlowTest.class
com\example\demo\controller\UserControllerTest.class
com\example\demo\controller\AdvancedLabControllerTest.class

View File

@@ -1,2 +1,3 @@
/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
D:\qoderhome\gitcode\springboot-demo-clean\src\test\java\com\example\demo\controller\AdvancedLabControllerTest.java
D:\qoderhome\gitcode\springboot-demo-clean\src\test\java\com\example\demo\controller\AuthFlowTest.java
D:\qoderhome\gitcode\springboot-demo-clean\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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,4 +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
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.497 s -- in com.example.demo.controller.AuthFlowTest

View File

@@ -1,4 +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
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.582 s -- in com.example.demo.controller.UserControllerTest