SpringBoot MVC 分层最佳实践

1. 项目结构规范

1.1 推荐目录结构

src/main/java/com/example/project/
├── controller/          # 控制器层
│   └── UserController.java
├── service/             # 服务层接口
│   └── UserService.java
├── service/impl/        # 服务层实现
│   └── UserServiceImpl.java
├── mapper/              # MyBatis Mapper 接口
│   └── UserMapper.java
├── model/               # 模型层(model/pojo)
│   ├── entity/          # 数据库实体(entity/domain)
│   │   └── User.java
│   ├── dto/             # 数据传输对象(请求)
│   │   └── user/
│   │       ├── UserRegisterDTO.java
│   │       ├── UserLoginDTO.java
│   │       └── UserUpdateDTO.java
│   ├── vo/              # 视图对象(响应)
│   │   └── UserVO.java
│   ├── query/           # 查询对象
│   │   └── UserQueryDTO.java
│   └── enums/           # 枚举类
│       ├── GenderEnum.java
│       ├── UserStatusEnum.java
│       └── UserRoleEnum.java
├── common/              # 公共模块
│   ├── BaseResponse.java
│   ├── ErrorCode.java
│   ├── ResultUtils.java
│   └── PageResult.java
├── exception/           # 异常处理
│   ├── BusinessException.java
│   └── GlobalExceptionHandler.java
├── config/              # 配置类
│   └── MyBatisPlusConfig.java
├── utils/               # 工具类
│   └── PasswordUtils.java
└── validation/          # 自定义校验器
    └── groups/
        └── UpdateGroup.java

1.2 命名规范演进

旧名称 新名称(推荐) 说明
POJO Model 更通用的术语
Domain Entity 明确表示数据库实体
Request DTO 数据传输对象,用于请求
Response VO 视图对象,用于响应

2. 实体类设计规范

2.1 数据库设计原则

-- 推荐:字段使用下划线命名
CREATE TABLE `user` (
    `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
    `user_name` VARCHAR(50) NOT NULL COMMENT '用户昵称',
    `user_account` VARCHAR(50) NOT NULL COMMENT '账号',
    `user_password` VARCHAR(256) NOT NULL COMMENT '密码',
    `avatar_url` VARCHAR(512) DEFAULT NULL COMMENT '头像URL',
    `gender` VARCHAR(10) DEFAULT 'UNKNOWN' COMMENT '性别',
    `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
    `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
    `user_status` VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '用户状态',
    `user_role` VARCHAR(20) DEFAULT 'USER' COMMENT '用户角色',
    `tags` VARCHAR(1024) DEFAULT NULL COMMENT '标签JSON',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `is_delete` TINYINT DEFAULT 0 COMMENT '逻辑删除标识',
    UNIQUE KEY `uk_user_account` (`user_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

2.2 Entity 实体类

package com.zwnsyw.zwwwspringbootbasetemplate.model.entity;

import com.baomidou.mybatisplus.annotation.*;

import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.GenderEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserRoleEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserStatusEnum;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 用户实体
 *
 * @author Zwww
 */
@Data
@TableName("user")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键ID
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 密码(加密存储)
     */
    private String userPassword;

    /**
     * 头像URL
     */
    private String avatarUrl;

    /**
     * 性别
     */
    private GenderEnum gender;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 邮箱
     */
    private String email;

    /**
     * 用户状态
     */
    private UserStatusEnum userStatus;

    /**
     * 用户角色
     */
    private UserRoleEnum userRole;

    /**
     * 标签JSON
     */
    private String tags;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;

    /**
     * 逻辑删除标识(0-未删除,1-已删除)
     */
    @TableLogic
    private Integer isDelete;
}

2.3 Entity 设计原则

原则 说明
不放校验注解 Entity 是数据库映射,校验逻辑放在 DTO
使用枚举类型 固定选项字段使用枚举,提高类型安全
使用 LocalDateTime 替代 Date,更现代的时间 API
逻辑删除字段 使用 @TableLogic 注解
序列化支持 实现 Serializable 接口

3. 枚举设计规范

3.1 用户性别枚举

package com.zwnsyw.zwwwspringbootbasetemplate.model.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

/**
 * 性别枚举
 */
public enum GenderEnum {

    MALE("MALE", "男"),
    FEMALE("FEMALE", "女"),
    UNKNOWN("UNKNOWN", "保密");

    @EnumValue
    private final String code;

    private final String description;

    GenderEnum(String code, String description) {
        this.code = code;
        this.description = description;
    }

    @JsonValue
    public String getCode() {
        return code;
    }

    public String getDescription() {
        return description;
    }

    @JsonCreator
    public static GenderEnum fromCode(String code) {
        if (code == null) {
            return UNKNOWN;
        }
        for (GenderEnum gender : values()) {
            if (gender.code.equalsIgnoreCase(code)) {
                return gender;
            }
        }
        return UNKNOWN;
    }

    public static boolean isValid(String code) {
        for (GenderEnum gender : values()) {
            if (gender.code.equalsIgnoreCase(code)) {
                return true;
            }
        }
        return false;
    }
}

3.2 用户状态枚举

package com.zwnsyw.zwwwspringbootbasetemplate.model.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

/**
 * 用户状态枚举
 */
public enum UserStatusEnum {

    ACTIVE("ACTIVE", "正常"),
    DISABLED("DISABLED", "禁用"),
    LOCKED("LOCKED", "锁定");

    @EnumValue
    private final String code;

    private final String description;

    UserStatusEnum(String code, String description) {
        this.code = code;
        this.description = description;
    }

    @JsonValue
    public String getCode() {
        return code;
    }

    public String getDescription() {
        return description;
    }

    @JsonCreator
    public static UserStatusEnum fromCode(String code) {
        if (code == null) {
            return ACTIVE;
        }
        for (UserStatusEnum status : values()) {
            if (status.code.equalsIgnoreCase(code)) {
                return status;
            }
        }
        throw new IllegalArgumentException("未知的用户状态: " + code);
    }
}

3.3 用户角色枚举

package com.zwnsyw.zwwwspringbootbasetemplate.model.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;

@Getter
public enum UserRoleEnum {
    USER("USER", "普通用户"),
    ADMIN("ADMIN", "管理员"),
    BAN("BAN", "封禁用户");

    @EnumValue
    private final String code;

    private final String description;

    UserRoleEnum(String code, String description) {
        this.code = code;
        this.description = description;
    }

    @JsonValue
    public String getCode() {
        return code;
    }

    @JsonCreator
    public static UserRoleEnum fromCode(String code) {
        if (code == null) {
            return null;
        }
        for (UserRoleEnum role : values()) {
            if (role.code.equalsIgnoreCase(code)) {
                return role;
            }
        }
        throw new IllegalArgumentException("未知的用户角色: " + code);
    }

    public boolean isAdmin() {
        return this == ADMIN;
    }

    public boolean isBanned() {
        return this == BAN;
    }

    public boolean isUser() {
        return this == USER;
    }
}

3.4 枚举设计总结

注解 作用 使用位置
@EnumValue 指定存储到数据库的字段 code 字段
@JsonValue 指定序列化到 JSON 的字段 description 字段
@JsonCreator 指定反序列化时的工厂方法 fromCode 方法

4. DTO/VO 设计规范

4.1 DTO(Data Transfer Object)- 请求数据

用户注册 DTO

package com.example.project.model.dto.user;

import lombok.Data;

import javax.validation.constraints.*;
import java.io.Serializable;

/**
 * 用户注册请求
 */
@Data
public class UserRegisterDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 账号
     */
    @NotBlank(message = "账号不能为空")
    @Size(min = 4, max = 20, message = "账号长度必须在4-20之间")
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "账号只能包含字母、数字和下划线")
    private String userAccount;

    /**
     * 密码
     */
    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 20, message = "密码长度必须在8-20之间")
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]+$",
        message = "密码必须包含大小写字母和数字"
    )
    private String userPassword;

    /**
     * 确认密码
     */
    @NotBlank(message = "确认密码不能为空")
    private String checkPassword;

    /**
     * 校验密码是否一致
     */
    @AssertTrue(message = "两次输入的密码不一致")
    public boolean isPasswordMatch() {
        if (userPassword == null || checkPassword == null) {
            return true; // 交给 @NotBlank 处理
        }
        return userPassword.equals(checkPassword);
    }
}

