Skip to content
第 6 章 ⏱ 12 分钟阅读

第 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               # 出参

二、实体类 ​

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 ​

java
@Mapper
public interface UserMapper extends BaseMapper<User> {

    // 自定义:按用户名查询(登录用)
    User selectByUsername(@Param("username") String username);

    // 自定义:按部门 ID 统计人数(部门删除时校验)
    Long countByDeptId(@Param("deptId") Long deptId);
}
xml
<!-- 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 是出参,严格分离。

java
@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;        // 按部门过滤
}
java
@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 接口 ​

java
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 实现 ​

java
@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:

java
@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 ​

java
@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:

java
@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

BCryptPasswordEncoder 会自动加盐,生成的密码形如 $2a$10$xxxx,同一个明文每次加密结果都不一样,但能匹配通过。

十、测试用例 ​

java
@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 VODTO=入参 VO=出参,严格分离
MyBatis PlusBaseMapper + @TableLogic 软删
密码BCrypt,自动加盐
唯一键联合唯一键含 is_deleted
注解@Valid + @RequiresPermission + @Operation

动手练习 ​

  1. 实现 CRUD:跟着本章代码完成 User 的 6 个接口
  2. 登录校验:用 admin/admin123 测试登录,确认密码加密生效
  3. 重复用户:测试同名用户第二次创建时抛 USER_EXISTS 异常

下一章:第 7 章:权限模块 →

本站基于 VitePress 构建 · 由 StackHub 团队维护