第 6 章:用户模块
学习目标
- 实现用户的 CRUD 接口
- 集成 Spring Security + BCrypt 完成注册/登录
- 实现分页查询 + 关键字搜索
一、模块结构
com.taskflow.system.user/
├── controller/
│ └── UserController.java
├── service/
│ ├── UserService.java # 接口
│ └── impl/UserServiceImpl.java # 实现
├── mapper/
│ ├── UserMapper.java
│ └── UserMapper.xml
├── entity/
│ └── User.java # 数据库实体
├── dto/
│ ├── UserCreateDTO.java # 新增入参
│ ├── UserUpdateDTO.java # 更新入参
│ └── UserPageQuery.java # 分页查询
└── vo/
└── UserVO.java # 出参二、实体类
package com.taskflow.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.taskflow.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_user")
public class User extends BaseEntity {
private String username;
private String password;
private String nickname;
private String email;
private String phone;
private String avatar;
private Integer gender;
private Integer status; // 0=禁用 1=正常 2=锁定
private Long deptId;
private String lastLoginIp;
private Integer version;
}继承 BaseEntity 自动有 id / createTime / createBy / updateTime / updateBy / deleted 这些公共字段。
三、Mapper
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 自定义:按用户名查询(登录用)
User selectByUsername(@Param("username") String username);
// 自定义:按部门 ID 统计人数(部门删除时校验)
Long countByDeptId(@Param("deptId") Long deptId);
}<!-- UserMapper.xml -->
<mapper namespace="com.taskflow.system.user.mapper.UserMapper">
<select id="selectByUsername" resultType="com.taskflow.system.user.entity.User">
SELECT * FROM sys_user
WHERE username = #{username} AND is_deleted = 0
LIMIT 1
</select>
<select id="countByDeptId" resultType="java.lang.Long">
SELECT COUNT(*) FROM sys_user
WHERE dept_id = #{deptId} AND is_deleted = 0
</select>
</mapper>⚠️ 坑 1:SQL 必须带
is_deleted = 0,否则逻辑删除的用户也会被查出来。MyBatis Plus 的@TableLogic只对BaseMapper内置方法生效,自定义 SQL 必须手写条件。
四、DTO / VO
DTO 是入参,VO 是出参,严格分离。
@Data
public class UserCreateDTO {
@NotBlank(message = "用户名不能为空")
@Length(min = 3, max = 50)
private String username;
@NotBlank(message = "密码不能为空")
@Length(min = 6, max = 100)
private String password;
@NotBlank(message = "昵称不能为空")
@Length(max = 50)
private String nickname;
@Email(message = "邮箱格式不正确")
private String email;
private String phone;
private Integer gender;
private Long deptId;
}
@Data
public class UserUpdateDTO {
@NotBlank
@Length(max = 50)
private String nickname;
@Email
private String email;
private String phone;
private Integer gender;
private Long deptId;
}
@Data
public class UserPageQuery {
@Min(1)
private Long current = 1L;
@Min(1) @Max(200)
private Long size = 10L;
private String keyword; // 模糊搜索 username / phone
private Integer status; // 按状态过滤
private Long deptId; // 按部门过滤
}@Data
public class UserVO {
private Long id;
private String username;
private String nickname;
private String email;
private String phone;
private String avatar;
private Integer gender;
private Integer status;
private Long deptId;
private String deptName; // 冗余字段
private LocalDateTime lastLoginTime;
private LocalDateTime createTime;
// 注意:没有 password 字段!
}⚠️ 坑 2:VO 一定要脱离 Entity。哪怕 Entity 里
password是@JsonIgnore,也不要把 Entity 直接返给前端。哪天加了token字段,前端拿走了都不知道。
五、Service 接口
public interface UserService extends IService<User> {
Long createUser(UserCreateDTO dto);
void updateUser(Long id, UserUpdateDTO dto);
void deleteUser(Long id);
void resetPassword(Long id, String newPassword);
UserVO getVOById(Long id);
PageResult<UserVO> page(UserPageQuery query);
}IService<T> 是 MyBatis Plus 提供的基础 CRUD,免去 80% 模板代码。
六、Service 实现
@Service
@RequiredArgsConstructor // 构造器注入,字段注入不推荐
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
private final PasswordEncoder passwordEncoder;
private final UserConvert userConvert;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createUser(UserCreateDTO dto) {
// ① 唯一性校验
if (baseMapper.selectByUsername(dto.getUsername()) != null) {
throw new BusinessException(ErrorCode.USER_EXISTS);
}
// ② DTO 转 Entity
User user = userConvert.toEntity(dto);
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setStatus(1);
baseMapper.insert(user);
return user.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateUser(Long id, UserUpdateDTO dto) {
User user = baseMapper.selectById(id);
Assert.notNull(user, ErrorCode.USER_NOT_FOUND);
userConvert.updateEntity(dto, user); // 只拷非 null 字段
baseMapper.updateById(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long id) {
// 保护超管
Assert.isTrue(!id.equals(1L), "超级管理员不可删除");
baseMapper.deleteById(id);
}
@Override
public void resetPassword(Long id, String newPassword) {
User user = baseMapper.selectById(id);
Assert.notNull(user, ErrorCode.USER_NOT_FOUND);
user.setPassword(passwordEncoder.encode(newPassword));
baseMapper.updateById(user);
}
@Override
public UserVO getVOById(Long id) {
User user = baseMapper.selectById(id);
Assert.notNull(user, ErrorCode.USER_NOT_FOUND);
return userConvert.toVO(user);
}
@Override
public PageResult<UserVO> page(UserPageQuery query) {
Page<User> page = new Page<>(query.getCurrent(), query.getSize());
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
.like(StringUtils.hasText(query.getKeyword()), User::getUsername, query.getKeyword())
.or().like(StringUtils.hasText(query.getKeyword()), User::getPhone, query.getKeyword())
.eq(query.getStatus() != null, User::getStatus, query.getStatus())
.eq(query.getDeptId() != null, User::getDeptId, query.getDeptId())
.orderByDesc(User::getCreateTime);
IPage<User> result = baseMapper.selectPage(page, wrapper);
return PageResult.of(result, userConvert::toVO);
}
}⚠️ 坑 3:
@Transactional默认只在RuntimeException时回滚。加上rollbackFor = Exception.class确保任何异常都回滚。否则IOException等检查异常不会触发回滚。
七、Convert(转换器)
不要在 Service 里写一堆 setXxx/getYyy,用 MapStruct 或手写 Convert:
@Component
public class UserConvert {
public User toEntity(UserCreateDTO dto) {
User user = new User();
BeanUtils.copyProperties(dto, user);
return user;
}
public void updateEntity(UserUpdateDTO dto, User user) {
BeanUtils.copyProperties(dto, user, "id"); // 忽略 ID
}
public UserVO toVO(User user) {
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
return vo;
}
}八、Controller
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
@Tag(name = "用户管理")
public class UserController {
private final UserService userService;
@GetMapping("/page")
@Operation(summary = "分页查询")
public Result<PageResult<UserVO>> page(UserPageQuery query) {
return Result.ok(userService.page(query));
}
@GetMapping("/{id}")
@Operation(summary = "查询详情")
public Result<UserVO> getById(@PathVariable Long id) {
return Result.ok(userService.getVOById(id));
}
@PostMapping
@Operation(summary = "创建用户")
@RequiresPermission("user:create")
public Result<Long> create(@RequestBody @Valid UserCreateDTO dto) {
return Result.ok(userService.createUser(dto));
}
@PutMapping("/{id}")
@Operation(summary = "更新用户")
@RequiresPermission("user:update")
public Result<Void> update(@PathVariable Long id, @RequestBody @Valid UserUpdateDTO dto) {
userService.updateUser(id, dto);
return Result.ok();
}
@DeleteMapping("/{id}")
@Operation(summary = "删除用户")
@RequiresPermission("user:delete")
public Result<Void> delete(@PathVariable Long id) {
userService.deleteUser(id);
return Result.ok();
}
@PutMapping("/{id}/reset-password")
@Operation(summary = "重置密码")
@RequiresPermission("user:reset-password")
public Result<Void> resetPassword(@PathVariable Long id,
@RequestParam String newPassword) {
userService.resetPassword(id, newPassword);
return Result.ok();
}
}⚠️ 坑 4:
@RequestBody @Valid是固定搭配。@Valid触发参数校验,失败抛MethodArgumentNotValidException,会被全局异常处理器转成 400 响应。漏写@Valid注解,参数校验形同虚设。
九、注册 PasswordEncoder
Spring Security 的密码编码器必须注册成 Bean:
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}BCryptPasswordEncoder 会自动加盐,生成的密码形如 $2a$10$xxxx,同一个明文每次加密结果都不一样,但能匹配通过。
十、测试用例
@SpringBootTest
@Transactional
@Rollback
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserMapper userMapper;
@Test
void testCreateUser() {
UserCreateDTO dto = new UserCreateDTO();
dto.setUsername("alice");
dto.setPassword("123456");
dto.setNickname("爱丽丝");
Long id = userService.createUser(dto);
assertNotNull(id);
User user = userMapper.selectById(id);
assertNotEquals("123456", user.getPassword()); // 已加密
assertTrue(user.getPassword().startsWith("$2a$")); // BCrypt
}
@Test
void testCreateUser_DuplicateUsername() {
UserCreateDTO dto = new UserCreateDTO();
dto.setUsername("alice");
dto.setPassword("123456");
userService.createUser(dto);
assertThrows(BusinessException.class, () -> userService.createUser(dto));
}
}十一、本章小结
| 要点 | 关键 |
|---|---|
| 模块结构 | controller / service / mapper / dto / vo |
| DTO vs VO | DTO=入参 VO=出参,严格分离 |
| MyBatis Plus | BaseMapper + @TableLogic 软删 |
| 密码 | BCrypt,自动加盐 |
| 唯一键 | 联合唯一键含 is_deleted |
| 注解 | @Valid + @RequiresPermission + @Operation |
动手练习
- 实现 CRUD:跟着本章代码完成 User 的 6 个接口
- 登录校验:用 admin/admin123 测试登录,确认密码加密生效
- 重复用户:测试同名用户第二次创建时抛
USER_EXISTS异常
下一章:第 7 章:权限模块 →