用户登录 DTO

package com.example.project.model.dto.user;

import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.io.Serializable;

/**
 * 用户登录请求
 */
@Data
public class UserLoginDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 账号
     */
    @NotBlank(message = "账号不能为空")
    @Size(max = 20, message = "账号长度不能超过20")
    private String userAccount;

    /**
     * 密码
     */
    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 20, message = "密码长度必须在8-20之间")
    private String userPassword;
}

用户更新 DTO(分组校验)

package com.example.project.model.dto.user;

import com.example.project.validation.groups.UpdateGroup;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;

import javax.validation.constraints.*;
import java.io.Serializable;

/**
 * 用户信息更新请求
 */
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserUpdateDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 用户ID(必填)
     */
    @NotNull(message = "用户ID不能为空", groups = UpdateGroup.class)
    private Long id;

    /**
     * 用户昵称
     */
    @Size(max = 50, message = "用户名长度不能超过50", groups = UpdateGroup.class)
    private String userName;

    /**
     * 头像URL
     */
    @Size(max = 512, message = "头像URL长度不能超过512", groups = UpdateGroup.class)
    private String avatarUrl;

    /**
     * 性别
     */
    private String gender;

    /**
     * 手机号
     */
    @Pattern(
        regexp = "^1[3-9]\\d{9}$",
        message = "手机号格式不正确",
        groups = UpdateGroup.class
    )
    private String phone;

    /**
     * 邮箱
     */
    @Email(message = "邮箱格式不正确", groups = UpdateGroup.class)
    private String email;

    /**
     * 标签列表(JSON格式)
     */
    @Size(max = 1024, message = "标签内容过长", groups = UpdateGroup.class)
    private String tags;
}

分组校验接口

package com.example.project.validation.groups;

/**
 * 更新操作的校验分组
 */
public interface UpdateGroup {
}

/**
 * 创建操作的校验分组
 */
public interface CreateGroup {
}

4.2 VO(View Object)- 响应数据

package com.zwnsyw.zwwwspringbootbasetemplate.model.vo;

import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.GenderEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserRoleEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 用户视图对象(脱敏后返回给前端)
 */
@Data
@Schema(description = "用户视图对象")
public class UserVO implements Serializable {

    private static final long serialVersionUID = 1L;

    @Schema(description = "用户ID")
    private Long id;

    @Schema(description = "用户昵称")
    private String userName;

    @Schema(description = "用户账号")
    private String userAccount;

    @Schema(description = "头像URL")
    private String avatarUrl;

    @Schema(description = "性别")
    private GenderEnum gender;

    @Schema(description = "手机号(脱敏)", example = "138****8888")
    private String phone;

    @Schema(description = "邮箱(脱敏)", example = "u***@example.com")
    private String email;

    @Schema(description = "用户状态")
    private UserStatusEnum userStatus;

    @Schema(description = "用户角色")
    private UserRoleEnum userRole;

    @Schema(description = "标签JSON")
    private String tags;

    @Schema(description = "创建时间")
    private LocalDateTime createTime;

    /**
     * Entity 转 VO(带脱敏处理)
     */
    public static UserVO fromEntity(User user) {
        if (user == null) {
            return null;
        }
        UserVO vo = new UserVO();
        vo.setId(user.getId());
        vo.setUserName(user.getUserName());
        vo.setUserAccount(user.getUserAccount());
        vo.setAvatarUrl(user.getAvatarUrl());
        vo.setGender(user.getGender());
        vo.setPhone(maskPhone(user.getPhone()));
        vo.setEmail(maskEmail(user.getEmail()));
        vo.setUserStatus(user.getUserStatus());
        vo.setUserRole(user.getUserRole());
        vo.setTags(user.getTags());
        vo.setCreateTime(user.getCreateTime());
        return vo;
    }

    /**
     * 手机号脱敏:138****8888
     */
    private static String maskPhone(String phone) {
        if (phone == null || phone.length() < 11) {
            return phone;
        }
        return phone.substring(0, 3) + "****" + phone.substring(7);
    }

    /**
     * 邮箱脱敏:u***@example.com
     */
    private static String maskEmail(String email) {
        if (email == null || !email.contains("@")) {
            return email;
        }
        int atIndex = email.indexOf("@");
        if (atIndex <= 1) {
            return email;
        }
        return email.charAt(0) + "***" + email.substring(atIndex);
    }
}

4.3 DTO/VO 设计对比

特性 DTO VO
用途 接收前端请求数据 返回给前端的响应数据
校验注解 ✅ 必须有 ❌ 不需要
脱敏处理 ❌ 不需要 ✅ 必须有
字段范围 仅包含请求需要的字段 仅包含需要展示的字段
敏感字段 可以包含(如密码) 不能包含(如密码)

5. 三层校验机制

5.1 校验分层架构图

┌─────────────────────────────────────────────────────────────┐
│                      前端校验                                 │
│                  (表单验证、即时反馈)                          │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  Controller 层校验                                           │
│  ├── 请求参数非空判断                                         │
│  ├── 触发 DTO 校验 (@Valid)                                  │
│  └── 简单的参数合法性检查                                     │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  DTO 层校验                                                  │
│  ├── 字段非空校验 (@NotBlank, @NotNull)                      │
│  ├── 格式校验 (@Email, @Pattern)                             │
│  ├── 范围校验 (@Size, @Min, @Max)                            │
│  └── 复杂校验 (@AssertTrue 自定义方法)                        │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  Service 层校验                                              │
│  ├── 业务逻辑校验                                            │
│  ├── 数据库查询校验 (账号是否存在等)                           │
│  ├── 权限校验                                                │
│  └── 复杂业务规则校验                                         │
└─────────────────────────────────────────────────────────────┘

5.2 Controller 层

package com.zwnsyw.zwwwspringbootbasetemplate.controller;

import com.zwnsyw.zwwwspringbootbasetemplate.common.response.BaseResponse;
import com.zwnsyw.zwwwspringbootbasetemplate.common.response.ResultUtils;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserLoginDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserRegisterDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserUpdateDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.UserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.service.UserService;
import com.zwnsyw.zwwwspringbootbasetemplate.validation.groups.UpdateGroup;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;

/**
 * 用户控制器
 *
 * @author Zwww
 */
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
@Tag(name = "用户模块", description = "用户注册、登录、信息管理等接口")
public class UserController {

    private final UserService userService;

    /**
     * 用户注册
     */
    @PostMapping("/register")
    @Operation(summary = "用户注册", description = "通过账号密码注册新用户")
    public BaseResponse<Long> register(@RequestBody @Valid UserRegisterDTO dto) {
        // @Valid 已完成参数校验,dto 不会为 null(Spring 自动处理)
        Long userId = userService.register(dto);
        return ResultUtils.success(userId);
    }

    /**
     * 用户登录
     */
    @PostMapping("/login")
    @Operation(summary = "用户登录", description = "通过账号密码登录,成功后会创建 Session")
    public BaseResponse<UserVO> login(
            @RequestBody @Valid UserLoginDTO dto,
            HttpServletRequest request) {
        UserVO userVO = userService.login(dto, request);
        return ResultUtils.success(userVO);
    }

    /**
     * 更新用户信息
     */
    @PostMapping("/update")
    @Operation(summary = "更新用户信息", description = "更新当前登录用户或指定用户的信息(管理员)")
    public BaseResponse<Boolean> update(
            @RequestBody @Validated(UpdateGroup.class) UserUpdateDTO dto,
            HttpServletRequest request) {
        LoginUserVO loginUser = userService.getLoginUser(request);
        Boolean result = userService.updateUser(dto, loginUser);
        return ResultUtils.success(result);
    }

    /**
     * 获取当前登录用户
     */
    @GetMapping("/current")
    @Operation(summary = "获取当前登录用户", description = "获取当前登录用户的详细信息(从数据库实时查询)")
    public BaseResponse<UserVO> getCurrentUser(HttpServletRequest request) {
        UserVO userVO = userService.getCurrentUserVO(request);
        return ResultUtils.success(userVO);
    }

    /**
     * 用户退出登录
     */
    @PostMapping("/logout")
    @Operation(summary = "用户退出登录", description = "清除当前用户的登录状态")
    public BaseResponse<Boolean> logout(HttpServletRequest request) {
        Boolean result = userService.logout(request);
        return ResultUtils.success(result);
    }

