文章目录
- 一、项目概述
-
- [1.1 项目目标](#1.1 项目目标)
- [1.2 技术栈](#1.2 技术栈)
- [1.3 项目结构](#1.3 项目结构)
- 二、项目初始化
-
- [2.1 让 Cursor 生成项目骨架](#2.1 让 Cursor 生成项目骨架)
- 三、数据库设计与实体类
-
- [3.1 数据库表设计](#3.1 数据库表设计)
- [3.2 生成实体类](#3.2 生成实体类)
- [四、DTO 与 Mapper 层](#四、DTO 与 Mapper 层)
-
- [4.1 生成请求/响应 DTO](#4.1 生成请求/响应 DTO)
- [4.2 生成 Mapper](#4.2 生成 Mapper)
- 五、工具类与配置
-
- [5.1 JWT 工具类](#5.1 JWT 工具类)
- [5.2 配置文件](#5.2 配置文件)
- 六、业务逻辑层(Service)
-
- [6.1 生成 Service 接口和实现](#6.1 生成 Service 接口和实现)
- 七、控制器层(Controller)
-
- [7.1 生成 Controller](#7.1 生成 Controller)
- 八、全局异常处理与统一返回
-
- [8.1 统一返回格式](#8.1 统一返回格式)
- [8.2 全局异常处理器](#8.2 全局异常处理器)
- 九、认证拦截器
-
- [9.1 生成拦截器](#9.1 生成拦截器)
- [9.2 注册拦截器](#9.2 注册拦截器)
- 十、单元测试
-
- [10.1 生成 Service 测试](#10.1 生成 Service 测试)
- 十一、运行与测试
-
- [11.1 启动应用](#11.1 启动应用)
- [11.2 接口测试](#11.2 接口测试)
- [11.3 验证要点](#11.3 验证要点)
- 十二、常见问题与解决方案
-
- [12.1 开发中遇到的坑](#12.1 开发中遇到的坑)
- [12.2 优化建议](#12.2 优化建议)
- 写在最后
理论讲再多,不如动手做一个项目。这篇文章,我将带你用 Cursor 从零开始搭建一个生产级的用户中心------包含注册、登录、JWT 鉴权、Redis 缓存、全局异常处理、单元测试等完整功能。全程使用 Cursor 辅助开发,让你直观感受 AI 编程的真实效率。
一、项目概述
1.1 项目目标
构建一个用户中心微服务,包含以下功能:
yaml
核心功能:
- 用户注册(手机号 + 密码)
- 用户登录(JWT Token)
- 用户信息查询
- 用户信息更新
技术特性:
- 密码 BCrypt 加密
- Redis 存储 Token 黑名单
- 全局异常处理
- 参数校验
- 单元测试覆盖
非功能要求:
- 响应时间 < 100ms
- 支持并发 1000 QPS
1.2 技术栈
| 组件 | 技术选型 | 版本 |
|---|---|---|
| 基础框架 | Spring Boot | 3.2.0 |
| 数据库 | MySQL | 8.0 |
| 缓存 | Redis | 7.0 |
| ORM | MyBatis-Plus | 3.5.5 |
| JWT | JJWT | 0.12.3 |
| 连接池 | HikariCP | 5.0.1 |
| 测试 | JUnit 5 + Mockito | - |
1.3 项目结构
user-center/
├── src/main/java/com/example/usercenter/
│ ├── controller/ # API 接口层
│ ├── service/ # 业务逻辑层
│ ├── mapper/ # 数据访问层
│ ├── entity/ # 数据库实体
│ ├── dto/ # 数据传输对象
│ │ ├── request/ # 请求 DTO
│ │ └── response/ # 响应 DTO
│ ├── config/ # 配置类
│ ├── exception/ # 异常处理
│ ├── interceptor/ # 拦截器
│ └── util/ # 工具类
├── src/main/resources/
│ ├── application.yml
│ └── mapper/ # MyBatis XML 文件
└── pom.xml
二、项目初始化
2.1 让 Cursor 生成项目骨架
指令:
创建一个 Spring Boot 3.2 项目,项目名 user-center,使用 Java 17。
依赖包含:Spring Web、MyBatis-Plus、MySQL Driver、Lombok、Spring Validation、Spring Data Redis、JJWT。
请生成完整的 pom.xml 文件。
Cursor 输出(核心部分):
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>user-center</artifactId>
<version>1.0.0</version>
<name>user-center</name>
<description>User Center Service</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<jjwt.version>0.12.3</jjwt.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Tools -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
三、数据库设计与实体类
3.1 数据库表设计
指令:
设计用户表,表名 `user`,包含以下字段:
- id(主键,自增)
- phone(手机号,唯一索引)
- password(密码,BCrypt 加密存储)
- nickname(昵称)
- avatar(头像 URL)
- status(状态:0-禁用,1-正常)
- last_login_time(最后登录时间)
- create_time(创建时间)
- update_time(更新时间)
请生成建表 SQL。
Cursor 输出:
sql
CREATE TABLE `user` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`phone` VARCHAR(11) NOT NULL COMMENT '手机号',
`password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT(4) NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-正常',
`last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
3.2 生成实体类
指令:
根据上面的 user 表,生成 MyBatis-Plus 的实体类 User。
要求:
1. 使用 Lombok 注解
2. 添加 @TableName 注解
3. 添加 @TableId 指定主键策略
4. 添加必要的 JavaDoc
Cursor 输出:
java
package com.example.usercenter.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户实体类
*
* @author AI Assistant
* @date 2026-01-15
*/
@Data
@TableName("user")
public class User {
/**
* 用户ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 手机号
*/
private String phone;
/**
* 密码(BCrypt加密)
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatar;
/**
* 状态:0-禁用,1-正常
*/
private Integer status;
/**
* 最后登录时间
*/
private LocalDateTime lastLoginTime;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
四、DTO 与 Mapper 层
4.1 生成请求/响应 DTO
指令:
生成用户模块的 DTO:
请求 DTO:
1. UserRegisterRequest:手机号、密码、昵称
2. UserLoginRequest:手机号、密码
3. UserUpdateRequest:昵称、头像
响应 DTO:
1. UserVO:用户ID、手机号、昵称、头像、状态
2. LoginVO:用户ID、昵称、Token
要求:
1. 使用 Jakarta Validation 注解做参数校验
2. 使用 Lombok 注解
Cursor 输出:
java
// UserRegisterRequest.java
package com.example.usercenter.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 用户注册请求 DTO
*/
@Data
public class UserRegisterRequest {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String password;
@Size(max = 50, message = "昵称长度不能超过50位")
private String nickname;
}
// LoginVO.java
package com.example.usercenter.dto.response;
import lombok.Builder;
import lombok.Data;
/**
* 登录响应 VO
*/
@Data
@Builder
public class LoginVO {
private Long userId;
private String nickname;
private String token;
}
4.2 生成 Mapper
指令:
生成 UserMapper,继承 MyBatis-Plus 的 BaseMapper。
添加根据手机号查询用户的方法:selectByPhone
Cursor 输出:
java
package com.example.usercenter.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.usercenter.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
/**
* 用户 Mapper 接口
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据手机号查询用户
*
* @param phone 手机号
* @return 用户信息
*/
@Select("SELECT * FROM user WHERE phone = #{phone}")
User selectByPhone(String phone);
}
五、工具类与配置
5.1 JWT 工具类
指令:
生成 JWT 工具类 JwtUtil,包含:
1. 生成 Token(用户ID作为 subject,过期时间 7 天)
2. 解析 Token 获取用户ID
3. 验证 Token 有效性
4. Token 黑名单管理(配合 Redis)
使用 JJWT 0.12.3 版本。
Cursor 输出:
java
package com.example.usercenter.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* JWT 工具类
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final StringRedisTemplate redisTemplate;
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private static final String TOKEN_BLACKLIST_PREFIX = "token:blacklist:";
/**
* 生成 Token
*/
public String generateToken(Long userId) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(now)
.expiration(expireDate)
.signWith(key)
.compact();
}
/**
* 从 Token 获取用户ID
*/
public Long getUserIdFromToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
return Long.parseLong(claims.getSubject());
} catch (Exception e) {
log.error("解析 Token 失败: {}", e.getMessage());
return null;
}
}
/**
* 验证 Token 有效性
*/
public boolean validateToken(String token) {
// 检查是否在黑名单中
String blacklistKey = TOKEN_BLACKLIST_PREFIX + token;
Boolean isBlacklisted = redisTemplate.hasKey(blacklistKey);
if (Boolean.TRUE.equals(isBlacklisted)) {
return false;
}
// 检查是否过期
Long userId = getUserIdFromToken(token);
return userId != null;
}
/**
* 将 Token 加入黑名单(登出时使用)
*/
public void addToBlacklist(String token) {
Long userId = getUserIdFromToken(token);
if (userId != null) {
// 获取剩余过期时间
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
if (ttl > 0) {
String blacklistKey = TOKEN_BLACKLIST_PREFIX + token;
redisTemplate.opsForValue().set(blacklistKey, "1", ttl, TimeUnit.MILLISECONDS);
}
}
}
}
5.2 配置文件
指令:
生成 application.yml 配置文件,包含:
1. 数据库连接配置
2. Redis 配置
3. MyBatis-Plus 配置
4. JWT 配置(secret、expiration)
5. 日志配置
Cursor 输出:
yaml
spring:
application:
name: user-center
datasource:
url: jdbc:mysql://localhost:3306/user_center?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
data:
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 5000ms
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.usercenter.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
jwt:
secret: "your-256-bit-secret-key-for-jwt-signature-please-change-in-production"
expiration: 604800000 # 7天(毫秒)
logging:
level:
com.example.usercenter: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
六、业务逻辑层(Service)
6.1 生成 Service 接口和实现
指令:
生成 UserService 接口和实现类,包含以下方法:
1. register(registerRequest) - 用户注册
2. login(loginRequest) - 用户登录
3. getUserInfo(userId) - 获取用户信息
4. updateUser(userId, updateRequest) - 更新用户信息
5. logout(token) - 用户登出
要求:
- 注册时检查手机号是否已存在
- 密码使用 BCrypt 加密
- 登录失败时记录失败次数
- 使用 @Transactional 保证事务
- 添加详细日志
Cursor 输出:
java
// UserService.java
package com.example.usercenter.service;
import com.example.usercenter.dto.request.UserLoginRequest;
import com.example.usercenter.dto.request.UserRegisterRequest;
import com.example.usercenter.dto.request.UserUpdateRequest;
import com.example.usercenter.dto.response.LoginVO;
import com.example.usercenter.dto.response.UserVO;
public interface UserService {
/**
* 用户注册
*/
void register(UserRegisterRequest request);
/**
* 用户登录
*/
LoginVO login(UserLoginRequest request);
/**
* 获取用户信息
*/
UserVO getUserInfo(Long userId);
/**
* 更新用户信息
*/
void updateUser(Long userId, UserUpdateRequest request);
/**
* 用户登出
*/
void logout(String token);
}
java
// UserServiceImpl.java
package com.example.usercenter.service.impl;
import com.example.usercenter.dto.request.UserLoginRequest;
import com.example.usercenter.dto.request.UserRegisterRequest;
import com.example.usercenter.dto.request.UserUpdateRequest;
import com.example.usercenter.dto.response.LoginVO;
import com.example.usercenter.dto.response.UserVO;
import com.example.usercenter.entity.User;
import com.example.usercenter.exception.BusinessException;
import com.example.usercenter.mapper.UserMapper;
import com.example.usercenter.service.UserService;
import com.example.usercenter.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final JwtUtil jwtUtil;
private final StringRedisTemplate redisTemplate;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private static final String LOGIN_FAIL_PREFIX = "login:fail:";
@Override
@Transactional(rollbackFor = Exception.class)
public void register(UserRegisterRequest request) {
log.info("开始用户注册: phone={}", request.getPhone());
// 1. 检查手机号是否已存在
User existUser = userMapper.selectByPhone(request.getPhone());
if (existUser != null) {
throw new BusinessException("手机号已注册");
}
// 2. 创建新用户
User user = new User();
user.setPhone(request.getPhone());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setNickname(StringUtils.hasText(request.getNickname()) ?
request.getNickname() : "用户" + request.getPhone().substring(7));
user.setStatus(1);
// 3. 保存用户
userMapper.insert(user);
log.info("用户注册成功: userId={}", user.getId());
}
@Override
public LoginVO login(UserLoginRequest request) {
log.info("用户登录: phone={}", request.getPhone());
// 1. 检查登录失败次数
String failKey = LOGIN_FAIL_PREFIX + request.getPhone();
String failCountStr = redisTemplate.opsForValue().get(failKey);
int failCount = failCountStr == null ? 0 : Integer.parseInt(failCountStr);
if (failCount >= 5) {
throw new BusinessException("登录失败次数过多,请15分钟后重试");
}
// 2. 查询用户
User user = userMapper.selectByPhone(request.getPhone());
if (user == null) {
recordLoginFail(failKey, failCount);
throw new BusinessException("用户不存在");
}
// 3. 校验密码
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
recordLoginFail(failKey, failCount);
throw new BusinessException("密码错误");
}
// 4. 校验用户状态
if (user.getStatus() != 1) {
throw new BusinessException("账号已被禁用");
}
// 5. 清除失败记录
redisTemplate.delete(failKey);
// 6. 更新最后登录时间
user.setLastLoginTime(LocalDateTime.now());
userMapper.updateById(user);
// 7. 生成 Token
String token = jwtUtil.generateToken(user.getId());
log.info("用户登录成功: userId={}", user.getId());
return LoginVO.builder()
.userId(user.getId())
.nickname(user.getNickname())
.token(token)
.build();
}
@Override
public UserVO getUserInfo(Long userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setPhone(user.getPhone());
vo.setNickname(user.getNickname());
vo.setAvatar(user.getAvatar());
vo.setStatus(user.getStatus());
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateUser(Long userId, UserUpdateRequest request) {
log.info("更新用户信息: userId={}", userId);
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
if (StringUtils.hasText(request.getNickname())) {
user.setNickname(request.getNickname());
}
if (StringUtils.hasText(request.getAvatar())) {
user.setAvatar(request.getAvatar());
}
userMapper.updateById(user);
log.info("用户信息更新成功: userId={}", userId);
}
@Override
public void logout(String token) {
log.info("用户登出");
jwtUtil.addToBlacklist(token);
}
private void recordLoginFail(String failKey, int currentFailCount) {
int newFailCount = currentFailCount + 1;
if (newFailCount >= 5) {
redisTemplate.opsForValue().set(failKey, String.valueOf(newFailCount), 15, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(failKey, String.valueOf(newFailCount), 1, TimeUnit.HOURS);
}
log.warn("登录失败,失败次数: {}", newFailCount);
}
}
七、控制器层(Controller)
7.1 生成 Controller
指令:
生成 UserController,包含以下接口:
1. POST /api/v1/user/register - 用户注册
2. POST /api/v1/user/login - 用户登录
3. GET /api/v1/user/info - 获取当前用户信息
4. PUT /api/v1/user/info - 更新当前用户信息
5. POST /api/v1/user/logout - 用户登出
要求:
- 使用统一返回格式 Result<T>
- 添加参数校验
- 添加接口说明(JavaDoc)
- 需要认证的接口添加拦截
Cursor 输出:
java
package com.example.usercenter.controller;
import com.example.usercenter.dto.request.UserLoginRequest;
import com.example.usercenter.dto.request.UserRegisterRequest;
import com.example.usercenter.dto.request.UserUpdateRequest;
import com.example.usercenter.dto.response.LoginVO;
import com.example.usercenter.dto.response.UserVO;
import com.example.usercenter.service.UserService;
import com.example.usercenter.common.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 用户管理 Controller
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 用户注册
*
* @param request 注册请求参数
* @return 注册结果
*/
@PostMapping("/register")
public Result<Void> register(@Valid @RequestBody UserRegisterRequest request) {
log.info("收到注册请求: phone={}", request.getPhone());
userService.register(request);
return Result.success();
}
/**
* 用户登录
*
* @param request 登录请求参数
* @return 登录信息
*/
@PostMapping("/login")
public Result<LoginVO> login(@Valid @RequestBody UserLoginRequest request) {
log.info("收到登录请求: phone={}", request.getPhone());
LoginVO loginVO = userService.login(request);
return Result.success(loginVO);
}
/**
* 获取当前用户信息
*
* @param userId 当前用户ID(从拦截器获取)
* @return 用户信息
*/
@GetMapping("/info")
public Result<UserVO> getUserInfo(@RequestAttribute("userId") Long userId) {
UserVO userVO = userService.getUserInfo(userId);
return Result.success(userVO);
}
/**
* 更新当前用户信息
*
* @param userId 当前用户ID(从拦截器获取)
* @param request 更新请求参数
* @return 更新结果
*/
@PutMapping("/info")
public Result<Void> updateUser(@RequestAttribute("userId") Long userId,
@Valid @RequestBody UserUpdateRequest request) {
userService.updateUser(userId, request);
return Result.success();
}
/**
* 用户登出
*
* @param token 当前用户的 Token
* @return 登出结果
*/
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader("Authorization") String token) {
// 去掉 "Bearer " 前缀
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
userService.logout(token);
return Result.success();
}
}
八、全局异常处理与统一返回
8.1 统一返回格式
指令:
生成统一返回类 Result<T>,包含:
- code:响应码
- message:响应消息
- data:响应数据
提供静态方法:
- success()
- success(data)
- error(code, message)
Cursor 输出:
java
package com.example.usercenter.common;
import lombok.Data;
/**
* 统一返回结果
*/
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
private Result(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> Result<T> success() {
return new Result<>(200, "success", null);
}
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
}
8.2 全局异常处理器
指令:
生成全局异常处理器 GlobalExceptionHandler,处理:
1. BusinessException(业务异常)
2. MethodArgumentNotValidException(参数校验异常)
3. Exception(未知异常)
返回统一的 Result 格式。
Cursor 输出:
java
package com.example.usercenter.exception;
import com.example.usercenter.common.Result;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常处理
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.error(400, e.getMessage());
}
/**
* 参数校验异常处理
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return Result.error(400, message);
}
/**
* 未知异常处理
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error(500, "系统内部错误,请稍后重试");
}
}
九、认证拦截器
9.1 生成拦截器
指令:
生成认证拦截器 AuthInterceptor,实现 HandlerInterceptor:
1. 从请求头获取 Authorization
2. 解析 Token 获取用户 ID
3. 将用户 ID 存入 request attribute
4. 排除白名单路径:/api/v1/user/register, /api/v1/user/login
Cursor 输出:
java
package com.example.usercenter.interceptor;
import com.example.usercenter.exception.BusinessException;
import com.example.usercenter.util.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
private static final List<String> WHITE_LIST = List.of(
"/api/v1/user/register",
"/api/v1/user/login"
);
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String path = request.getRequestURI();
// 白名单放行
if (WHITE_LIST.contains(path)) {
return true;
}
// 获取 Token
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new BusinessException("未登录或 Token 格式错误");
}
token = token.substring(7);
// 验证 Token
if (!jwtUtil.validateToken(token)) {
throw new BusinessException("Token 无效或已过期");
}
// 获取用户 ID 并存入 request
Long userId = jwtUtil.getUserIdFromToken(token);
request.setAttribute("userId", userId);
return true;
}
}
9.2 注册拦截器
指令:
生成 WebConfig 配置类,注册 AuthInterceptor
Cursor 输出:
java
package com.example.usercenter.config;
import com.example.usercenter.interceptor.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/v1/**");
}
}
十、单元测试
10.1 生成 Service 测试
指令:
为 UserServiceImpl 生成单元测试,覆盖:
1. 注册成功
2. 注册时手机号已存在
3. 登录成功
4. 登录时密码错误
5. 登录失败次数限制
使用 Mockito 和 JUnit 5
Cursor 输出:
java
package com.example.usercenter.service.impl;
import com.example.usercenter.dto.request.UserLoginRequest;
import com.example.usercenter.dto.request.UserRegisterRequest;
import com.example.usercenter.entity.User;
import com.example.usercenter.exception.BusinessException;
import com.example.usercenter.mapper.UserMapper;
import com.example.usercenter.util.JwtUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
private UserMapper userMapper;
@Mock
private JwtUtil jwtUtil;
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private ValueOperations<String, String> valueOperations;
@InjectMocks
private UserServiceImpl userService;
private BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@BeforeEach
void setUp() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
}
@Test
void registerSuccess() {
// given
UserRegisterRequest request = new UserRegisterRequest();
request.setPhone("13800138000");
request.setPassword("123456");
request.setNickname("测试用户");
when(userMapper.selectByPhone(anyString())).thenReturn(null);
when(userMapper.insert(any(User.class))).thenReturn(1);
// when
assertDoesNotThrow(() -> userService.register(request));
// then
verify(userMapper).insert(any(User.class));
}
@Test
void registerWhenPhoneExists() {
// given
UserRegisterRequest request = new UserRegisterRequest();
request.setPhone("13800138000");
request.setPassword("123456");
User existUser = new User();
existUser.setPhone("13800138000");
when(userMapper.selectByPhone(anyString())).thenReturn(existUser);
// when & then
assertThrows(BusinessException.class, () -> userService.register(request));
verify(userMapper, never()).insert(any());
}
@Test
void loginSuccess() {
// given
UserLoginRequest request = new UserLoginRequest();
request.setPhone("13800138000");
request.setPassword("123456");
User user = new User();
user.setId(1L);
user.setPhone("13800138000");
user.setPassword(passwordEncoder.encode("123456"));
user.setStatus(1);
when(valueOperations.get(anyString())).thenReturn(null);
when(userMapper.selectByPhone(anyString())).thenReturn(user);
when(jwtUtil.generateToken(anyLong())).thenReturn("test-token");
// when
var result = userService.login(request);
// then
assertNotNull(result);
assertEquals("test-token", result.getToken());
verify(userMapper).updateById(any());
}
@Test
void loginWithWrongPassword() {
// given
UserLoginRequest request = new UserLoginRequest();
request.setPhone("13800138000");
request.setPassword("wrong-password");
User user = new User();
user.setPassword(passwordEncoder.encode("123456"));
when(valueOperations.get(anyString())).thenReturn(null);
when(userMapper.selectByPhone(anyString())).thenReturn(user);
// when & then
assertThrows(BusinessException.class, () -> userService.login(request));
verify(valueOperations).set(anyString(), anyString(), anyLong(), any());
}
@Test
void loginExceedsFailLimit() {
// given
UserLoginRequest request = new UserLoginRequest();
request.setPhone("13800138000");
request.setPassword("123456");
when(valueOperations.get(anyString())).thenReturn("5");
// when & then
assertThrows(BusinessException.class, () -> userService.login(request));
verify(userMapper, never()).selectByPhone(anyString());
}
}
十一、运行与测试
11.1 启动应用
bash
# 编译打包
mvn clean package
# 启动应用
java -jar target/user-center-1.0.0.jar
# 或者通过 Maven 直接运行
mvn spring-boot:run
11.2 接口测试
注册:
bash
curl -X POST http://localhost:8080/api/v1/user/register \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000","password":"123456","nickname":"测试用户"}'
登录:
bash
curl -X POST http://localhost:8080/api/v1/user/login \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000","password":"123456"}'
获取用户信息:
bash
curl -X GET http://localhost:8080/api/v1/user/info \
-H "Authorization: Bearer {token}"
11.3 验证要点
yaml
功能验证:
- [ ] 注册成功,数据库有记录
- [ ] 密码被 BCrypt 加密
- [ ] 重复手机号注册被拦截
- [ ] 登录成功返回 Token
- [ ] 错误密码登录失败
- [ ] 连续 5 次失败被锁定
- [ ] Token 验证生效
- [ ] 登出后 Token 失效
性能验证:
- [ ] 注册接口响应时间 < 100ms
- [ ] 登录接口响应时间 < 100ms
- [ ] 查询接口响应时间 < 50ms
十二、常见问题与解决方案
12.1 开发中遇到的坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Token 解析失败 | secret 长度不足 256 位 | 使用 32 字节以上的字符串 |
| Redis 连接超时 | 未启动 Redis 服务 | 启动 Redis 或禁用 Redis 配置 |
| 事务不回滚 | 异常被 catch 了 | 确保异常抛出给事务管理器 |
| 循环依赖 | Service 互相注入 | 使用 @Lazy 或重构设计 |
12.2 优化建议
yaml
性能优化:
- 添加 Redis 缓存用户信息
- 使用 Caffeine 本地二级缓存
- 批量查询优化
安全加固:
- 添加接口限流
- 敏感信息脱敏
- 增加验证码功能
可观测性:
- 集成 Actuator
- 添加链路追踪
- 配置告警规则
写在最后
从零到一,我们用 Cursor 完成了整个用户中心的开发。回顾整个过程:
yaml
时间统计:
项目初始化: 5 分钟
实体类 + DTO: 10 分钟
Mapper + Service: 20 分钟
Controller + 拦截器: 15 分钟
异常处理 + 配置: 10 分钟
单元测试: 15 分钟
---
总计: 约 1.5 小时(纯 AI 生成时间)
如果纯手工开发:
预估时间: 6-8 小时
效率提升: 4-5 倍
关键心得:
- 指令越清晰,代码越完美------每次提问前,花 30 秒组织语言
- 分步生成比一次生成更可靠------先生成接口定义,再生成实现
- 审查比生成更重要------AI 生成的代码需要人工把关
- 规范文件是效率倍增器 ------
.cursorrules让 AI 输出更符合项目风格
希望这个实战案例能帮你建立起使用 AI 开发的信心。动手试试吧,你会发现 AI 编程远比你想象的强大。
如需获取更多关于 AI 编程助手实战技巧、Cursor 深度玩法、模型选型策略、提示词工程经验、AI 驱动开发工作流等内容,请持续关注本专栏 《AI Coding 实战之路》 系列文章。