feat: upgrade user management demo

This commit is contained in:
Codex
2026-03-18 16:43:04 +08:00
parent e8afe9a5f4
commit 00306082fb
9 changed files with 753 additions and 464 deletions

View File

@@ -2,6 +2,7 @@ package com.example.demo.controller;
import com.example.demo.common.ApiResponse;
import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserStatsResponse;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import jakarta.validation.Valid;
@@ -24,31 +25,38 @@ public class UserController {
return ApiResponse.ok(userService.findAll());
}
@GetMapping("/stats")
public ApiResponse<UserStatsResponse> getUserStats() {
return ApiResponse.ok(userService.getStats());
}
@GetMapping("/search")
public ApiResponse<List<User>> searchUsers(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String name) {
String term = keyword != null && !keyword.isBlank() ? keyword : name;
return ApiResponse.ok(userService.search(term));
}
@GetMapping("/{id}")
public ApiResponse<User> getUserById(@PathVariable Long id) {
return ApiResponse.ok(userService.findById(id));
}
@PostMapping
public ApiResponse<User> createUser(@Valid @RequestBody UserRequest req) {
User user = new User(null, req.name(), req.email(), req.age());
return ApiResponse.ok("创建成功", userService.create(user));
public ApiResponse<User> createUser(@Valid @RequestBody UserRequest request) {
User user = new User(null, request.name(), request.email(), request.age());
return ApiResponse.ok("User created successfully", userService.create(user));
}
@PutMapping("/{id}")
public ApiResponse<User> updateUser(@PathVariable Long id, @Valid @RequestBody UserRequest req) {
User user = new User(id, req.name(), req.email(), req.age());
return ApiResponse.ok("更新成功", userService.update(id, user));
public ApiResponse<User> updateUser(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
User user = new User(id, request.name(), request.email(), request.age());
return ApiResponse.ok("User updated successfully", userService.update(id, user));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ApiResponse.ok("删除成功", null);
}
@GetMapping("/search")
public ApiResponse<List<User>> searchUsers(@RequestParam String name) {
return ApiResponse.ok(userService.findByName(name));
return ApiResponse.ok("User deleted successfully", null);
}
}

View File

@@ -4,16 +4,21 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record UserRequest(
@NotBlank(message = "姓名不能为空")
@NotBlank(message = "Name is required")
@Size(max = 40, message = "Name must be at most 40 characters")
String name,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@NotBlank(message = "Email is required")
@Email(message = "Email format is invalid")
String email,
@Min(value = 1, message = "年龄最小为 1")
@Max(value = 120, message = "年龄最大为 120")
@NotNull(message = "Age is required")
@Min(value = 1, message = "Age must be at least 1")
@Max(value = 120, message = "Age must be at most 120")
Integer age
) {}
) {
}

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@@ -15,24 +16,31 @@ import java.util.Map;
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException e) {
public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException exception) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.fail(404, e.getMessage()));
.body(ApiResponse.fail(404, exception.getMessage()));
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResponse<Void>> handleDuplicateEmail(DuplicateEmailException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.fail(409, exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException e) {
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException exception) {
Map<String, String> errors = new HashMap<>();
for (FieldError error : e.getBindingResult().getFieldErrors()) {
for (FieldError error : exception.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(new ApiResponse<>(400, "参数校验失败", errors, java.time.Instant.now()));
.body(new ApiResponse<>(400, "Validation failed", errors, Instant.now()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAny(Exception e) {
public ResponseEntity<ApiResponse<Void>> handleAny(Exception exception) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(500, "服务器内部错误: " + e.getMessage()));
.body(ApiResponse.fail(500, "Unexpected server error"));
}
}

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,10 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
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
@@ -29,9 +31,9 @@ class UserControllerTest {
void shouldCreateUser() throws Exception {
String json = """
{
\"name\": \"测试用户\",
\"email\": \"test@example.com\",
\"age\": 22
"name": "Demo User",
"email": "demo-user@example.com",
"age": 22
}
""";
@@ -40,16 +42,16 @@ class UserControllerTest {
.content(json))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.name").value("测试用户"));
.andExpect(jsonPath("$.data.name").value("Demo User"));
}
@Test
void shouldRejectInvalidUser() throws Exception {
String json = """
{
\"name\": \"\",
\"email\": \"bad-mail\",
\"age\": 222
"name": "",
"email": "bad-mail",
"age": 222
}
""";
@@ -59,4 +61,31 @@ class UserControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void shouldRejectDuplicateEmail() throws Exception {
String json = """
{
"name": "Another User",
"email": "alice@example.com",
"age": 29
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(409));
}
@Test
void shouldExposeUserStats() throws Exception {
mockMvc.perform(get("/api/users/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.totalUsers").isNumber())
.andExpect(jsonPath("$.data.adults").isNumber())
.andExpect(jsonPath("$.data.averageAge").isNumber());
}
}