    /**
     * 根据ID获取用户信息(公开接口)
     */
    @GetMapping("/{id}")
    @Operation(summary = "根据ID获取用户", description = "获取指定用户的公开信息")
    public BaseResponse<UserVO> getUserById(
            @Parameter(description = "用户ID") @PathVariable Long id) {
        UserVO userVO = userService.getUserVOById(id);
        return ResultUtils.success(userVO);
    }
}

5.3 Service 层

package com.zwnsyw.zwwwspringbootbasetemplate.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserLoginDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserRegisterDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserUpdateDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.UserVO;

import javax.servlet.http.HttpServletRequest;

/**
 * 用户服务接口
 *
 * @author Zwww
 */
public interface UserService extends IService<User> {

    /**
     * 用户注册
     *
     * @param dto 注册信息
     * @return 新用户ID
     */
    Long register(UserRegisterDTO dto);

    /**
     * 用户登录
     *
     * @param dto     登录信息
     * @param request HTTP请求
     * @return 用户视图对象
     */
    UserVO login(UserLoginDTO dto, HttpServletRequest request);

    /**
     * 更新用户信息
     *
     * @param dto       更新信息
     * @param loginUser 当前登录用户
     * @return 是否成功
     */
    Boolean updateUser(UserUpdateDTO dto, LoginUserVO loginUser);

    /**
     * 获取当前登录用户(Session 中的简化信息)
     *
     * @param request HTTP请求
     * @return 登录用户信息
     */
    LoginUserVO getLoginUser(HttpServletRequest request);

    /**
     * 获取当前登录用户的完整 VO(从数据库查询)
     *
     * @param request HTTP请求
     * @return 用户视图对象
     */
    UserVO getCurrentUserVO(HttpServletRequest request);

    /**
     * 根据ID获取用户 VO
     *
     * @param id 用户ID
     * @return 用户视图对象
     */
    UserVO getUserVOById(Long id);

    /**
     * 用户退出登录
     *
     * @param request HTTP请求
     * @return 是否成功
     */
    Boolean logout(HttpServletRequest request);
}
package com.zwnsyw.zwwwspringbootbasetemplate.service.serviceimpl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zwnsyw.zwwwspringbootbasetemplate.exception.ErrorCode;
import com.zwnsyw.zwwwspringbootbasetemplate.exception.ThrowUtils;
import com.zwnsyw.zwwwspringbootbasetemplate.mapper.UserMapper;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserLoginDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserRegisterDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserUpdateDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.GenderEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserRoleEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserStatusEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.UserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.service.UserService;
import com.zwnsyw.zwwwspringbootbasetemplate.utils.PasswordUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

/**
 * 用户服务实现
 *
 * @author Zwww
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    private static final String USER_LOGIN_STATE = "USER_LOGIN_STATE";

    /**
     * 账号正则:只允许字母、数字、下划线
     */
    private static final String ACCOUNT_PATTERN = "^[a-zA-Z0-9_]+$";

    private final PasswordUtils passwordUtils;

    @Override
    public Long register(UserRegisterDTO dto) {
        String userAccount = dto.getUserAccount();
        String userPassword = dto.getUserPassword();

        // ============ Service 层:业务级校验 ============

        // 1. 账号格式校验
        ThrowUtils.throwIf(
                !userAccount.matches(ACCOUNT_PATTERN),
                ErrorCode.USER_ACCOUNT_INVALID,
                "账号只能包含字母、数字、下划线"
        );

        // 2. 账号是否已存在
        ThrowUtils.throwIf(
                isAccountExists(userAccount),
                ErrorCode.USER_ACCOUNT_ALREADY_EXISTS,
                String.format("账号 '%s' 已被注册", userAccount)
        );

        // 3. 加密密码
        String encryptedPassword = passwordUtils.encrypt(userPassword);

        // 4. 创建用户
        User user = new User();
        user.setUserAccount(userAccount);
        user.setUserPassword(encryptedPassword);
        user.setUserName("用户" + System.currentTimeMillis());
        user.setGender(GenderEnum.UNKNOWN);
        user.setUserStatus(UserStatusEnum.ACTIVE);
        user.setUserRole(UserRoleEnum.USER);

        boolean saved = this.save(user);
        ThrowUtils.throwIf(!saved, ErrorCode.USER_REGISTER_FAILED, "注册失败,请稍后重试");

        log.info("用户注册成功,userId: {}, account: {}", user.getId(), userAccount);
        return user.getId();
    }

    @Override
    public UserVO login(UserLoginDTO dto, HttpServletRequest request) {
        String userAccount = dto.getUserAccount();
        String userPassword = dto.getUserPassword();

        // ============ Service 层:业务级校验 ============

        // 1. 查询用户
        User user = getByAccount(userAccount);
        if (user == null) {
            log.info("用户登录失败,账号不存在: {}", userAccount);
            // 统一返回"账号或密码错误",避免暴露账号是否存在
            throw new com.zwnsyw.zwwwspringbootbasetemplate.exception.BusinessException(
                    ErrorCode.USER_LOGIN_FAILED, "账号或密码错误"
            );
        }

        // 2. 校验密码
        if (!passwordUtils.verify(userPassword, user.getUserPassword())) {
            log.info("用户登录失败,密码错误: {}", userAccount);
            throw new com.zwnsyw.zwwwspringbootbasetemplate.exception.BusinessException(
                    ErrorCode.USER_LOGIN_FAILED, "账号或密码错误"
            );
        }

        // 3. 校验用户状态
        ThrowUtils.throwIf(
                user.getUserStatus() == UserStatusEnum.DISABLED,
                ErrorCode.USER_ACCOUNT_DISABLED,
                String.format("账号 '%s' 已被禁用,如有疑问请联系管理员", userAccount)
        );
        ThrowUtils.throwIf(
                user.getUserStatus() == UserStatusEnum.LOCKED,
                ErrorCode.USER_ACCOUNT_LOCKED,
                String.format("账号 '%s' 已被锁定,请稍后重试", userAccount)
        );

        // 4. 存储登录态(只存储必要信息)
        HttpSession session = request.getSession();
        LoginUserVO loginUserVO = LoginUserVO.fromEntity(user);
        session.setAttribute(USER_LOGIN_STATE, loginUserVO);

        log.info("用户登录成功,userId: {}, account: {}", user.getId(), userAccount);
        return UserVO.fromEntity(user);
    }

    @Override
    public Boolean updateUser(UserUpdateDTO dto, LoginUserVO loginUser) {
        Long targetUserId = dto.getId();

        // ============ Service 层:业务级校验 ============

        // 1. 权限校验:只能修改自己的信息,管理员可以修改任何人
        boolean isSelf = targetUserId.equals(loginUser.getId());
        boolean isAdmin = loginUser.getUserRole() != null && loginUser.getUserRole().isAdmin();
        ThrowUtils.throwIf(
                !isSelf && !isAdmin,
                ErrorCode.USER_NO_PERMISSION,
                "无权限修改其他用户信息"
        );

        // 2. 校验目标用户是否存在
        User targetUser = ThrowUtils.throwIfNull(
                this.getById(targetUserId),
                ErrorCode.USER_NOT_FOUND,
                String.format("用户 ID=%d 不存在", targetUserId)
        );

        // 3. 更新字段(仅更新非空字段)
        updateUserFields(dto, targetUser);

        boolean updated = this.updateById(targetUser);
        ThrowUtils.throwIf(!updated, ErrorCode.USER_UPDATE_FAILED, "更新用户信息失败");

        log.info("用户信息更新成功,targetUserId: {}, operatorId: {}", targetUserId, loginUser.getId());
        return true;
    }

    @Override
    public LoginUserVO getLoginUser(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        ThrowUtils.throwIf(session == null, ErrorCode.USER_NOT_LOGIN, "请先登录");

        Object userObj = session.getAttribute(USER_LOGIN_STATE);
        ThrowUtils.throwIfNull(userObj, ErrorCode.USER_NOT_LOGIN, "请先登录");

        return (LoginUserVO) userObj;
    }

    @Override
    public UserVO getCurrentUserVO(HttpServletRequest request) {
        LoginUserVO loginUser = getLoginUser(request);

        // 从数据库获取最新信息
        User user = ThrowUtils.throwIfNull(
                this.getById(loginUser.getId()),
                ErrorCode.USER_NOT_FOUND,
                "用户不存在或已被删除"
        );

        return UserVO.fromEntity(user);
    }

    @Override
    public UserVO getUserVOById(Long id) {
        ThrowUtils.throwIfNull(id, ErrorCode.PARAMS_ERROR, "用户ID不能为空");

        User user = ThrowUtils.throwIfNull(
                this.getById(id),
                ErrorCode.USER_NOT_FOUND,
                String.format("用户 ID=%d 不存在", id)
        );

        return UserVO.fromEntity(user);
    }

    @Override
    public Boolean logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.removeAttribute(USER_LOGIN_STATE);
            log.info("用户退出登录成功");
        }
        return true;
    }

    // ============ 私有辅助方法 ============

    /**
     * 根据账号查询用户
     */
    private User getByAccount(String userAccount) {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserAccount, userAccount);
        return this.getOne(queryWrapper);
    }

    /**
     * 检查账号是否已存在
     */
    private boolean isAccountExists(String userAccount) {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserAccount, userAccount);
        return this.count(queryWrapper) > 0;
    }

    /**
     * 检查手机号是否被其他用户使用
     */
    private boolean isPhoneUsedByOther(String phone, Long excludeUserId) {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone, phone)
                .ne(User::getId, excludeUserId);
        return this.count(queryWrapper) > 0;
    }

    /**
     * 检查邮箱是否被其他用户使用
     */
    private boolean isEmailUsedByOther(String email, Long excludeUserId) {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getEmail, email)
                .ne(User::getId, excludeUserId);
        return this.count(queryWrapper) > 0;
    }

    /**
     * 更新用户字段
     */
    private void updateUserFields(UserUpdateDTO dto, User targetUser) {
        Long userId = targetUser.getId();

        if (StringUtils.isNotBlank(dto.getUserName())) {
            targetUser.setUserName(dto.getUserName());
        }
        if (StringUtils.isNotBlank(dto.getAvatarUrl())) {
            targetUser.setAvatarUrl(dto.getAvatarUrl());
        }
        if (StringUtils.isNotBlank(dto.getGender())) {
            targetUser.setGender(GenderEnum.fromCode(dto.getGender()));
        }
        if (StringUtils.isNotBlank(dto.getPhone())) {
            ThrowUtils.throwIf(
                    isPhoneUsedByOther(dto.getPhone(), userId),
                    ErrorCode.USER_PHONE_ALREADY_EXISTS,
                    String.format("手机号 '%s' 已被其他用户使用", dto.getPhone())
            );
            targetUser.setPhone(dto.getPhone());
        }
        if (StringUtils.isNotBlank(dto.getEmail())) {
            ThrowUtils.throwIf(
                    isEmailUsedByOther(dto.getEmail(), userId),
                    ErrorCode.USER_EMAIL_ALREADY_EXISTS,
                    String.format("邮箱 '%s' 已被其他用户使用", dto.getEmail())
            );
            targetUser.setEmail(dto.getEmail());
        }
        if (StringUtils.isNotBlank(dto.getTags())) {
            targetUser.setTags(dto.getTags());
        }
    }
}

PasswordUtils

package com.zwnsyw.zwwwspringbootbasetemplate.utils;

import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.stereotype.Component;

/**
 * 密码加密工具类
 * <p>
 * 使用 BCrypt 算法 + 静态盐进行密码加密
 * BCrypt 本身会生成随机盐并存储在哈希结果中,静态盐提供额外的安全层
 * </p>
 *
 * @author zwnsyw
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class PasswordUtils {

    private final AppProperties appProperties;

    /**
     * 加密密码
     *
     * @param rawPassword 明文密码
     * @return 加密后的密码
     */
    public String encrypt(String rawPassword) {
        if (rawPassword == null || rawPassword.isEmpty()) {
            throw new IllegalArgumentException("密码不能为空");
        }

        String enhancedPassword = enhance(rawPassword);
        String salt = BCrypt.gensalt(appProperties.getSecurity().getBcryptStrength());
        return BCrypt.hashpw(enhancedPassword, salt);
    }

    /**
     * 验证密码
     *
     * @param rawPassword       用户输入的明文密码
     * @param encryptedPassword 数据库中存储的加密密码
     * @return 是否匹配
     */
    public boolean verify(String rawPassword, String encryptedPassword) {
        if (rawPassword == null || encryptedPassword == null) {
            return false;
        }

        try {
            String enhancedPassword = enhance(rawPassword);
            return BCrypt.checkpw(enhancedPassword, encryptedPassword);
        } catch (Exception e) {
            log.warn("密码验证异常: {}", e.getMessage());
            return false;
        }
    }

    /**
     * 增强密码(添加静态盐)
     */
    private String enhance(String rawPassword) {
        return appProperties.getSecurity().getPasswordSalt() + rawPassword;
    }

    /**
     * 检查密码是否需要重新加密(用于密码策略升级)
     *
     * @param encryptedPassword 加密后的密码
     * @return 是否需要重新加密
     */
    public boolean needsRehash(String encryptedPassword) {
        if (encryptedPassword == null || !encryptedPassword.startsWith("$2")) {
            return true;
        }

        try {
            // BCrypt 哈希格式: $2a$10$... 其中 10 是强度
            String[] parts = encryptedPassword.split("\\$");
            if (parts.length >= 3) {
                int currentStrength = Integer.parseInt(parts[2]);
                return currentStrength < appProperties.getSecurity().getBcryptStrength();
            }
        } catch (Exception e) {
            log.warn("解析密码哈希强度失败: {}", e.getMessage());
        }

        return true;
    }
}

5.4 三层校验职责总结

层级 校验类型 示例
Controller 参数非空、触发DTO校验 dto == null@Valid
DTO 字段格式、长度、范围 @NotBlank@Size@Pattern@AssertTrue
Service 业务逻辑、数据库校验 账号是否存在、密码是否正确、权限校验

6. 代码生成与配置

6.1 Maven 依赖

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- ==================== 项目基本信息 ==================== -->
    <!-- 修改为你的组织/公司标识 -->
    <groupId>com.zwnsyw</groupId>
    <!-- 修改为你的项目名称 -->
    <artifactId>ZwwwSpringBootBaseTemplate</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ZwwwSpringBootBaseTemplate</name>
    <description>ZwwwSpringBootBaseTemplate</description>

    <!-- ==================== 版本属性配置 ==================== -->
    <properties>
        <!-- Java 版本 -->
        <java.version>1.8</java.version>
        <!-- 源码编码格式 -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <!-- 输出报告编码格式 -->
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <!-- Spring Boot 版本 -->
        <spring-boot.version>2.7.6</spring-boot.version>
        <!-- MyBatis Plus 版本 -->
        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
    </properties>

    <dependencies>
        <!-- ==================== Spring Boot 核心依赖 ==================== -->

        <!-- Web 启动器:包含 Spring MVC、内嵌 Tomcat -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- AOP 切面:用于日志、权限校验、事务等横切关注点 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- 单元测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- 参数校验:@NotNull、@NotBlank、@Size 等注解 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- ==================== 数据库相关 ==================== -->

        <!-- MyBatis Plus:增强版 MyBatis,简化 CRUD 操作 -->
        <!-- 使用与 SpringBoot 2.7 兼容的版本 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>

        <!-- MySQL 数据库驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- ==================== Redis 缓存相关 ==================== -->

        <!-- Redis 数据操作 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Spring Session + Redis:分布式会话管理 -->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <!-- Spring 缓存注解支持:@Cacheable、@CacheEvict 等 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

        <!-- ==================== 安全认证相关 ==================== -->

        <!-- JWT Token 生成与解析 - API -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <!-- JWT 实现 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <!-- JWT Jackson 序列化支持 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>

        <!-- BCrypt 密码加密:安全的密码哈希算法 -->
        <dependency>
            <groupId>org.mindrot</groupId>
            <artifactId>jbcrypt</artifactId>
            <version>0.4</version>
        </dependency>

        <!-- ==================== 开发工具 ==================== -->

        <!-- Lombok:简化代码,自动生成 getter/setter/构造器等 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Hutool:国产 Java 工具类库,功能全面 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.26</version>
        </dependency>

        <!-- Apache Commons Lang3:字符串、对象等常用工具 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>

        <!-- ==================== 接口文档 ==================== -->

        <!-- Knife4j:Swagger 增强版,API 文档生成 -->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
            <version>4.4.0</version>
        </dependency>

        <!-- ==================== HTTP 客户端(可选) ==================== -->

        <!-- OkHttp:高效的 HTTP 客户端,用于调用外部 API -->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.10.0</version>
        </dependency>

    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.zwnsyw.zwwwspringbootbasetemplate.ZwwwSpringBootBaseTemplateApplication</mainClass>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

6.2 Spring Boot YAML 配置详解与多环境管理

application.yml(主配置文件)

# ==================== 服务器配置 ====================
server:
  port: 8080
  servlet:
    context-path: /api
  tomcat:
    max-http-form-post-size: 100MB
    max-swallow-size: -1

# ==================== Spring 核心配置 ====================
spring:
  application:
    name: project-name

  # 多环境配置 - 默认激活 dev
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

  # -------------------- 数据源配置 --------------------
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/database_name?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: your_password

  # -------------------- 文件上传配置 --------------------
  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB
      max-request-size: 50MB

  # -------------------- Redis 配置 --------------------
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
        max-wait: -1ms

  # -------------------- 缓存配置 --------------------
  cache:
    type: redis
    redis:
      time-to-live: 300s
      cache-null-values: true

  # -------------------- Session 配置 --------------------
  session:
    store-type: redis
    redis:
      namespace: ${spring.application.name}:session
      flush-mode: on_save
    timeout: 86400s

# ==================== MyBatis Plus 配置 ====================
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
    default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler
  global-config:
    db-config:
      logic-delete-field: isDeleted
      logic-delete-value: 1
      logic-not-delete-value: 0
  # mapper-locations: classpath*:/mapper/**/*.xml

# ==================== 接口文档配置(Knife4j) ====================
knife4j:
  enable: true
  openapi:
    title: "${app.name} 接口文档"
    description: "API 接口说明文档"
    version: ${app.version}
  group:
    default:
      api-rule: package
      api-rule-resources:
        - com.example.projectname.controller

# ==================== 自定义应用配置(AppProperties) ====================
app:
  name: project-name
  version: 1.0.0
  debug: false

  # 文件上传配置
  file:
    max-size: 10485760  # 10MB
    allowed-formats: jpg,jpeg,png,gif,webp,pdf
    upload-path: /uploads

  # JWT 配置
  jwt:
    secret: ${JWT_SECRET:your-default-secret-key-please-change-in-production}
    expiration: 604800  # 7天(秒)
    token-prefix: "Bearer "
    header-name: Authorization

  # 用户相关配置
  user:
    max-password-retry: 5
    max-login-device: 3
    lock-minutes: 30

# ==================== CORS 跨域配置 ====================
cors:
  # 允许的前端域名(多个用逗号分隔)
  allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000}

# ==================== 日志配置 ====================
logging:
  level:
    root: INFO
    com.example.projectname: DEBUG
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

application-dev.yml(开发环境)

# ==================== 开发环境配置 ====================
# 激活方式:spring.profiles.active=dev

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_database?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: dev_password

  redis:
    host: localhost
    port: 6379
    database: 0

# 开发环境开启 SQL 日志
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

# 开启接口文档
knife4j:
  enable: true

# 应用配置 - 开发环境
app:
  debug: true
  jwt:
    # 开发环境使用较短的过期时间便于测试
    expiration: 86400  # 1天

# CORS - 开发环境允许所有来源
cors:
  allowed-origins: "*"

logging:
  level:
    com.example.projectname: DEBUG
    com.example.projectname.mapper: DEBUG  # 开启 Mapper SQL 日志

application-test.yml(测试环境)

# ==================== 测试环境配置 ====================
# 激活方式:spring.profiles.active=test

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST:test-db.example.com}:3306/test_database?useSSL=true&serverTimezone=Asia/Shanghai
    username: ${DB_USERNAME:test_user}
    password: ${DB_PASSWORD:test_password}

  redis:
    host: ${REDIS_HOST:test-redis.example.com}
    port: 6379
    password: ${REDIS_PASSWORD:}
    database: 1  # 使用不同的数据库索引

# 测试环境关闭详细 SQL 日志
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

# 测试环境开启接口文档(便于测试人员使用)
knife4j:
  enable: true

app:
  debug: false
  jwt:
    secret: ${JWT_SECRET:test-environment-jwt-secret-key}

cors:
  allowed-origins: https://test.example.com,https://test-admin.example.com

logging:
  level:
    root: INFO
    com.example.projectname: DEBUG
  file:
    name: logs/application-test.log
    max-size: 50MB
    max-history: 15

application-prod.yml(生产环境)

# ==================== 生产环境配置 ====================
# 激活方式:spring.profiles.active=prod

server:
  tomcat:
    # 生产环境优化线程池
    threads:
      max: 200
      min-spare: 20

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      # 连接池优化
      maximum-pool-size: 20
      minimum-idle: 5
      idle-timeout: 300000
      connection-timeout: 20000
      max-lifetime: 1200000

  redis:
    host: ${REDIS_HOST}
    port: ${REDIS_PORT:6379}
    password: ${REDIS_PASSWORD}
    database: 0
    lettuce:
      pool:
        max-active: 16
        max-idle: 8
        min-idle: 4

# 生产环境关闭 SQL 日志
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl

# 【重要】生产环境必须关闭接口文档
knife4j:
  enable: false

app:
  debug: false
  jwt:
    # 【重要】生产环境必须通过环境变量配置
    secret: ${JWT_SECRET}
    expiration: 604800

cors:
  # 生产环境严格限制允许的域名
  allowed-origins: ${CORS_ORIGINS:https://www.example.com,https://admin.example.com}

logging:
  level:
    root: WARN
    com.example.projectname: INFO
  file:
    name: /var/log/application/app.log
    max-size: 100MB
    max-history: 30

多环境配置详解

6.3 配置类

CacheConfig.java

package com.zwnsyw.zwwwspringbootbasetemplate.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.cache.CacheManager;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

/**
 * Spring Cache 缓存配置
 * <p>
 * 配合 @Cacheable@CacheEvict@CachePut 等注解使用
 * </p>
 *
 * @author your-name
 */
@Configuration
public class CacheConfig {

    /**
     * 默认缓存过期时间(小时)
     */
    private static final long DEFAULT_TTL_HOURS = 2;

    /**
     * 配置缓存管理器
     * <p>
     * 支持:
     * - 自定义不同缓存名称的过期时间
     * - JSON 序列化存储
     * - 禁止缓存空值(防止缓存穿透可设为 true)
     * </p>
     *
     * @param connectionFactory Redis 连接工厂
     * @return CacheManager 缓存管理器
     */
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // 默认缓存配置
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                // 默认过期时间
                .entryTtl(Duration.ofHours(DEFAULT_TTL_HOURS))
                // 禁止缓存空值(设为 true 可防止缓存穿透)
                .disableCachingNullValues()
                // Key 使用 String 序列化
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(RedisSerializer.string()))
                // Value 使用 JSON 序列化
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // 针对不同缓存名称配置不同的过期时间
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();

        // 示例:用户信息缓存 30 分钟
        cacheConfigurations.put("user", defaultConfig.entryTtl(Duration.ofMinutes(30)));

        // 示例:热门数据缓存 10 分钟
        cacheConfigurations.put("hot", defaultConfig.entryTtl(Duration.ofMinutes(10)));

        // 示例:字典数据缓存 24 小时
        cacheConfigurations.put("dict", defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigurations)
                .build();
    }

    // ==================== 自定义 KeyGenerator 示例 ====================

    /**
     * 自定义缓存 Key 生成器
     * <p>
     * 使用方式:@Cacheable(cacheNames = "xxx", keyGenerator = "customKeyGenerator")
     * </p>
     *
     * @return KeyGenerator
     */
    // @Bean("customKeyGenerator")
    // public KeyGenerator customKeyGenerator() {
    //     return (target, method, params) -> {
    //         StringBuilder sb = new StringBuilder();
    //         sb.append(target.getClass().getSimpleName());
    //         sb.append(":");
    //         sb.append(method.getName());
    //         for (Object param : params) {
    //             sb.append(":").append(param.toString());
    //         }
    //         return sb.toString();
    //     };
    // }
}

CorsConfig.java

package com.example.projectname.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 跨域资源共享(CORS)配置
 * <p>
 * 解决前后端分离项目中的跨域访问问题
 * </p>
 *
 * @author your-name
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    /**
     * 允许的前端域名列表(从配置文件读取)
     * <p>
     * 配置示例:cors.allowed-origins=http://localhost:5173,https://example.com
     * </p>
     */
    @Value("${cors.allowed-origins:*}")
    private String[] allowedOrigins;

    /**
     * 预检请求缓存时间(秒)
     * <p>
     * 浏览器会缓存 OPTIONS 预检请求的结果,避免频繁发送预检请求
     * </p>
     */
    private static final long MAX_AGE_SECONDS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                // 对所有路径生效
                .addMapping("/**")

                // 允许携带 Cookie 和认证信息
                // 注意:设为 true 时,allowedOrigins 不能为 "*"
                .allowCredentials(true)

                // 允许的请求来源
                // 方式1:使用 allowedOrigins 精确匹配
                // .allowedOrigins("http://localhost:5173", "https://example.com")

                // 方式2:使用 allowedOriginPatterns 支持通配符
                .allowedOriginPatterns(allowedOrigins)

                // 允许的 HTTP 方法
                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")

                // 允许的请求头
                .allowedHeaders("*")
                // 或者精确指定:
                // .allowedHeaders("Content-Type", "Authorization", "X-Requested-With")

                // 允许前端访问的响应头
                .exposedHeaders("X-Total-Count", "Content-Disposition")

                // 预检请求缓存时间
                .maxAge(MAX_AGE_SECONDS);
    }
}

JsonConfig.java

package com.example.projectname.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * Jackson JSON 序列化配置
 * <p>
 * 主要解决以下问题:
 * 1. Long 类型精度丢失(JS 最大安全整数为 2^53-1)
 * 2. LocalDateTime 等日期时间格式化
 * </p>
 *
 * @author your-name
 */
@JsonComponent
public class JsonConfig {

    /**
     * 日期格式
     */
    private static final String DATE_FORMAT = "yyyy-MM-dd";

    /**
     * 日期时间格式
     */
    private static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";

    /**
     * 配置全局 ObjectMapper
     *
     * @param builder Jackson2ObjectMapperBuilder
     * @return ObjectMapper
     */
    @Bean
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();

        // ==================== Long 类型处理 ====================
        // JavaScript 中 Number 类型最大安全整数为 2^53-1 (9007199254740991)
        // 雪花算法生成的 ID 为 19 位,超出范围会精度丢失
        // 解决方案:将 Long 类型序列化为 String
        SimpleModule longModule = new SimpleModule();
        longModule.addSerializer(Long.class, ToStringSerializer.instance);
        longModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(longModule);

        // ==================== 日期时间处理 ====================
        JavaTimeModule javaTimeModule = new JavaTimeModule();

        // LocalDate 序列化/反序列化
        javaTimeModule.addSerializer(LocalDate.class,
                new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
        javaTimeModule.addDeserializer(LocalDate.class,
                new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));

        // LocalDateTime 序列化/反序列化
        javaTimeModule.addSerializer(LocalDateTime.class,
                new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)));
        javaTimeModule.addDeserializer(LocalDateTime.class,
                new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)));

        objectMapper.registerModule(javaTimeModule);

        // 禁用日期时间戳格式(使用格式化字符串)
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        return objectMapper;
    }
}

MyBatisPlusConfig.java

package com.zwnsyw.zwwwspringbootbasetemplate.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;

/**
 * MyBatis Plus 配置
 * <p>
 * 包含:
 * - 分页插件
 * - 乐观锁插件
 * - 自动填充处理器
 * </p>
 *
 * @author your-name
 */
@Configuration
@MapperScan("com.zwnsyw.zwwwspringbootbasetemplate.mapper")
@Slf4j
public class MyBatisPlusConfig {

    /**
     * 配置 MyBatis Plus 插件
     * <p>
     * 注意:插件的添加顺序会影响执行顺序
     * 建议顺序:多租户 -> 动态表名 -> 分页 -> 乐观锁 -> SQL性能规范
     * </p>
     *
     * @return MybatisPlusInterceptor
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // ==================== 分页插件 ====================
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        // 设置最大单页限制数量,-1 不受限制
        paginationInterceptor.setMaxLimit(500L);
        // 溢出总页数后是否进行处理(true:回到第一页)
        paginationInterceptor.setOverflow(false);
        interceptor.addInnerInterceptor(paginationInterceptor);

        // ==================== 乐观锁插件 ====================
        // 配合实体类 @Version 注解使用
        // 更新时自动检查版本号,防止并发更新冲突
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

        return interceptor;
    }

    /**
     * 字段自动填充处理器
     * <p>
     * 配合实体类 @TableField(fill = FieldFill.INSERT) 等注解使用
     * 自动填充创建时间、更新时间、创建人、更新人等字段
     * </p>
     *
     * @return MetaObjectHandler
     */
    @Bean
    public MetaObjectHandler metaObjectHandler() {
        return new MetaObjectHandler() {

            /**
             * 插入时自动填充
             */
            @Override
            public void insertFill(MetaObject metaObject) {
                log.debug("开始插入填充...");

                // 创建时间
                this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
                // 更新时间
                this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());

                // 创建人(需要从上下文获取当前登录用户ID)
                // Long currentUserId = UserContext.getCurrentUserId();
                // if (currentUserId != null) {
                //     this.strictInsertFill(metaObject, "createBy", Long.class, currentUserId);
                //     this.strictInsertFill(metaObject, "updateBy", Long.class, currentUserId);
                // }

                // 逻辑删除初始值
                this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
            }

            /**
             * 更新时自动填充
             */
            @Override
            public void updateFill(MetaObject metaObject) {
                log.debug("开始更新填充...");

                // 更新时间
                this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());

                // 更新人
                // Long currentUserId = UserContext.getCurrentUserId();
                // if (currentUserId != null) {
                //     this.strictUpdateFill(metaObject, "updateBy", Long.class, currentUserId);
                // }
            }
        };
    }
}

RedisConfig.java

package com.example.projectname.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;

/**
 * Redis 配置
 * <p>
 * 配置 RedisTemplate 的序列化方式,解决:
 * 1. 默认 JDK 序列化可读性差、占用空间大的问题
 * 2. 类型信息丢失导致反序列化失败的问题
 * </p>
 *
 * @author your-name
 */
@Configuration
@Slf4j
public class RedisConfig {

    /**
     * 配置 RedisTemplate
     * <p>
     * Key 使用 String 序列化
     * Value 使用 JSON 序列化(携带类型信息)
     * </p>
     *
     * @param connectionFactory Redis 连接工厂
     * @return RedisTemplate<String, Object>
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // Key 序列化:使用 String
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());

        // Value 序列化:使用 JSON
        GenericJackson2JsonRedisSerializer valueSerializer = createJsonSerializer();
        template.setValueSerializer(valueSerializer);
        template.setHashValueSerializer(valueSerializer);
        template.setDefaultSerializer(valueSerializer);

        // 开启事务支持(可选,需要配合 @Transactional 使用)
        // template.setEnableTransactionSupport(true);

        template.afterPropertiesSet();
        log.info("RedisTemplate 配置完成,使用 JSON 序列化");

        return template;
    }

    /**
     * 配置 StringRedisTemplate
     * <p>
     * Key 和 Value 都使用 String 序列化
     * 适用于简单的字符串存储场景
     * </p>
     *
     * @param connectionFactory Redis 连接工厂
     * @return StringRedisTemplate
     */
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(connectionFactory);
        return template;
    }

    /**
     * 创建 JSON 序列化器
     * <p>
     * 配置类型白名单,只允许序列化/反序列化指定包下的类
     * 防止反序列化漏洞攻击
     * </p>
     *
     * @return GenericJackson2JsonRedisSerializer
     */
    private GenericJackson2JsonRedisSerializer createJsonSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();

        // 设置所有字段可见(包括私有字段)
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

        // 配置多态类型验证器(安全白名单)
        BasicPolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
                // ========== 允许的业务包路径(修改为你的包路径) ==========
                .allowIfSubType("com.example.projectname")

                // ========== 允许的 Java 集合类型 ==========
                .allowIfSubType("java.util.ArrayList")
                .allowIfSubType("java.util.LinkedList")
                .allowIfSubType("java.util.HashMap")
                .allowIfSubType("java.util.LinkedHashMap")
                .allowIfSubType("java.util.TreeMap")
                .allowIfSubType("java.util.HashSet")
                .allowIfSubType("java.util.LinkedHashSet")
                .allowIfSubType("java.util.TreeSet")

                // ========== 允许的基础类型 ==========
                .allowIfSubType("java.lang.String")
                .allowIfSubType("java.lang.Long")
                .allowIfSubType("java.lang.Integer")
                .allowIfSubType("java.lang.Double")
                .allowIfSubType("java.lang.Boolean")
                .allowIfSubType("java.math.BigDecimal")
                .allowIfSubType("java.math.BigInteger")

                // ========== 允许的日期时间类型 ==========
                .allowIfSubType("java.util.Date")
                .allowIfSubType("java.sql.Timestamp")
                .allowIfSubType("java.time.LocalDate")
                .allowIfSubType("java.time.LocalDateTime")
                .allowIfSubType("java.time.LocalTime")

                // ========== 允许的第三方类型 ==========
                .allowIfSubType("com.baomidou.mybatisplus.extension.plugins.pagination.Page")

                .build();

        // 启用默认类型(存储类型信息,支持多态)
        objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);

        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }
}

SessionConfig.java

package com.example.projectname.config;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

/**
 * Spring Session 配置
 * <p>
 * 使用 Redis 存储 Session,支持分布式会话管理
 * </p>
 * <p>
 * maxInactiveIntervalInSeconds:Session 过期时间(秒)
 * - 86400 = 1天
 * - 604800 = 7天
 * - 2592000 = 30天
 * </p>
 *
 * @author your-name
 */
@Configuration
@EnableRedisHttpSession(
        // Session 过期时间:30天
        maxInactiveIntervalInSeconds = 86400 * 30,
        // Redis 中 Session 的命名空间前缀
        redisNamespace = "session"
)
public class SessionConfig {

    /**
     * 配置 Session 序列化器
     * <p>
     * 使用 JSON 格式存储 Session 数据,便于查看和调试
     * </p>
     *
     * @return RedisSerializer
     */
    @Bean
    @Qualifier("springSessionDefaultRedisSerializer")
    public RedisSerializer springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

    // ==================== Session 相关说明 ====================
    //
    // 1. Session 存储的数据结构:
    //    - spring:session:sessions:{sessionId} -> Session 数据
    //    - spring:session:expirations:{时间戳} -> 过期时间索引
    //    - spring:session:sessions:expires:{sessionId} -> 过期标记
    //
    // 2. 获取 Session:
    //    HttpSession session = request.getSession();
    //    session.setAttribute("user", userInfo);
    //    Object user = session.getAttribute("user");
    //
    // 3. 使 Session 失效(登出):
    //    session.invalidate();
    //
    // 4. 前端需要:
    //    - 请求时携带 Cookie(credentials: 'include')
    //    - 或使用自定义 Header 传递 Session ID
}

WebMvcConfig.java

package com.example.projectname.config;

import com.example.projectname.interceptor.AuthInterceptor;
import com.example.projectname.interceptor.LogInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.Arrays;
import java.util.List;

/**
 * Spring MVC 配置
 * <p>
 * 配置拦截器、静态资源映射等
 * </p>
 *
 * @author your-name
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    /**
     * 认证拦截器
     */
    @Autowired(required = false)
    private AuthInterceptor authInterceptor;

    /**
     * 日志拦截器
     */
    @Autowired(required = false)
    private LogInterceptor logInterceptor;

    /**
     * 不需要拦截的路径(白名单)
     */
    private static final List<String> EXCLUDE_PATHS = Arrays.asList(
            // 登录注册相关
            "/user/login",
            "/user/register",
            "/user/captcha",

            // 静态资源
            "/static/**",
            "/favicon.ico",

            // 接口文档
            "/doc.html",
            "/swagger-resources/**",
            "/webjars/**",
            "/v2/api-docs",
            "/v3/api-docs/**",

            // 健康检查
            "/actuator/**",

            // 错误页面
            "/error"
    );

    /**
     * 配置拦截器
     *
     * @param registry 拦截器注册器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // ========== 日志拦截器(记录请求日志) ==========
        if (logInterceptor != null) {
            registry.addInterceptor(logInterceptor)
                    .addPathPatterns("/**")
                    .order(0);  // 优先级最高
        }

        // ========== 认证拦截器(权限校验) ==========
        if (authInterceptor != null) {
            registry.addInterceptor(authInterceptor)
                    .addPathPatterns("/**")           // 拦截所有路径
                    .excludePathPatterns(EXCLUDE_PATHS)  // 排除白名单
                    .order(1);
        }

        // ========== 其他拦截器示例 ==========
        // registry.addInterceptor(new RateLimitInterceptor())
        //         .addPathPatterns("/**")
        //         .order(2);
    }

    /**
     * 配置静态资源映射
     *
     * @param registry 资源处理器注册器
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // Knife4j 接口文档静态资源
        registry.addResourceHandler("doc.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");

        // 自定义静态资源路径
        // registry.addResourceHandler("/uploads/**")
        //         .addResourceLocations("file:/data/uploads/");
    }

    // ==================== 其他可配置项 ====================

    // /**
    //  * 配置视图解析器
    //  */
    // @Override
    // public void configureViewResolvers(ViewResolverRegistry registry) {
    //     registry.jsp("/WEB-INF/views/", ".jsp");
    // }

    // /**
    //  * 配置消息转换器
    //  */
    // @Override
    // public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    //     // 添加自定义消息转换器
    // }
}

Knife4jConfig.java

package com.zwnsyw.zwwwspringbootbasetemplate.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

/**
 * Knife4j / OpenAPI 3.0 接口文档配置
 * <p>
 * 访问地址:http://localhost:8080/api/doc.html
 * </p>
 * <p>
 * 基于 Knife4j 4.4.0 (OpenAPI 3.0)
 * 特点:
 * - 无需 Springfox 依赖
 * - 自动扫描所有 @RestController 接口
 * - 支持 @Tag@Operation 等 OpenAPI 3.0 注解
 * - 生产环境通过 knife4j.enable=false 禁用
 * </p>
 *
 * @author zwww
 */
@Configuration
@Profile({"dev", "test"}) // 仅在开发和测试环境启用
public class Knife4jConfig {

    /**
     * 配置 OpenAPI 3.0 文档信息
     * <p>
     * 注意:Knife4j OpenAPI 3.0 会自动扫描所有 Controller
     * 无需手动配置 Docket 或路径选择器
     * </p>
     *
     * @return OpenAPI 文档对象
     */
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                // ==================== 基本信息 ====================
                .info(new Info()
                        .title("项目接口文档")
                        .version("1.0.0")
                        .description("RESTful API 接口说明文档")
                        // 联系人信息
                        .contact(new Contact()
                                .name("zwww")
                                .url("https://zwnsyw.top")
                                .email("2446796988@qq.com"))
                        // 许可证信息
                        .license(new License()
                                .name("Apache 2.0")
                                .url("http://www.apache.org/licenses/LICENSE-2.0")))

                // ==================== JWT 安全认证配置 ====================
                .addSecurityItem(new SecurityRequirement().addList("Bearer Token"))
                .components(new io.swagger.v3.oas.models.Components()
                        .addSecuritySchemes("Bearer Token",
                                new SecurityScheme()
                                        .type(SecurityScheme.Type.HTTP)
                                        .scheme("bearer")
                                        .bearerFormat("JWT")
                                        .description("JWT Token")));
    }

    // ==================== 配置说明 ====================

    /**
     * 在 Controller 中使用注解:
     *
     * @RestController
     * @RequestMapping("/api/users")
     * @Tag(name = "用户管理", description = "用户相关接口")
     * public class UserController {
     *
     *     @GetMapping("/{id}")
     *     @Operation(summary = "获取用户", description = "根据ID获取用户详情")
     *     @Parameter(name = "id", description = "用户ID", required = true)
     *     @ApiResponse(responseCode = "200", description = "成功")
     *     public UserDTO getUser(@PathVariable Long id) {
     *         return new UserDTO();
     *     }
     *
     *     @PostMapping
     *     @Operation(summary = "创建用户")
     *     public UserDTO createUser(@RequestBody UserDTO user) {
     *         return user;
     *     }
     * }
     *
     * @Schema 用于 DTO 字段:
     *
     * public class UserDTO {
     *     @Schema(description = "用户ID", example = "1")
     *     private Long id;
     *
     *     @Schema(description = "用户名", example = "张三")
     *     private String name;
     *
     *     @Schema(description = "邮箱", example = "test@example.com")
     *     private String email;
     * }
     */
}

ThreadPoolConfig.java

package com.example.projectname.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

/**
 * 线程池配置
 * <p>
 * 配合 @Async 注解使用,实现异步方法调用
 * </p>
 * <p>
 * 使用方式:
 * 1. 在启动类或配置类上添加 @EnableAsync
 * 2. 在需要异步执行的方法上添加 @Async("asyncExecutor")
 * </p>
 *
 * @author your-name
 */
@Configuration
@EnableAsync
public class ThreadPoolConfig {

    /**
     * 核心线程数
     * <p>
     * 建议:CPU 密集型任务设为 CPU 核心数 + 1
     *      IO 密集型任务设为 CPU 核心数 * 2
     * </p>
     */
    private static final int CORE_POOL_SIZE = 4;

    /**
     * 最大线程数
     */
    private static final int MAX_POOL_SIZE = 10;

    /**
     * 队列容量
     */
    private static final int QUEUE_CAPACITY = 100;

    /**
     * 线程空闲时间(秒)
     */
    private static final int KEEP_ALIVE_SECONDS = 60;

    /**
     * 线程名称前缀
     */
    private static final String THREAD_NAME_PREFIX = "async-task-";

    /**
     * 配置异步任务线程池
     *
     * @return Executor
     */
    @Bean("asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // 核心线程数
        executor.setCorePoolSize(CORE_POOL_SIZE);
        // 最大线程数
        executor.setMaxPoolSize(MAX_POOL_SIZE);
        // 队列容量
        executor.setQueueCapacity(QUEUE_CAPACITY);
        // 线程空闲时间
        executor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
        // 线程名称前缀
        executor.setThreadNamePrefix(THREAD_NAME_PREFIX);

        // 拒绝策略
        // AbortPolicy:直接抛出异常(默认)
        // CallerRunsPolicy:由调用线程执行
        // DiscardPolicy:直接丢弃任务
        // DiscardOldestPolicy:丢弃队列中最老的任务
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        // 等待所有任务完成后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        // 等待时间
        executor.setAwaitTerminationSeconds(60);

        // 初始化
        executor.initialize();

        return executor;
    }

    /**
     * 定时任务线程池(如果使用 @Scheduled)
     *
     * @return Executor
     */
    @Bean("scheduledExecutor")
    public Executor scheduledExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("scheduled-task-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

AppProperties.java

package com.example.projectname.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Positive;

/**
 * 自定义业务配置
 * <p>
 * 集中管理业务相关的配置项,从 application.yml 读取
 * </p>
 * <p>
 * 使用方式:
 * 1. 在 application.yml 中配置:
 *    app:
 *      name: 项目名称
 *      file:
 *        max-size: 10485760
 * 2. 注入使用:
 *    @Autowired
 *    private AppProperties appProperties;
 * </p>
 *
 * @author your-name
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {

    /**
     * 应用名称
     */
    private String name = "project-name";

    /**
     * 应用版本
     */
    private String version = "1.0.0";

    /**
     * 是否开启调试模式
     */
    private boolean debug = false;

    /**
     * 文件上传配置
     */
    private FileConfig file = new FileConfig();

    /**
     * JWT 配置
     */
    private JwtConfig jwt = new JwtConfig();

    /**
     * 用户相关配置
     */
    private UserConfig user = new UserConfig();

    // ==================== 内部配置类 ====================

    /**
     * 文件上传配置
     */
    @Data
    public static class FileConfig {
        /**
         * 文件最大大小(字节)
         * 默认 10MB
         */
        @Positive(message = "文件大小必须为正数")
        private long maxSize = 10 * 1024 * 1024;

        /**
         * 允许的文件格式
         */
        private String allowedFormats = "jpg,jpeg,png,gif,webp,pdf";

        /**
         * 上传路径
         */
        private String uploadPath = "/uploads";
    }

    /**
     * JWT 配置
     */
    @Data
    public static class JwtConfig {
        /**
         * 密钥
         */
        @NotBlank(message = "JWT 密钥不能为空")
        private String secret = "your-default-secret-key-please-change-in-production";

        /**
         * 过期时间(秒)
         * 默认 7 天
         */
        @Positive(message = "过期时间必须为正数")
        private long expiration = 7 * 24 * 60 * 60;

        /**
         * Token 前缀
         */
        private String tokenPrefix = "Bearer ";

        /**
         * Header 名称
         */
        private String headerName = "Authorization";
    }

    /**
     * 用户相关配置
     */
    @Data
    public static class UserConfig {
        /**
         * 密码最大重试次数
         */
        private int maxPasswordRetry = 5;

        /**
         * 最大登录设备数
         */
        private int maxLoginDevice = 3;

        /**
         * 账户锁定时间(分钟)
         */
        private int lockMinutes = 30;
    }
}

配置类总览

配置类 作用 关键配置项
CacheConfig Spring Cache 缓存配置 缓存过期时间、序列化方式
CorsConfig 跨域配置 允许的域名、方法、请求头
JsonConfig JSON 序列化配置 Long 转 String、日期格式化
MyBatisPlusConfig ORM 配置 分页、乐观锁、自动填充
RedisConfig Redis 配置 序列化方式、类型白名单
SessionConfig 分布式会话配置 过期时间、序列化方式
WebMvcConfig MVC 配置 拦截器、静态资源映射
Knife4jConfig 接口文档配置 文档信息、扫描范围
ThreadPoolConfig 线程池配置 核心线程数、队列容量
AppProperties 业务配置 文件上传、JWT、用户配置

使用建议

  1. 按需引入:根据项目需要选择配置类
  2. 修改包路径:将 com.example.projectname 替换为你的实际包路径
  3. 敏感信息外置:密钥等敏感信息通过环境变量配置
  4. 生产环境:关闭接口文档、调整日志级别

7. 最佳实践总结

7.1 命名规范

类型 命名格式 示例
Entity XxxEntityXxx UserTeam
DTO XxxDTOXxxRequest UserRegisterDTO
VO XxxVO UserVO
Enum XxxEnum GenderEnum
Service XxxService UserService
ServiceImpl XxxServiceImpl UserServiceImpl
Controller XxxController UserController
Mapper XxxMapper UserMapper

8.2 分层职责

┌─────────────┐
│  Controller │ → 参数接收、非空校验、触发DTO校验、调用Service
├─────────────┤
│   Service   │ → 业务逻辑、业务校验、事务管理、调用Mapper
├─────────────┤
│   Mapper    │ → 数据库操作(CRUD)
├─────────────┤
│   Entity    │ → 数据库表映射
├─────────────┤
│    DTO      │ → 请求数据封装、字段校验
├─────────────┤
│     VO      │ → 响应数据封装、脱敏处理
└─────────────┘

8.3 校验注解速查

注解 作用 示例
@NotNull 不能为 null @NotNull Long id
@NotBlank 字符串非空且非空白 @NotBlank String name
@NotEmpty 集合/数组非空 @NotEmpty List<String> tags
@Size 长度/大小范围 @Size(min=1, max=10)
@Min / @Max 数值范围 @Min(0) @Max(100)
@Email 邮箱格式 @Email String email
@Pattern 正则匹配 @Pattern(regexp="...")
@AssertTrue 自定义校验方法 @AssertTrue isValid()
@Valid 触发嵌套校验 @Valid AddressDTO address
@Validated 分组校验 @Validated(UpdateGroup.class)

8.4 枚举设计要点

  1. @EnumValue:标记存储到数据库的字段
  2. @JsonValue:标记序列化到 JSON 的字段
  3. @JsonCreator:标记反序列化的工厂方法
  4. fromCode():提供根据 code 获取枚举的静态方法
  5. isValid():提供验证 code 是否有效的方法

8.5 MybatisX 代码生成使用建议

  1. 生成到临时目录,手动迁移到项目中
  2. Entity 生成后需添加枚举字段映射
  3. Mapper 和 Service 可直接使用生成结果
  4. 按需修改生成的代码,添加业务逻辑