- CourseCollectionPackage、TenantCourse、ClassTeacher 改为独立实体类 - 不再继承 BaseEntity,移除 deleted 字段,使用物理删除 - 创建 V41 迁移脚本移除联合唯一索引和 deleted 字段 - 更新中间表设计规范到 CLAUDE.md 和统一开发规范.md refactor(entity): 中间表优化 - 移除联合唯一索引和 deleted 字段 - CourseCollectionPackage、TenantCourse、ClassTeacher 改为独立实体类 - 不再继承 BaseEntity,移除 deleted 字段,使用物理删除 - 创建 V41 迁移脚本移除联合唯一索引和 deleted 字段 - 更新中间表设计规范到 CLAUDE.md 和统一开发规范.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
115 KiB
Spring Boot + Vue 3 企业级应用开发规范
制定背景
在 AI 辅助编程时代,每次项目开始都需要重新定义规范,造成大量重复工作。本文档旨在提供一套可复用的标准化开发规范,适用于所有采用 Spring Boot + Vue 3 技术栈的企业级应用开发。
核心原则
- OpenAPI 规范驱动 - 前后端通过接口规范对齐,零沟通成本
- 类型安全优先 - TypeScript 强制类型校验,早发现早修复
- 约定大于配置 - 统一代码风格和目录结构,降低认知负担
- 自动化优先 - 能自动化的绝不手动(代码生成、部署、测试)
- 三层架构分离 - Controller、Service、Mapper 职责清晰
一、技术栈规范
后端技术栈
| 组件 | 技术选型 | 版本 | 说明 |
|---|---|---|---|
| 框架 | Spring Boot | 3.2+ | 基于 Java 17 |
| 持久层 | MyBatis-Plus | 3.5+ | 简化 CRUD |
| 数据库连接池 | Alibaba Druid | 1.2+ | 数据库连接池 + 监控 |
| 安全 | Spring Security + JWT | - | 无状态认证 + RBAC |
| API 文档 | Knife4j (SpringDoc) | 4.x | OpenAPI 3.0 |
| 数据库 | MySQL | 8.0+ | 关系型数据库 |
| 迁移 | Flyway | - | 版本化数据库变更 |
| 校验 | Hibernate Validator | - | JSR-303 参数校验 |
| 缓存 | Redis + Spring Data Redis | - | 缓存、会话存储 |
| 日志 | Logback | - | 结构化日志 |
| JSON | FastJSON | 2.x | JSON 序列化 |
| 工具类 | Hutool | 5.x | 常用工具集合 |
| 文件存储 | 阿里云 OSS | - | 对象存储 |
前端技术栈
| 组件 | 技术选型 | 版本 | 说明 |
|---|---|---|---|
| 框架 | Vue 3 | 3.4+ | Composition API |
| 语言 | TypeScript | 5.x | 严格模式 |
| UI 库 | Ant Design Vue | 4.x | 企业级组件库 |
| 构建 | Vite | 5.x | 快速开发服务器 |
| 状态 | Pinia | 2.x | 轻量状态管理 |
| 请求 | Axios | 1.x | HTTP 客户端 |
| API 生成 | Orval | 7.x | OpenAPI → TypeScript |
| 路由 | Vue Router | 4.x | SPA 路由 |
二、项目结构规范
后端目录结构
backend/
├── src/main/java/com/company/project/
│ ├── ProjectApplication.java # 启动类
│ ├── common/ # 公共模块
│ │ ├── config/ # 配置类
│ │ │ ├── MybatisPlusConfig.java # MP 配置
│ │ │ ├── RedisConfig.java # Redis 配置
│ │ │ ├── SecurityConfig.java # 安全配置
│ │ │ ├── OssConfig.java # OSS 配置
│ │ │ └── SwaggerConfig.java # Swagger 配置
│ │ ├── security/ # 安全相关
│ │ │ ├── JwtAuthenticationFilter.java
│ │ │ ├── UserPrincipal.java
│ │ │ └── RBACAspect.java
│ │ ├── response/ # 统一响应
│ │ │ ├── Result.java
│ │ │ ├── PageResult.java
│ │ │ └── ErrorCode.java
│ │ ├── annotation/ # 自定义注解
│ │ │ ├── RequireRole.java
│ │ │ └── Log.java
│ │ ├── aspect/ # AOP 切面
│ │ │ ├── RBACAspect.java
│ │ │ └── LogAspect.java
│ │ ├── interceptor/ # 拦截器
│ │ └── util/ # 工具类
│ │ ├── RedisUtils.java
│ │ ├── OssUtils.java
│ │ └── JsonUtils.java
│ ├── controller/ # 控制器层(入口)
│ │ ├── BaseController.java # 基础控制器
│ │ ├── AuthController.java # 认证接口
│ │ └── xxx/ # 按业务模块
│ ├── service/ # 服务层(业务逻辑)
│ │ ├── XxxService.java
│ │ └── impl/
│ │ └── XxxServiceImpl.java
│ ├── mapper/ # 数据访问层(数据库操作)
│ │ ├── XxxMapper.java
│ │ └── xml/ # MyBatis XML
│ │ └── XxxMapper.xml
│ ├── entity/ # 实体类
│ │ ├── BaseEntity.java # 基础实体
│ │ └── xxx/ # 业务实体
│ ├── dto/ # 数据传输对象
│ │ ├── request/ # 请求 DTO
│ │ └── response/ # 响应 VO
│ └── enums/ # 枚举类
│ ├── CommonStatusEnum.java
│ └── UserStatusEnum.java
│
├── src/main/resources/
│ ├── application.yml # 主配置文件
│ ├── application-dev.yml # 开发环境
│ ├── application-prod.yml # 生产环境
│ ├── db/migration/ # Flyway 迁移脚本
│ ├── logback-spring.xml # 日志配置
│ └── mapper/ # MyBatis XML
│
├── pom.xml
└── Dockerfile
前端目录结构
frontend/
├── src/
│ ├── main.ts # 入口文件
│ ├── App.vue # 根组件
│ ├── api/ # API 接口
│ │ ├── generated/ # 自动生成
│ │ └── index.ts # Axios 实例
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ ├── composables/ # 组合式函数
│ ├── layouts/ # 布局组件
│ ├── router/ # 路由配置
│ │ ├── index.ts
│ │ └── routes.ts
│ ├── stores/ # Pinia 状态管理
│ ├── types/ # 类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ │ ├── login/ # 登录页
│ │ ├── dashboard/ # 仪表盘
│ │ └── system/ # 系统管理
│ │ ├── user/
│ │ ├── role/
│ │ └── menu/
│ └── constants/ # 常量定义
│
├── api-spec.yml # OpenAPI 规范
├── orval.config.ts # API 生成配置
├── index.html
├── package.json
└── vite.config.ts
三、三层架构规范
6. Service 层和 Mapper 层使用实体类规范(重要)
核心原则:Service 层和 Mapper 层必须使用实体类(Entity)接收和返回数据,严禁在 Service 层和 Mapper 层之间使用 DTO/VO 转换。
为什么 Service/Mapper 层要使用实体类?
| 对比项 | ❌ 错误做法(DTO/VO 转换) | ✅ 正确做法(使用实体类) |
|---|---|---|
| 类型转换 | Service↔Mapper 需要多次转换 | 零转换,直接使用 |
| 代码量 | 大量重复的 convert 方法 | 无需转换代码 |
| 维护成本 | 字段变更需要改多处 | 只需改实体类 |
| 类型安全 | 手动转换容易遗漏字段 | 编译期检查 |
| 性能 | 多次对象拷贝开销 | 零拷贝开销 |
| MyBatis-Plus | 无法使用 MP 内置方法 | 完美支持 |
各层职责与数据流转
┌─────────────────────────────────────────────────────────┐
│ Controller 层(入口) │
│ • 接收 HTTP 请求参数(DTO/Request) │
│ • 参数校验(@Valid) │
│ • 调用 Service 层(传入 DTO) │
│ • 接收 Service 返回的 Entity 或 VO │
│ • 转换为响应 VO(如需要) │
│ • 返回 Result<VO> │
└─────────────────────────────────────────────────────────┘
↓ 使用 DTO/Entity
┌─────────────────────────────────────────────────────────┐
│ Service 层(业务) │
│ • 接收 Controller 传入的 DTO 或参数 │
│ • 转换为 Entity(用于创建/更新场景) │
│ • 调用 Mapper 层(传入/返回 Entity) │
│ • 处理业务逻辑、事务控制 │
│ • 返回 Entity 或 Entity 列表(给 Controller 转换) │
└─────────────────────────────────────────────────────────┘
↓ 只使用 Entity
┌─────────────────────────────────────────────────────────┐
│ Mapper 层(数据访问) │
│ • 继承 BaseMapper<Entity> │
│ • 接收/返回 Entity 或 Entity 列表 │
│ • 复杂查询返回 Entity(通过 ResultMap 映射) │
│ • 禁止返回 Map/JSONObject/自定义 DTO │
└─────────────────────────────────────────────────────────┘
错误示例:Service 层使用 DTO 接收 Mapper 结果
// ❌ 错误示范:不要在 Service 层和 Mapper 层之间使用 DTO
@Service
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
public UserInfoDTO getUserById(Long userId) {
// 错误:Mapper 返回 DTO,失去 MP 原生支持
UserInfoDTO dto = userMapper.selectUserDTO(userId);
return dto;
}
// 错误:需要在 Service 层手动转换
private UserInfoDTO convertToDTO(User user) {
UserInfoDTO dto = new UserInfoDTO();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
// ... 大量重复代码
return dto;
}
}
// 错误:Mapper 返回 DTO 而非 Entity
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 错误:返回自定义 DTO,无法使用 MP 的 ResultMap
@Select("SELECT * FROM t_user WHERE id = #{id}")
UserInfoDTO selectUserDTO(Long id);
}
正确示例:Service 层和 Mapper 层使用实体类
// ✅ 正确示范:Service 层和 Mapper 层使用 Entity
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public User createUser(UserCreateRequest request) {
log.info("创建用户,用户名:{}", request.getUsername());
// 1. 检查是否存在
boolean exists = userMapper.existsByUsername(request.getUsername());
if (exists) {
throw new BusinessException("用户名已存在");
}
// 2. DTO 转 Entity(仅在创建/更新时转换一次)
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.status(CommonStatusEnum.ACTIVE.getCode())
.build();
// 3. 直接传入 Entity,MP 自动填充 ID 和审计字段
userMapper.insert(user);
log.info("用户创建成功,ID: {}", user.getId());
return user; // 直接返回 Entity
}
@Override
public User getUserById(Long userId) {
log.debug("查询用户,ID: {}", userId);
// 直接使用 MP 的 selectById,返回 Entity
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
return user; // 直接返回 Entity
}
@Override
public List<User> listUsersByCondition(UserQueryRequest request) {
// 使用 LambdaQueryWrapper 构建条件,返回 Entity 列表
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, CommonStatusEnum.ACTIVE.getCode());
if (StringUtils.hasText(request.getKeyword())) {
wrapper.and(w -> w
.like(User::getUsername, request.getKeyword())
.or()
.like(User::getEmail, request.getKeyword())
);
}
return userMapper.selectList(wrapper); // 返回 List<User>
}
}
// ✅ 正确示范:Mapper 层只操作 Entity
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名检查用户是否存在
* 返回基础类型,不返回 Entity
*/
@Select("SELECT COUNT(*) FROM t_user WHERE username = #{username}")
boolean existsByUsername(@Param("username") String username);
/**
* 分页查询用户(带关键词搜索)
* 返回 Entity 分页对象,不是 DTO 分页
*/
Page<User> selectPageByKeyword(Page<User> page,
@Param("keyword") String keyword);
/**
* 根据角色查询用户列表
* 返回 Entity 列表,不是 DTO 列表
*/
List<User> selectByRoleId(@Param("roleId") Long roleId);
}
<!-- ✅ 正确示范:MyBatis XML 映射到 Entity -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.company.project.mapper.UserMapper">
<!-- 结果映射直接指向 Entity -->
<resultMap id="BaseResultMap" type="com.company.project.entity.User">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="email" property="email"/>
<result column="status" property="status"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<!-- 复杂查询也返回 Entity -->
<select id="selectByRoleId" resultMap="BaseResultMap">
SELECT u.* FROM t_user u
INNER JOIN t_user_role ur ON u.id = ur.user_id
WHERE ur.role_id = #{roleId}
AND u.deleted = 0
</select>
<!-- 分页查询也返回 Entity -->
<select id="selectPageByKeyword" resultMap="BaseResultMap">
SELECT * FROM t_user
<where>
deleted = 0
<if test="keyword != null and keyword != ''">
AND (username LIKE CONCAT('%', #{keyword}, '%')
OR email LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY create_time DESC
</select>
</mapper>
Controller 层的转换时机
规范:转换只在 Controller 层发生一次,用于将 Entity 转为响应 VO
// ✅ 正确示范:Controller 负责 Entity ↔ VO 转换
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 创建用户
* Service 返回 Entity → Controller 转为 VO
*/
@PostMapping
public Result<UserInfoVO> createUser(
@Validated @RequestBody UserCreateRequest request) {
// Service 返回 Entity
User user = userService.createUser(request);
// Controller 负责转换为 VO
UserInfoVO vo = convertToVO(user);
return Result.success(vo);
}
/**
* 获取用户详情
* Service 返回 Entity → Controller 转为 VO
*/
@GetMapping("/{id}")
public Result<UserInfoVO> getUser(@PathVariable Long id) {
// Service 返回 Entity
User user = userService.getUserById(id);
// Controller 负责转换为 VO
UserInfoVO vo = convertToVO(user);
return Result.success(vo);
}
/**
* 用户列表
* Service 返回 Entity 列表 → Controller 转为 VO 列表
*/
@GetMapping
public Result<PageResult<UserInfoVO>> listUsers(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String keyword) {
// Service 返回 Entity 分页
Page<User> entityPage = userService.pageUsers(page, size, keyword);
// Controller 转换为 VO 分页
List<UserInfoVO> voList = entityPage.getRecords()
.stream()
.map(this::convertToVO)
.collect(Collectors.toList());
PageResult<UserInfoVO> result = new PageResult<>();
result.setItems(voList);
result.setTotal(entityPage.getTotal());
result.setPage(entityPage.getCurrent());
result.setPageSize(entityPage.getSize());
return Result.success(result);
}
private UserInfoVO convertToVO(User user) {
return UserInfoVO.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.phone(user.getPhone())
.avatar(user.getAvatar())
.status(user.getStatus())
.createTime(user.getCreateTime())
.build();
}
}
总结:各层数据使用规范
| 层级 | 接收数据 | 返回数据 | 说明 |
|---|---|---|---|
| Controller | DTO/Request | VO/Response | 负责 HTTP ↔ 业务层转换 |
| Service | DTO/Entity | Entity | 业务逻辑,不调用 convert |
| Mapper | Entity/条件 | Entity | 数据访问,只操作 Entity |
黄金法则:
- Service ↔ Mapper 之间:只用 Entity
- Controller ↔ Service 之间:可以 DTO/Entity 混用
- Controller ↔ HTTP 之间:DTO 进,VO 出
7. Service 层继承与查询方式规范
7.1 Service 层必须继承 IService
规范:所有与 ORM 实体类相关的 Service 层接口都必须继承 MyBatis-Plus 的 IService<T> 接口
// ✅ 正确示范:Service 接口继承 IService<User>
public interface UserService extends IService<User> {
/**
* 创建用户
*/
User createUser(UserCreateRequest request);
/**
* 根据 ID 查询用户
*/
User getUserById(Long userId);
/**
* 分页查询用户
*/
Page<User> pageUsers(Integer page, Integer size, String keyword);
}
// ✅ 正确示范:Service 实现类继承 ServiceImpl<UserMapper, User> 并实现 UserService
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
// 继承 ServiceImpl 后,自动拥有以下方法:
// - save() / saveBatch() 保存
// - remove() / removeById() 删除
// - update() / updateById() 更新
// - getById() / getOne() 查询单个
// - list() / listByIds() 查询列表
// - page() / pageByCondition() 分页查询
// - count() / countByCondition() 统计数量
@Override
@Transactional(rollbackFor = Exception.class)
public User createUser(UserCreateRequest request) {
// 构建 Entity
User user = convertToEntity(request);
// 直接使用继承的 save 方法
this.save(user);
return user;
}
@Override
public User getUserById(Long userId) {
// 直接使用继承的 getById 方法
User user = this.getById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
return user;
}
private User convertToEntity(UserCreateRequest request) {
return User.builder()
.username(request.getUsername())
.email(request.getEmail())
.build();
}
}
继承 IService 的好处:
| 好处 | 说明 |
|---|---|
| 减少样板代码 | 基础 CRUD 方法无需手动编写 |
| 统一接口规范 | 所有 Service 层接口一致 |
| 类型安全 | 泛型确保类型正确 |
| 链式调用 | 支持 lambdaQuery() 等链式操作 |
| 批量操作 | 内置 saveBatch(), removeBatch() 等方法 |
7.2 查询列表接口必须分页处理
规范:所有返回列表的查询接口,默认都应该进行分页处理,避免数据量过大导致性能问题
// ❌ 错误示范:不分页返回所有数据
@GetMapping("/list")
public Result<List<User>> listUsers() {
List<User> users = userService.list(); // 返回所有用户,可能成千上万条
return Result.success(users);
}
// ✅ 正确示范:分页返回数据
@GetMapping("/page")
public Result<PageResult<UserInfoVO>> pageUsers(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String keyword) {
// 使用 MP 的分页方法
Page<User> userPage = userService.page(
new Page<>(page, size),
Wrappers.<User>lambdaQuery()
.like(StringUtils.hasText(keyword), User::getUsername, keyword)
);
// 转换为 VO 分页结果
PageResult<UserInfoVO> result = new PageResult<>();
result.setItems(userPage.getRecords().stream()
.map(this::convertToVO)
.collect(Collectors.toList()));
result.setTotal(userPage.getTotal());
result.setPage(userPage.getCurrent());
result.setPageSize(userPage.getSize());
return Result.success(result);
}
分页参数规范:
| 参数名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| page | Integer | 1 | 当前页码,从 1 开始 |
| size | Integer | 10 | 每页数量,建议 10-100 |
| total | Long | - | 总记录数(响应中返回) |
不分页的例外场景:
- 下拉选项数据(如角色列表、部门列表)
- 数据量固定且很小(< 100 条)
- 导出接口(全量导出)
7.3 简单查询使用 QueryWrapper + 通用函数
规范:简单查询或单表查询,优先使用 QueryWrapper 和 MyBatis-Plus 提供的通用方法
场景一:单表条件查询
// ✅ 正确示范:Service 层使用 QueryWrapper + BaseMapper 通用方法
@Override
public List<User> listActiveUsers() {
// 使用 lambda 表达式构建类型安全的查询条件
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, CommonStatusEnum.ACTIVE.getCode())
.orderByDesc(User::getCreateTime);
// 使用继承的 list 方法
return this.list(wrapper);
}
@Override
public User getUserByUsername(String username) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
// 使用继承的 getOne 方法
return this.getOne(wrapper);
}
场景二:单表分页查询
// ✅ 正确示范:Controller 层直接使用 IService 的 page 方法
@GetMapping("/page")
public Result<PageResult<UserInfoVO>> pageUsers(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Integer status) {
// Controller 层直接使用 IService.page() 方法
Page<User> userPage = this.page(
new Page<>(page, size),
Wrappers.<User>lambdaQuery()
.like(StringUtils.hasText(keyword), User::getUsername, keyword)
.eq(status != null, User::getStatus, status)
.orderByDesc(User::getCreateTime)
);
return Result.success(buildPageResult(userPage));
}
场景三:条件统计
// ✅ 正确示范:使用 count 方法统计数量
@Override
public Long countActiveUsers() {
return this.count(Wrappers.<User>lambdaQuery()
.eq(User::getStatus, CommonStatusEnum.ACTIVE.getCode()));
}
QueryWrapper 使用规范:
// 基础用法
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
// 等值查询
wrapper.eq(User::getStatus, 1);
// 模糊查询
wrapper.like(User::getUsername, "张");
wrapper.likeLeft(User::getUsername, "三"); // %三
wrapper.likeRight(User::getUsername, "张"); // 张%
// 范围查询
wrapper.between(User::getAge, 18, 30);
wrapper.in(User::getStatus, Arrays.asList(1, 2));
// 比较查询
wrapper.gt(User::getAge, 18); // >
wrapper.ge(User::getAge, 18); // >=
wrapper.lt(User::getAge, 60); // <
wrapper.le(User::getAge, 60); // <=
// 空值判断
wrapper.isNull(User::getDeletedAt);
wrapper.isNotNull(User::getEmail);
// 排序
wrapper.orderByDesc(User::getCreateTime);
wrapper.orderByAsc(User::getSortOrder);
// 条件查询(第一个参数为 true 时才添加条件)
wrapper.eq(StringUtils.hasText(keyword), User::getUsername, keyword);
7.4 复杂查询使用自定义 SQL
规范:涉及多表联查、复杂条件、聚合统计等场景,使用自定义 SQL 查询
场景一:多表联查
// Mapper 层定义自定义查询方法
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 查询用户及其角色信息(多表联查)
*/
Page<UserWithRoleVO> selectPageWithRole(Page<UserWithRoleVO> page,
@Param("keyword") String keyword);
}
<!-- XML 中编写复杂 SQL -->
<mapper namespace="com.company.project.mapper.UserMapper">
<select id="selectPageWithRole" resultType="com.company.project.dto.vo.UserWithRoleVO">
SELECT
u.id,
u.username,
u.email,
u.status,
u.create_time,
r.role_name,
r.role_code
FROM t_user u
LEFT JOIN t_user_role ur ON u.id = ur.user_id
LEFT JOIN t_auth_role r ON ur.role_id = r.id
<where>
u.deleted = 0
<if test="keyword != null and keyword != ''">
AND (u.username LIKE CONCAT('%', #{keyword}, '%')
OR u.email LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY u.create_time DESC
</select>
</mapper>
// Service 层调用自定义查询
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
public Page<UserWithRoleVO> pageUsersWithRole(Integer page, Integer size, String keyword) {
// 复杂查询使用自定义 SQL
return baseMapper.selectPageWithRole(new Page<>(page, size), keyword);
}
}
场景二:聚合统计
// Mapper 层
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 统计每月订单金额
*/
List<MonthlyStatsVO> selectMonthlyStats(@Param("year") Integer year);
}
<!-- XML 聚合查询 -->
<select id="selectMonthlyStats" resultType="com.company.project.dto.vo.MonthlyStatsVO">
SELECT
DATE_FORMAT(create_time, '%Y-%m') AS month,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount
FROM t_order
WHERE YEAR(create_time) = #{year}
AND deleted = 0
GROUP BY DATE_FORMAT(create_time, '%Y-%m')
ORDER BY month
</select>
场景三:复杂条件查询
// 当查询条件过于复杂,使用 QueryWrapper 难以表达时
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 高级搜索(多条件组合)
*/
Page<User> selectPageByAdvancedCondition(Page<User> page,
@Param("condition") UserAdvancedCondition condition);
}
<select id="selectPageByAdvancedCondition" resultMap="BaseResultMap">
SELECT * FROM t_user
<where>
deleted = 0
<if test="condition.username != null">
AND username LIKE CONCAT('%', #{condition.username}, '%')
</if>
<if test="condition.status != null">
AND status = #{condition.status}
</if>
<if test="condition.createTimeStart != null">
AND create_time >= #{condition.createTimeStart}
</if>
<if test="condition.createTimeEnd != null">
AND create_time <= #{condition.createTimeEnd}
</if>
<if test="condition.hasRole != null and condition.hasRole">
AND id IN (SELECT user_id FROM t_user_role WHERE role_id = #{condition.roleId})
</if>
</where>
ORDER BY create_time DESC
</select>
7.5 查询方式选择决策树
开始查询
│
▼
┌────────────────┐
│ 是否单表查询? │
└────────────────┘
│ │
是 否
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 是否需要分页? │ │ 使用自定义 SQL │
└──────────────────┘ │ (XML 或@Select) │
│ │ └──────────────────┘
是 否
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ page(Page, │ │ list(QueryWrapper) │
│ QueryWrapper)│ │ getOne(QueryWrapper)│
└──────────────┘ │ count(QueryWrapper) │
└──────────────────┘
查询方式选择指南:
| 场景 | 推荐方式 | 示例方法 |
|---|---|---|
| 单表按 ID 查询 | 通用方法 | getById(id) |
| 单表条件查询 | QueryWrapper | list(wrapper) / getOne(wrapper) |
| 单表分页查询 | QueryWrapper + Page | page(new Page<>(p, s), wrapper) |
| 单表统计 | QueryWrapper | count(wrapper) |
| 两表联查 | 自定义 SQL | mapper.selectWithXxx() |
| 三表及以上 | 自定义 SQL | mapper.selectWithXxxAndYyy() |
| 聚合统计 | 自定义 SQL | mapper.selectStats() |
| 子查询 | 自定义 SQL | mapper.selectBySubQuery() |
| 动态复杂条件 | 自定义 SQL(XML) | mapper.selectByCondition() |
架构设计原则
┌─────────────────────────────────────────────────────┐
│ Controller 层 │
│ • 接收请求参数(DTO) │
│ • 参数校验(@Valid) │
│ • 调用 Service 层 │
│ • 返回统一响应(Result) │
│ • 不包含业务逻辑 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Service 层 │
│ • 处理业务逻辑 │
│ • 事务控制(@Transactional) │
│ • 调用 Mapper 层 │
│ • 调用其他 Service │
│ • 可以包含多个 Mapper 操作 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Mapper 层 │
│ • 数据库 CRUD 操作 │
│ • 继承 BaseMapper<T> │
│ • 复杂查询使用 XML 或@Select │
│ • 不包含业务逻辑 │
└─────────────────────────────────────────────────────┘
Controller 层规范
package com.company.project.controller;
import com.company.project.common.annotation.RequireRole;
import com.company.project.common.response.Result;
import com.company.project.dto.request.UserCreateRequest;
import com.company.project.dto.response.UserInfoResponse;
import com.company.project.service.UserService;
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.*;
/**
* 用户管理控制器
*
* 处理用户相关的 HTTP 请求,包括用户创建、查询、更新、删除等操作。
* 所有接口需要登录认证,部分接口需要管理员权限。
*
* @author developer
* @since 2024-01-01
*/
@Tag(name = "用户管理", description = "用户信息的增删改查接口")
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController extends BaseController {
private final UserService userService;
/**
* 创建新用户
*
* <p>仅管理员可调用,创建后自动发送激活邮件。</p>
*
* @param request 用户创建请求,包含用户名、邮箱、角色等信息
* @return 创建成功的用户信息
*/
@Operation(summary = "创建用户", description = "创建新用户并返回用户信息,需要管理员权限")
@RequireRole("ADMIN")
@PostMapping
public Result<UserInfoResponse> createUser(
@Validated @RequestBody UserCreateRequest request) {
UserInfoResponse response = userService.createUser(request);
return Result.success(response);
}
/**
* 根据 ID 获取用户详情
*
* @param userId 用户 ID,路径变量
* @return 用户详细信息
*/
@Operation(summary = "获取用户详情", description = "根据用户 ID 获取详细信息")
@GetMapping("/{userId}")
public Result<UserInfoResponse> getUser(
@Parameter(description = "用户 ID", required = true)
@PathVariable Long userId) {
UserInfoResponse response = userService.getUserById(userId);
return Result.success(response);
}
}
Service 层规范
package com.company.project.service;
import com.company.project.dto.request.UserCreateRequest;
import com.company.project.dto.response.UserInfoResponse;
/**
* 用户服务接口
*
* <p>定义用户相关的业务操作方法。</p>
*
* @author developer
* @since 2024-01-01
*/
public interface UserService {
/**
* 创建用户
*
* @param request 创建请求
* @return 创建后的用户信息
*/
UserInfoResponse createUser(UserCreateRequest request);
/**
* 根据 ID 查询用户
*
* @param userId 用户 ID
* @return 用户信息
*/
UserInfoResponse getUserById(Long userId);
/**
* 分页查询用户
*
* @param page 页码
* @param size 每页数量
* @param keyword 关键词
* @return 分页结果
*/
PageResult<UserInfoResponse> getUserPage(Integer page, Integer size, String keyword);
}
package com.company.project.service.impl;
import com.company.project.common.exception.BusinessException;
import com.company.project.dto.request.UserCreateRequest;
import com.company.project.dto.response.UserInfoResponse;
import com.company.project.entity.User;
import com.company.project.mapper.UserMapper;
import com.company.project.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 用户服务实现类
*
* <p>实现用户相关的业务逻辑,包括用户创建、查询、更新、删除等操作。</p>
* <p>所有写操作都需要事务控制。</p>
*
* @author developer
* @since 2024-01-01
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public UserInfoResponse createUser(UserCreateRequest request) {
log.info("创建用户,用户名:{}", request.getUsername());
// 检查用户名是否存在
boolean exists = userMapper.existsByUsername(request.getUsername());
if (exists) {
throw new BusinessException("用户名已存在");
}
// 构建用户实体
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.status(CommonStatusEnum.ACTIVE.getCode())
.build();
// 插入数据库
userMapper.insert(user);
log.info("用户创建成功,ID: {}", user.getId());
return convertToResponse(user);
}
@Override
public UserInfoResponse getUserById(Long userId) {
log.debug("查询用户,ID: {}", userId);
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
return convertToResponse(user);
}
/**
* 实体转换为响应对象
*/
private UserInfoResponse convertToResponse(User user) {
return UserInfoResponse.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.status(user.getStatus())
.createTime(user.getCreateTime())
.build();
}
}
Mapper 层规范
package com.company.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.company.project.entity.User;
/**
* 用户数据访问接口
*
* <p>继承 MyBatis-Plus 的 BaseMapper,获得基础 CRUD 能力。</p>
* <p>复杂查询通过 XML 或注解方式实现。</p>
*
* @author developer
* @since 2024-01-01
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名检查用户是否存在
*
* @param username 用户名
* @return 是否存在
*/
@Select("SELECT COUNT(*) FROM t_user WHERE username = #{username}")
boolean existsByUsername(@Param("username") String username);
/**
* 分页查询用户(带关键词搜索)
*
* @param page 分页对象
* @param keyword 关键词(匹配用户名或邮箱)
* @return 分页结果
*/
Page<User> selectPageByKeyword(Page<User> page, @Param("keyword") String keyword);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.company.project.mapper.UserMapper">
<!-- 结果映射 -->
<resultMap id="BaseResultMap" type="com.company.project.entity.User">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="email" property="email"/>
<result column="status" property="status"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<!-- 分页查询(带关键词) -->
<select id="selectPageByKeyword" resultMap="BaseResultMap">
SELECT * FROM t_user
<where>
deleted = 0
<if test="keyword != null and keyword != ''">
AND (username LIKE CONCAT('%', #{keyword}, '%')
OR email LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY create_time DESC
</select>
</mapper>
四、ORM 实体类规范
1. 表名命名规范
所有数据库表必须添加业务模块前缀,格式为 t_{模块}_{表名}:
| 模块分类 | 前缀 | 示例 |
|---|---|---|
| 用户模块 | t_user_ | t_user, t_user_role, t_user_menu |
| 系统模块 | t_sys_ | t_sys_config, t_sys_log, t_sys_dict |
| 业务模块 | t_biz_ | t_biz_order, t_biz_product |
| 权限模块 | t_auth_ | t_auth_role, t_auth_menu, t_auth_perm |
2. 表名与实体类名对应规范
表名与实体类名必须保持一致,仅允许下划线与驼峰的区别:
| 表名(下划线命名) | 实体类名(驼峰命名) | 正确/错误 |
|---|---|---|
t_auth_role |
AuthRole |
✅ 正确 |
t_auth_menu |
AuthMenu |
✅ 正确 |
t_user_role |
UserRole |
✅ 正确 |
t_auth_role |
Role |
❌ 错误(缺少模块前缀) |
t_auth_menu |
Menu |
❌ 错误(缺少模块前缀) |
t_biz_order |
BizOrder |
✅ 正确 |
t_biz_order |
OrderInfo |
❌ 错误(名称不一致) |
规范说明:
- 表名去掉前缀
t_后,剩余部分与实体类名对应 - 表名下划线转实体类大驼峰(如
auth_role→AuthRole) - 禁止表名和实体类名不一致的情况(如
t_auth_role对应RoleEntity)
3. 实体类分类规范
实体类根据业务模块添加前缀,保持与服务层一致:
entity/
├── BaseEntity.java # 基础实体类(审计字段)
├── user/
│ ├── User.java # 用户实体 → t_user_user
│ └── UserRole.java # 用户角色关联实体 → t_user_user_role
├── sys/
│ ├── SysConfig.java # 系统配置实体 → t_sys_sys_config
│ └── SysLog.java # 系统日志实体 → t_sys_sys_log
└── auth/
├── AuthRole.java # 角色实体 → t_auth_auth_role
└── AuthMenu.java # 菜单实体 → t_auth_auth_menu
注意: 为避免表名冗长,模块内实体可省略模块前缀,示例:
entity/
├── user/
│ ├── User.java # 用户实体 → t_user (而非 t_user_user)
│ └── Role.java # 角色实体 → t_user_role
├── sys/
│ ├── Config.java # 系统配置实体 → t_sys_config
│ └── Log.java # 系统日志实体 → t_sys_log
└── auth/
├── Role.java # 角色实体 → t_auth_role
└── Menu.java # 菜单实体 → t_auth_menu
推荐方式: 使用简短的表名(省略模块内重复前缀),实体类名保持简洁:
| 推荐表名 | 推荐实体类名 | 不推荐的表名 | 不推荐的实体类名 |
|---|---|---|---|
t_user |
User |
t_user_user |
User |
t_auth_role |
AuthRole |
t_auth_auth_role |
AuthRole |
t_sys_config |
SysConfig |
t_sys_sys_config |
SysConfig |
4. 不使用外键约束规范
规范说明:
- 数据库表之间不使用 FOREIGN KEY 外键约束
- 表与表之间的关联关系通过代码逻辑控制
- 关联查询通过
JOIN或应用层组装实现
原因:
- 性能考虑:外键约束会影响数据库写入性能,尤其是大批量数据插入/删除时
- 灵活性:应用层控制关联关系更灵活,便于处理复杂的业务逻辑
- 分库分表:外键约束会限制数据库的水平/垂直扩展能力
- 维护性:外键约束删除数据时需要先删除子记录,增加维护复杂度
示例对比:
-- ❌ 不推荐:使用外键约束
CREATE TABLE t_user_role (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES t_user(id),
CONSTRAINT fk_role FOREIGN KEY (role_id) REFERENCES t_auth_role(id)
);
-- ✅ 推荐:不使用外键约束
CREATE TABLE t_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
user_id BIGINT NOT NULL COMMENT '用户 ID',
role_id BIGINT NOT NULL COMMENT '角色 ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_user_role (user_id, role_id)
) COMMENT='用户角色关联表';
代码层控制关联关系:
// ✅ 推荐:在 Service 层控制关联关系
@Service
@RequiredArgsConstructor
public class UserRoleService {
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long userId) {
// 1. 先删除关联关系
userRoleMapper.delete(new LambdaQueryWrapper<UserRole>()
.eq(UserRole::getUserId, userId));
// 2. 再删除用户记录
userMapper.deleteById(userId);
}
@Transactional(rollbackFor = Exception.class)
public void assignRole(Long userId, Long roleId) {
// 1. 验证用户是否存在
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 2. 验证角色是否存在
AuthRole role = authRoleMapper.selectById(roleId);
if (role == null) {
throw new BusinessException("角色不存在");
}
// 3. 添加用户角色关联
UserRole userRole = UserRole.builder()
.userId(userId)
.roleId(roleId)
.build();
userRoleMapper.insert(userRole);
}
}
关联查询示例:
// ✅ 推荐:使用 JOIN 查询关联数据
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 查询用户及其角色信息
*/
@Select("SELECT u.*, r.role_code, r.role_name " +
"FROM t_user u " +
"LEFT JOIN t_user_role ur ON u.id = ur.user_id " +
"LEFT JOIN t_auth_role r ON ur.role_id = r.id " +
"WHERE u.id = #{userId} AND u.deleted = 0")
UserWithRolesVO getUserWithRoles(Long userId);
}
// ✅ 或者:在 Service 层组装关联数据
public UserWithRolesVO getUserWithRoles(Long userId) {
// 1. 查询用户
User user = userMapper.selectById(userId);
// 2. 查询用户的角色列表
List<AuthRole> roles = authRoleMapper.selectByUserId(userId);
// 3. 组装 VO
return UserWithRolesVO.builder()
.user(user)
.roles(roles)
.build();
}
3.5 多对多关系中间表命名规范
核心原则:中间表名必须清晰表达两个关联实体的关系,避免歧义。
1. 标准命名格式
t_{实体 A}_{实体 B}
- 按字母顺序或业务逻辑顺序排列
- 不使用
relation、rel、link、mapping等冗余词 - 不使用
map、assoc、junction等缩写 - 实体类名直接使用两个实体名的组合(如
ParentStudent)
2. 项目中的实际案例
| 表名 | 实体类 | 说明 | 关联关系 |
|---|---|---|---|
t_parent_student |
ParentStudent |
家长 - 学生关联 | 一个家长可关联多个学生 |
t_class_teacher |
ClassTeacher |
班级 - 教师关联 | 一个班级有多个教师,一个教师可带多个班级 |
t_course_package_course |
CoursePackageCourse |
课程包 - 课程关联 | 一个课程包包含多门课程 |
t_product_bundle_course_package |
ProductBundleCoursePackage |
套餐 - 课程包关联 | 一个套餐包含多个课程包 |
t_tenant_product_bundle |
TenantProductBundle |
租户 - 套餐关联 | 一个租户可订购多个套餐 |
3. 避免歧义的建议
建议 1:当表名本身是复合词时,使用完整名称
✅ 正确:t_course_package_course(课程包 - 课程)
❌ 错误:t_package_course(歧义:可能是包裹 - 课程)
建议 2:当两个表名组合后可能产生歧义时,添加业务前缀
场景:order_item 可能是订单 - 条目,也可能是订单项(单个实体)
✅ 方案一:t_order_order_item(明确表示关联表)
✅ 方案二:t_order_product_item(用 product 替代 item)
建议 3:一对多关系 vs 多对多关系的区分
一对多关系(外键在"多"方):
- 不需要中间表,直接在从表添加外键字段
- 示例:一个班级有多个学生 → 在 t_student 表添加 class_id 字段
多对多关系(需要中间表):
- 必须使用中间表
- 中间表命名:t_表 A_表 B
- 示例:一个家长有多个学生,一个学生有多个家长 → t_parent_student
建议 4:中间表可以包含业务属性
中间表不仅仅是关联关系,还可以包含业务属性:
@Data
@TableName("t_class_teacher")
@Schema(description = "班级教师关联")
public class ClassTeacher {
@Schema(description = "ID", accessMode = Schema.AccessMode.READ_ONLY)
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "班级 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long classId;
@Schema(description = "教师 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long teacherId;
// === 业务属性字段 ===
@Schema(description = "角色", example = "teacher", requiredMode = Schema.RequiredMode.REQUIRED)
private String role; // 主教/助教
@Schema(description = "是否主教", example = "true")
private Boolean isPrimary;
@Schema(description = "排序", example = "1")
private Integer sortOrder;
}
4. 实体类命名规范
// ✅ 推荐:直接使用两个实体类名的组合
public class ParentStudent { } // 对应 t_parent_student
public class ClassTeacher { } // 对应 t_class_teacher
// ❌ 避免:使用模糊的后缀
public class ParentStudentRelation { } // 冗余
public class ParentStudentMap { } // 易与 Map 数据结构混淆
public class ParentStudentLink { } // 冗余
public class ParentStudentMapping { } // 冗余
5. 主键策略
// 推荐:使用 AUTO_INCREMENT(数据库自增)- 适用于所有表
@TableId(type = IdType.AUTO)
private Long id;
6. 唯一索引约束
-- 防止重复关联(必须在数据库中创建唯一索引)
CREATE UNIQUE INDEX uk_parent_student ON t_parent_student(parent_id, student_id);
CREATE UNIQUE INDEX uk_class_teacher ON t_class_teacher(class_id, teacher_id);
7. 命名决策树
开始设计关联关系
│
▼
┌─────────────────────┐
│ 是几对几关系? │
└─────────────────────┘
│ │
一对多 多对多
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ 在"多"方表 │ │ 创建中间表 │
│ 添加外键 │ │ t_表 A_表 B │
│ class_id │ │ + 业务属性字段 │
└──────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ 表名是否复合词? │
└──────────────────┘
│ │
是 否
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 使用完整 │ │ 直接组合 │
│ 名称 │ │ 表名 │
└──────────┘ └──────────┘
8. 完整示例
表结构:
CREATE TABLE t_parent_student (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
parent_id BIGINT NOT NULL COMMENT '家长 ID',
student_id BIGINT NOT NULL COMMENT '学生 ID',
relationship VARCHAR(20) COMMENT '关系:father/mother/other',
is_primary TINYINT DEFAULT 1 COMMENT '是否主要联系人',
created_by VARCHAR(50) COMMENT '创建人',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_by VARCHAR(50) COMMENT '更新人',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
UNIQUE INDEX uk_parent_student (parent_id, student_id) COMMENT '防止重复关联',
INDEX idx_parent (parent_id) COMMENT '按家长查询',
INDEX idx_student (student_id) COMMENT '按学生查询'
) COMMENT='家长学生关联表';
实体类:
package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 家长 - 学生关联实体
*
* <p>用于实现家长与学生的多对多关系:一个家长可关联多个学生,一个学生也可被多个家长关联。</p>
* <p>中间表可包含业务属性:关系(父子/母子)、是否主要联系人等。</p>
*/
@Data
@TableName("t_parent_student")
@Schema(description = "家长学生关联")
public class ParentStudent {
@Schema(description = "ID", accessMode = Schema.AccessMode.READ_ONLY)
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "家长 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long parentId;
@Schema(description = "学生 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long studentId;
@Schema(description = "关系", example = "mother", allowableValues = {"father", "mother", "other"})
private String relationship;
@Schema(description = "是否主要联系人", example = "1")
private Integer isPrimary;
@Schema(description = "创建人用户名", accessMode = Schema.AccessMode.READ_ONLY)
@TableField(fill = FieldFill.INSERT)
private String createdBy;
@Schema(description = "创建时间", accessMode = Schema.AccessMode.READ_ONLY)
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@Schema(description = "是否删除 0-未删除 1-已删除", accessMode = Schema.AccessMode.READ_ONLY)
@TableLogic
private Integer deleted;
}
4. 基础实体类
package com.company.project.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.SuperBuilder;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 基础实体类
*
* <p>包含所有实体共有的字段:ID(数据库自增)、审计字段、逻辑删除。</p>
* <p>所有实体类应继承此基类。</p>
*
* @author developer
* @since 2024-01-01
*/
@Data
@SuperBuilder
@Schema(hidden = true)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键 ID(数据库自增)
*/
@Schema(description = "主键 ID")
@TableId(type = IdType.AUTO)
private Long id;
/**
* 创建人(username)
*/
@Schema(description = "创建人")
@CreatedBy
@TableField(fill = FieldFill.INSERT)
private String createBy;
/**
* 创建时间
*/
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人(username)
*/
@Schema(description = "更新人")
@LastModifiedBy
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/**
* 更新时间
*/
@Schema(description = "更新时间")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标识(0-未删除,1-已删除)
*/
@Schema(description = "删除标识")
@TableLogic
@JsonIgnore
private Integer deleted;
}
4. 业务实体类示例
package com.company.project.entity.user;
import com.baomidou.mybatisplus.annotation.TableName;
import com.company.project.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
/**
* 用户实体类
*
* <p>对应数据库表:t_user</p>
* <p>存储系统用户的基本信息,包括登录账号、联系方式、状态等。</p>
*
* @author developer
* @since 2024-01-01
*/
@Data
@SuperBuilder
@EqualsAndHashCode(callSuper = true)
@TableName("t_user")
@Schema(description = "用户信息")
public class User extends BaseEntity {
private static final long serialVersionUID = 1L;
/**
* 用户名(登录账号)
*/
@Schema(description = "用户名", example = "zhangsan")
private String username;
/**
* 密码(BCrypt 加密存储)
*/
@Schema(description = "密码")
private String password;
/**
* 昵称
*/
@Schema(description = "昵称", example = "张三")
private String nickname;
/**
* 邮箱
*/
@Schema(description = "邮箱", example = "zhangsan@example.com")
private String email;
/**
* 手机号
*/
@Schema(description = "手机号", example = "13800138000")
private String phone;
/**
* 头像 URL
*/
@Schema(description = "头像 URL")
private String avatar;
/**
* 状态:0-禁用,1-正常,2-锁定
*/
@Schema(description = "状态", example = "1", allowableValues = {"0", "1", "2"})
private Integer status;
/**
* 备注
*/
@Schema(description = "备注")
private String remark;
}
5. MyBatis-Plus 自动填充配置
package com.company.project.common.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.PaginationInnerInterceptor;
import com.company.project.common.security.UserPrincipal;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 配置类
*
* <p>配置分页插件和自动填充处理器。</p>
*
* @author developer
* @since 2024-01-01
*/
@Configuration
public class MybatisPlusConfig implements MetaObjectHandler {
/**
* 分页插件配置
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
/**
* 插入时自动填充
*/
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "createBy", String.class, getCurrentUsername());
}
/**
* 更新时自动填充
*/
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictUpdateFill(metaObject, "updateBy", String.class, getCurrentUsername());
}
/**
* 获取当前登录用户名
*/
private String getCurrentUsername() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
return ((UserPrincipal) authentication.getPrincipal()).getUsername();
}
} catch (Exception e) {
// 未登录或获取失败
}
return null;
}
}
五、RBAC 权限体系规范
1. 权限模型设计
采用标准的 RBAC(Role-Based Access Control)模型:
用户 → 用户 - 角色关联 → 角色 → 角色 - 菜单关联 → 菜单/按钮权限
2. 权限相关表结构
-- 角色表
CREATE TABLE t_auth_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
role_code VARCHAR(50) NOT NULL COMMENT '角色编码',
role_name VARCHAR(100) NOT NULL COMMENT '角色名称',
sort_order INT DEFAULT 0 COMMENT '排序',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-正常',
create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人(username)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人(username)',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT DEFAULT 0 COMMENT '删除标识',
UNIQUE KEY uk_role_code (role_code)
) COMMENT='角色表';
-- 菜单权限表
CREATE TABLE t_auth_menu (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
parent_id BIGINT DEFAULT 0 COMMENT '父菜单 ID',
menu_type VARCHAR(20) NOT NULL COMMENT '菜单类型:menu-菜单,button-按钮',
menu_name VARCHAR(100) NOT NULL COMMENT '菜单名称',
menu_path VARCHAR(200) COMMENT '菜单路径',
permission VARCHAR(100) COMMENT '权限标识',
icon VARCHAR(100) COMMENT '菜单图标',
sort_order INT DEFAULT 0 COMMENT '排序',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-正常',
create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人(username)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人(username)',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT DEFAULT 0 COMMENT '删除标识'
) COMMENT='菜单权限表';
-- 用户角色关联表
CREATE TABLE t_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
user_id BIGINT NOT NULL COMMENT '用户 ID',
role_id BIGINT NOT NULL COMMENT '角色 ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_user_role (user_id, role_id)
) COMMENT='用户角色关联表';
-- 角色菜单关联表
CREATE TABLE t_role_menu (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
role_id BIGINT NOT NULL COMMENT '角色 ID',
menu_id BIGINT NOT NULL COMMENT '菜单 ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_role_menu (role_id, menu_id)
) COMMENT='角色菜单关联表';
3. 权限注解与切面
package com.company.project.common.annotation;
import java.lang.annotation.*;
/**
* RBAC 权限注解
*
* <p>用于标记需要权限控制的方法。</p>
* <p>支持角色权限和按钮权限两种校验方式。</p>
*
* @author developer
* @since 2024-01-01
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireRole {
/**
* 角色编码列表(支持多个)
*/
String[] value() default {};
/**
* 权限标识(按钮权限)
*/
String permission() default "";
/**
* 是否校验所有条件(AND 关系)
*/
boolean requireAll() default false;
}
package com.company.project.common.aspect;
import com.company.project.common.annotation.RequireRole;
import com.company.project.common.exception.ForbiddenException;
import com.company.project.common.security.UserPrincipal;
import com.company.project.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
/**
* RBAC 权限切面
*
* <p>拦截带有 RequireRole 注解的方法和类,进行权限校验。</p>
*
* @author developer
* @since 2024-01-01
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RBACAspect {
private final AuthService authService;
@Around("@annotation(com.company.project.common.annotation.RequireRole) || " +
"@within(com.company.project.common.annotation.RequireRole)")
public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof UserPrincipal)) {
throw new ForbiddenException("未登录");
}
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RequireRole requireRole = method.getAnnotation(RequireRole.class);
if (requireRole == null) {
requireRole = joinPoint.getTarget().getClass().getAnnotation(RequireRole.class);
}
if (requireRole != null) {
// 角色权限校验
String[] roles = requireRole.value();
if (roles.length > 0) {
boolean hasRole = java.util.Arrays.stream(roles)
.anyMatch(role -> principal.getRoles().contains(role));
if (!hasRole) {
throw new ForbiddenException("没有访问权限");
}
}
// 按钮权限校验
String permission = requireRole.permission();
if (StringUtils.hasText(permission) && !authService.hasPermission(principal.getId(), permission)) {
throw new ForbiddenException("没有操作权限:" + permission);
}
}
return joinPoint.proceed();
}
}
六、日志规范
0. 日志打印语言规范(重要)
核心原则:所有日志打印内容必须使用中文,包括业务日志、调试日志、异常日志等。
原因:
- 可读性:项目团队成员都是中文使用者,中文日志更易于理解
- 排查效率:中文日志能更准确地描述业务场景,提高问题排查效率
- 统一规范:保持整个项目的代码风格和文档一致性
错误示例:
// ❌ 错误:使用英文日志
log.info("User created successfully, id: {}", userId);
log.debug("Query user by id: {}", userId);
log.error("Failed to create user", e);
log.warn("User not found, id: {}", userId);
正确示例:
// ✅ 正确:使用中文日志
log.info("用户创建成功,ID: {}", userId);
log.debug("查询用户,ID: {}", userId);
log.error("创建用户失败", e);
log.warn("用户不存在,ID: {}", userId);
完整示例:
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
@Override
@Transactional(rollbackFor = Exception.class)
public User createUser(UserCreateRequest request) {
// 记录入参
log.info("开始创建用户,用户名:{}", request.getUsername());
// 检查唯一性
boolean exists = userMapper.existsByUsername(request.getUsername());
if (exists) {
log.warn("用户名已存在:{}", request.getUsername());
throw new BusinessException("用户名已存在");
}
// 构建并保存
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.build();
userMapper.insert(user);
// 记录结果
log.info("用户创建成功,ID: {}", user.getId());
return user;
}
@Override
public User getUserById(Long userId) {
log.debug("查询用户,ID: {}", userId);
User user = this.getById(userId);
if (user == null) {
log.warn("用户不存在,ID: {}", userId);
throw new BusinessException("用户不存在");
}
log.info("查询用户成功,用户名:{}", user.getUsername());
return user;
}
@Override
public void deleteUser(Long userId) {
log.info("开始删除用户,ID: {}", userId);
try {
this.removeById(userId);
log.info("用户删除成功,ID: {}", userId);
} catch (Exception e) {
log.error("用户删除失败,ID: {}", userId, e);
throw new SystemException("删除用户失败");
}
}
}
日志格式规范:
| 场景 | 推荐格式 | 示例 |
|---|---|---|
| 操作开始 | "开始{操作},{关键参数}" | 开始创建用户,用户名:zhangsan |
| 操作成功 | "{操作}成功,{关键结果}" | 用户创建成功,ID: 123 |
| 操作失败 | "{操作}失败,{关键参数}" | 用户删除失败,ID: 123 |
| 查询操作 | "{动作}{对象},{关键参数}" | 查询用户,ID: 123 |
| 状态检查 | "{对象}不存在/已存在,{关键参数}" | 用户不存在,ID: 123 |
| 异常日志 | "{操作}异常,{关键参数}" + e | 创建用户异常,ID: 123 + e |
1. Logback 配置
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<property name="LOG_PATH" value="${LOG_PATH:-./logs}"/>
<property name="APP_NAME" value="${APP_NAME:-project}"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- INFO 日志文件 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- ERROR 日志文件 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>90</maxHistory>
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 开发环境 -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.company.project" level="DEBUG"/>
</springProfile>
<!-- 生产环境 -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="FILE_ERROR"/>
</root>
</springProfile>
</configuration>
2. 日志使用规范
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Override
public UserInfoResponse getUserById(Long userId) {
// DEBUG - 调试信息
log.debug("查询用户,ID: {}", userId);
try {
User user = userMapper.selectById(userId);
if (user == null) {
// WARN - 业务警告
log.warn("用户不存在,ID: {}", userId);
throw new BusinessException("用户不存在");
}
// INFO - 业务操作记录
log.info("查询用户成功,用户名:{}", user.getUsername());
return convertToResponse(user);
} catch (BusinessException e) {
// 业务异常
log.warn("业务异常:{}", e.getMessage());
throw e;
} catch (Exception e) {
// ERROR - 系统异常
log.error("系统异常,ID: {}", userId, e);
throw new SystemException("系统异常");
}
}
}
3. 日志级别使用场景
| 级别 | 使用场景 | 示例 |
|---|---|---|
| ERROR | 系统异常,需要人工介入 | 数据库连接失败、第三方服务调用失败 |
| WARN | 业务异常,可预期的错误 | 参数校验失败、资源不存在 |
| INFO | 重要的业务操作记录 | 用户登录、订单创建、支付完成 |
| DEBUG | 调试信息,开发时使用 | 方法入参、SQL 执行结果 |
七、FastJSON 配置规范
1. FastJSON 配置类
package com.company.project.common.config;
import com.alibaba.fastjson2.support.spring.http.converter.FastJsonHttpMessageConverter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* FastJSON 配置类
*
* @author developer
* @since 2024-01-01
*/
@Configuration
public class FastJsonConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
converter.setDefaultCharset(StandardCharsets.UTF_8);
List<MediaType> mediaTypes = new ArrayList<>();
mediaTypes.add(MediaType.APPLICATION_JSON);
converter.setSupportedMediaTypes(mediaTypes);
converters.add(0, converter);
}
}
2. JSON 工具类
package com.company.project.common.util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import java.util.List;
/**
* FastJSON 工具类
*
* @author developer
* @since 2024-01-01
*/
public class JsonUtils {
private JsonUtils() {}
public static String toJson(Object obj) {
return JSON.toJSONString(obj);
}
public static <T> T parseObject(String json, Class<T> clazz) {
return JSON.parseObject(json, clazz);
}
public static <T> List<T> parseArray(String json, Class<T> clazz) {
return JSON.parseArray(json, clazz);
}
}
八、阿里云 OSS 文件上传规范
1. OSS 配置类
package com.company.project.common.config;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 阿里云 OSS 配置类
*
* @author developer
* @since 2024-01-01
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "aliyun.oss")
public class OssConfig {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
private String urlPrefix;
@Bean
public OSS ossClient() {
return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
}
}
2. application.yml 配置
# 阿里云 OSS 配置(预留)
aliyun:
oss:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: ${OSS_ACCESS_KEY_ID:your-access-key-id}
access-key-secret: ${OSS_ACCESS_KEY_SECRET:your-access-key-secret}
bucket-name: ${OSS_BUCKET_NAME:your-bucket-name}
url-prefix: https://your-bucket-name.oss-cn-hangzhou.aliyuncs.com/
3. OSS 工具类
package com.company.project.common.util;
import com.aliyun.oss.OSS;
import com.aliyun.oss.model.PutObjectRequest;
import com.company.project.common.config.OssConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 阿里云 OSS 工具类
*
* @author developer
* @since 2024-01-01
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OssUtils {
private final OSS ossClient;
private final OssConfig ossConfig;
/**
* 上传文件
*/
public String uploadFile(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd/"))
+ UUID.randomUUID().toString().replace("-", "") + extension;
try (InputStream inputStream = file.getInputStream()) {
ossClient.putObject(new PutObjectRequest(ossConfig.getBucketName(), objectName, inputStream));
String fileUrl = ossConfig.getUrlPrefix() + objectName;
log.info("文件上传成功:{}", fileUrl);
return fileUrl;
} catch (Exception e) {
log.error("文件上传失败", e);
throw new BusinessException("文件上传失败");
}
}
/**
* 删除文件
*/
public void deleteFile(String fileUrl) {
String objectName = fileUrl.replace(ossConfig.getUrlPrefix(), "");
ossClient.deleteObject(ossConfig.getBucketName(), objectName);
}
}
8. 工具函数使用规范
核心原则:工具函数必须集中管理,禁止在 Controller 层直接编写工具方法。
工具函数存放位置决策树
需要编写工具函数
│
▼
┌──────────────────┐
│ 调用地方有几个? │
└──────────────────┘
│ │
多处 一处
│ │
▼ ▼
┌─────────┐ ┌─────────────┐
│ 统一写 │ │ 直接写在 │
│ 到工具 │ │ Service 层 │
│ 类中 │ │ 内部方法 │
└─────────┘ └─────────────┘
规范说明
| 场景 | 存放位置 | 调用方式 |
|---|---|---|
| 多个地方调用(≥2 处) | 统一工具类(如 CommonUtil) |
静态方法调用 |
| 仅在一个地方调用 | Service 层内部私有方法 | 本类内调用 |
| 业务无关的通用工具 | 独立工具类(如 DateUtil, FileUtil) |
静态方法调用 |
错误示例
// ❌ 错误 1:在 Controller 层编写工具方法
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
/**
* 错误:工具方法不应该写在 Controller 层
*/
private String formatUsername(String username) {
return username.trim().toLowerCase();
}
@PostMapping
public Result<UserVO> createUser(@RequestBody UserRequest request) {
String formattedUsername = formatUsername(request.getUsername());
// ...
}
}
// ❌ 错误 2:工具逻辑散落在各处
@Service
public class UserServiceImpl implements UserService {
// 每个 Service 都写一遍相同的工具逻辑
private String generateOrderNo() {
return "ORD" + System.currentTimeMillis() + RandomUtil.randomInt(1000, 9999);
}
}
@Service
public class OrderServiceImpl implements OrderService {
// 重复代码
private String generateOrderNo() {
return "ORD" + System.currentTimeMillis() + RandomUtil.randomInt(1000, 9999);
}
}
正确示例
场景一:多处调用的工具函数 → 统一工具类
// ✅ 正确:统一工具类
package com.reading.platform.common.util;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 通用工具类
*
* <p>集中管理项目中常用的工具方法,避免代码重复。</p>
*/
@Slf4j
public class CommonUtil {
private CommonUtil() {
// 私有构造,防止实例化
}
/**
* 生成订单号
*
* <p>格式:ORD + 年月日时分秒 + 4 位随机数</p>
*
* @return 订单号
*/
public static String generateOrderNo() {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
int random = (int) (Math.random() * 9000) + 1000;
return "ORD" + timestamp + random;
}
/**
* 格式化用户名
*
* @param username 原始用户名
* @return 格式化后的用户名
*/
public static String formatUsername(String username) {
if (username == null) {
return null;
}
return username.trim().toLowerCase();
}
/**
* 生成 6 位短信验证码
*
* @return 验证码
*/
public static String generateSmsCode() {
return String.format("%06d", (int) (Math.random() * 999999));
}
}
场景二:仅一处调用的工具函数 → Service 层内部方法
// ✅ 正确:仅在本 Service 内使用的工具方法写在 Service 层
@Service
@Slf4j
@RequiredArgsConstructor
public class LessonServiceImpl extends ServiceImpl<LessonMapper, Lesson> implements LessonService {
private final StudentRecordMapper studentRecordMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void finishLesson(Long lessonId, List<StudentRecord> records) {
log.info("开始结束课时,ID: {}", lessonId);
// 保存学生记录
for (StudentRecord record : records) {
record.setLessonId(lessonId);
// 调用内部工具方法处理数据
processRecordBeforeSave(record);
studentRecordMapper.insert(record);
}
// 更新课时状态
Lesson lesson = this.getById(lessonId);
lesson.setStatus("finished");
this.updateById(lesson);
log.info("课时结束完成,ID: {}", lessonId);
}
/**
* 保存记录前处理数据
*
* <p>此方法仅在本类中使用,不需要抽取到工具类</p>
*/
private void processRecordBeforeSave(StudentRecord record) {
// 计算综合评分
int totalScore = record.getFocus() + record.getParticipation()
+ record.getInterest() + record.getUnderstanding();
record.setTotalScore(totalScore);
// 根据综合评分设置评价等级
if (totalScore >= 17) {
record.setGrade("A");
} else if (totalScore >= 13) {
record.setGrade("B");
} else {
record.setGrade("C");
}
}
}
场景三:通用工具 → 独立工具类
// ✅ 正确:独立工具类,按功能分类
package com.reading.platform.common.util;
/**
* 日期工具类
*/
public class DateUtil {
private DateUtil() {}
public static String formatDate(LocalDateTime dateTime) {
return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
public static LocalDateTime parseDate(String dateStr) {
return LocalDateTime.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
/**
* 文件工具类
*/
public class FileUtil {
private FileUtil() {}
public static String getExtension(String fileName) {
int lastDot = fileName.lastIndexOf(".");
return lastDot > 0 ? fileName.substring(lastDot) : "";
}
public static boolean isValidImage(String fileName) {
String ext = getExtension(fileName).toLowerCase();
return Arrays.asList(".jpg", ".jpeg", ".png", ".gif").contains(ext);
}
}
工具类命名规范
| 类型 | 命名格式 | 示例 |
|---|---|---|
| 通用工具类 | XxxUtil |
CommonUtil, DateUtil, FileUtil |
| 业务工具类 | XxxHelper |
UserHelper, OrderHelper |
| 转换工具类 | XxxConverter |
EntityConverter, DtoConverter |
工具类设计原则
-
私有构造:防止实例化
private CommonUtil() { throw new UnsupportedOperationException("Utility class cannot be instantiated"); } -
静态方法:所有方法都是
static的 -
无状态:工具类不应持有状态
-
线程安全:工具方法必须是线程安全的
-
充分注释:每个方法都要有 JavaDoc 注释,说明用途、参数、返回值
代码审查要点
- 工具函数是否抽取到工具类或 Service 内部
- 是否存在 Controller 层直接编写工具方法的情况
- 多处使用的工具是否统一到工具类中
- 工具类是否有私有构造防止实例化
- 工具方法是否为静态方法
- 工具类是否无状态(不保存成员变量)
- 工具方法是否有完整的 JavaDoc 注释
九、Hutool 工具类使用规范
1. Maven 依赖
<!-- Hutool 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.23</version>
</dependency>
<!-- FastJSON2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.42</version>
</dependency>
2. 常用工具方法
package com.company.project.common.util;
import cn.hutool.core.util.*;
import cn.hutool.crypto.SecureUtil;
/**
* Hutool 工具类使用示例
*
* @author developer
* @since 2024-01-01
*/
public class ToolUtils {
private ToolUtils() {}
/** 生成 UUID(不带横杠) */
public static String uuid() {
return IdUtil.fastSimpleUUID();
}
/** 密码加密(BCrypt) */
public static String encryptPassword(String password) {
return SecureUtil.bcrypt(password);
}
/** 密码校验 */
public static boolean verifyPassword(String password, String encrypted) {
return SecureUtil.bcryptVerify(password, encrypted);
}
/** 检查手机号是否有效 */
public static boolean isValidPhone(String phone) {
return Validator.isMatchRegex("^1[3-9]\\d{9}$", phone);
}
/** 脱敏手机号 */
public static String maskPhone(String phone) {
return DesensitizedUtil.mobilePhone(phone);
}
}
十、Swagger/Knife4j 配置规范
1. Swagger 配置类
package com.company.project.common.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.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Swagger/OpenAPI 配置类
*
* @author developer
* @since 2024-01-01
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("项目 API 接口文档")
.description("基于 Spring Boot + Vue 3 的企业级应用")
.version("1.0.0")
.contact(new Contact()
.name("开发团队")
.email("dev@company.com")))
.servers(List.of(
new Server().url("http://localhost:8080").description("本地环境"),
new Server().url("http://api-dev.company.com").description("开发环境")
))
.schemaRequirement("BearerAuth", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Authorization: Bearer {token}"))
.addSecurityItem(new SecurityRequirement().addList("BearerAuth"));
}
/** 接口分组 */
@Bean
public GroupedOpenApi allApi() {
return GroupedOpenApi.builder()
.group("全部接口")
.pathsToMatch("/api/**")
.build();
}
}
2. Maven 依赖
<!-- Knife4j (SpringDoc) -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
3. 接口文档访问
- Knife4j UI: http://localhost:8080/doc.html
- OpenAPI JSON: http://localhost:8080/v3/api-docs
十一、统一响应格式
1. 响应类定义
package com.company.project.common.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* 统一响应类
*
* @author developer
* @since 2024-01-01
*/
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "统一响应")
public class Result<T> {
@Schema(description = "状态码", example = "200")
private Integer code;
@Schema(description = "消息", example = "success")
private String message;
@Schema(description = "数据")
private T data;
@Schema(description = "时间戳")
private Long timestamp;
public static <T> Result<T> success(T data) {
return Result.<T>builder()
.code(200).message("success").data(data)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> error(Integer code, String message) {
return Result.<T>builder()
.code(code).message(message)
.timestamp(System.currentTimeMillis())
.build();
}
}
2. 错误码枚举
package com.company.project.common.response;
import lombok.Getter;
/**
* 错误码枚举
*
* @author developer
* @since 2024-01-01
*/
@Getter
public enum ErrorCode {
SUCCESS(200, "success"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或 Token 已过期"),
FORBIDDEN(403, "没有访问权限"),
NOT_FOUND(404, "资源不存在"),
INTERNAL_ERROR(500, "系统内部错误");
private final Integer code;
private final String message;
ErrorCode(Integer code, String message) {
this.code = code;
this.message = message;
}
}
3. 全局异常处理器
package com.company.project.common.exception;
import com.company.project.common.response.Result;
import com.company.project.common.response.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器
*
* @author developer
* @since 2024-01-01
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常:{}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), "系统异常,请稍后重试");
}
}
十二、多环境配置规范
1. 配置文件目录结构
backend/src/main/resources/
├── application.yml # 主配置文件(共用配置)
├── application-dev.yml # 开发环境配置
├── application-test.yml # 测试环境配置
├── application-prod.yml # 生产环境配置
├── db/migration/ # Flyway 迁移脚本
├── logback-spring.xml # 日志配置
└── mapper/ # MyBatis XML
2. 主配置文件 (application.yml)
# =================== 主配置文件 ===================
# 所有环境共用的配置,环境特定配置在各环境文件中覆盖
spring:
application:
name: ${APP_NAME:project}
# 激活的环境配置(通过命令行参数或环境变量切换)
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
server:
port: ${SERVER_PORT:8080}
3. 开发环境配置 (application-dev.yml)
# =================== 开发环境配置 ===================
spring:
config:
activate:
on-profile: dev
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:project}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: ${DB_USER:root}
password: ${DB_PASSWORD:password}
type: com.alibaba.druid.pool.DruidDataSource
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DB:0}
flyway:
enabled: true
locations: classpath:db/migration
clean-disabled: false
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
secret: ${JWT_SECRET:dev-secret-key-for-development-only}
expiration: ${JWT_EXPIRATION:86400000}
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
logging:
level:
root: INFO
com.company.project: DEBUG
com.company.project.mapper: DEBUG
4. 测试环境配置 (application-test.yml)
# =================== 测试环境配置 ===================
spring:
config:
activate:
on-profile: test
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:project_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${DB_USER:test_user}
password: ${DB_PASSWORD:test_password}
type: com.alibaba.druid.pool.DruidDataSource
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DB:0}
flyway:
enabled: true
locations: classpath:db/migration
clean-disabled: true
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
jwt:
secret: ${JWT_SECRET:test-secret-key}
expiration: ${JWT_EXPIRATION:86400000}
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
logging:
level:
root: INFO
com.company.project: INFO
com.company.project.mapper: DEBUG
5. 生产环境配置 (application-prod.yml)
# =================== 生产环境配置 ===================
spring:
config:
activate:
on-profile: prod
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:project}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=true
username: ${DB_USER:prod_user}
password: ${DB_PASSWORD:prod_password}
type: com.alibaba.druid.pool.DruidDataSource
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:prod_password}
database: ${REDIS_DB:0}
timeout: 10000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
flyway:
enabled: true
locations: classpath:db/migration
clean-disabled: true
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
jwt:
secret: ${JWT_SECRET} # 生产环境必须设置环境变量
expiration: ${JWT_EXPIRATION:86400000}
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
logging:
level:
root: WARN
com.company.project: INFO
com.company.project.mapper: WARN
6. 环境切换方式
方式一:环境变量(推荐)
# Linux/Mac
export SPRING_PROFILES_ACTIVE=prod
java -jar project.jar
# Windows
set SPRING_PROFILES_ACTIVE=prod
java -jar project.jar
方式二:命令行参数
java -jar project.jar --spring.profiles.active=prod
方式三:Docker 环境变量
# docker-compose.yml
services:
backend:
environment:
- SPRING_PROFILES_ACTIVE=prod
7. 环境配置说明
| 配置项 | 开发环境 (dev) | 测试环境 (test) | 生产环境 (prod) |
|---|---|---|---|
| 数据库 | 本地 MySQL | 测试服务器 | 生产服务器 |
| SQL 日志 | 开启 | 开启 | 关闭 |
| Swagger | 开启 | 开启 | 关闭 |
| Flyway Clean | 允许 | 禁止 | 禁止 |
| JWT 密钥 | 默认值 | 默认值 | 必须环境变量 |
| Redis 连接池 | 默认 | 默认 | 优化配置 |
十三、Docker Compose 本地开发
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: project
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
networks:
- project-network
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- project-network
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
- SPRING_PROFILES_ACTIVE=dev
- DB_HOST=mysql
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
networks:
- project-network
volumes:
- ./logs:/app/logs
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
networks:
- project-network
networks:
project-network:
driver: bridge
volumes:
mysql_data:
redis_data:
版本历史
| 版本 | 日期 | 更新内容 |
|---|---|---|
| 1.0 | 2024-01-01 | 初始版本 - 通用企业级开发规范 |
| 1.1 | 2024-01-01 | 新增多环境配置规范 |
| 1.2 | 2024-01-02 | 新增限流规范(Redis 滑动窗口 + 配置化 + 注解) |
快速参考
环境激活方式
| 方式 | 命令/配置 | 说明 |
|---|---|---|
| 环境变量 | SPRING_PROFILES_ACTIVE=prod |
推荐用于生产环境 |
| 命令行参数 | --spring.profiles.active=prod |
临时切换环境 |
| Docker | environment: - SPRING_PROFILES_ACTIVE=prod |
容器化部署 |
环境配置对比
| 配置项 | 开发环境 (dev) | 测试环境 (test) | 生产环境 (prod) |
|---|---|---|---|
| SQL 日志 | 开启 | 开启 | 关闭 |
| Swagger | 开启 | 开启 | 关闭 |
| Flyway Clean | 允许 | 禁止 | 禁止 |
| JWT 密钥 | 默认值 | 默认值 | 必须环境变量 |
| Redis 连接池 | 默认 | 默认 | 优化配置 |
核心配置速查
| 配置项 | 环境变量 | 默认值 |
|---|---|---|
| 活跃环境 | SPRING_PROFILES_ACTIVE |
dev |
| 数据库地址 | DB_HOST |
localhost |
| 数据库密码 | DB_PASSWORD |
password |
| Redis 地址 | REDIS_HOST |
localhost |
| Redis 密码 | REDIS_PASSWORD |
(空) |
| JWT 密钥 | JWT_SECRET |
dev-secret-key... |
| OSS AccessKey | OSS_ACCESS_KEY_ID |
your-access-key-id |
| OSS Secret | OSS_ACCESS_KEY_SECRET |
your-access-key-secret |
表名命名规范
t_user_*- 用户模块t_sys_*- 系统模块t_biz_*- 业务模块t_auth_*- 权限模块
Redis Key 命名规范
auth:token:{token}- 用户 Tokenuser:info:{userId}- 用户信息缓存dict:{type}- 数据字典lock:{resource}:{id}- 分布式锁rate_limit:{key}- 限流计数器
限流规范
技术选型
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis 滑动窗口 | 精确控制、支持分布式 | 依赖 Redis | 推荐:全局限流 |
| 自定义注解 + AOP | 灵活、可针对特定接口 | 需要手动添加注解 | 推荐:特殊接口限流 |
| 令牌桶算法 | 平滑限流、允许突发 | 实现复杂 | 特殊场景 |
| 计数器算法 | 简单、性能好 | 临界问题 | 不推荐 |
推荐方案:配置化 + 注解 fallback
采用 配置化全局限流 作为基础,注解限流 作为特殊场景的补充。
方案一:配置化限流(推荐作为基础)
在 application.yml 中配置全局限流规则:
rate-limit:
enabled: true # 是否启用限流
default-time-window: 60 # 默认时间窗口(秒)
default-max-requests: 1000 # 默认时间窗口内最大请求数
rules: # 限流规则列表
- pattern: "/api/v1/auth/login" # 接口路径(支持 Ant 风格)
time-window: 60 # 时间窗口(秒)
max-requests: 10 # 时间窗口内最大请求数
- pattern: "/api/v1/sms/**"
time-window: 60
max-requests: 3
exclude-patterns: # 排除限流的接口
- "/doc.html"
- "/v3/api-docs/**"
- "/uploads/**"
配置说明:
- 按照
pattern匹配接口路径,支持*和**通配符 - 每个接口按照
IP 地址 + pattern作为限流键,实现 per-IP 限流 - 未在规则中的接口使用默认配置(
default-time-window,default-max-requests)
方案三:注解限流(作为 fallback)
针对特殊接口使用 @RateLimit 注解进行限流:
package com.company.project.common.annotation;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
String keyPrefix() default ""; // 限流键前缀
long time() default 60; // 时间窗口大小
TimeUnit timeUnit() default TimeUnit.SECONDS;
long maxRequests() default 100; // 最大请求数
String message() default "请求过于频繁,请稍后再试";
boolean enabled() default true;
}
使用示例:
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
/**
* 登录接口 - 限流防止暴力破解
* 60 秒内最多 10 次请求
*/
@PostMapping("/login")
@RateLimit(time = 60, maxRequests = 10, message = "登录过于频繁,请稍后再试")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
...
}
/**
* 发送短信验证码 - 严格限流
* 60 秒内最多 3 次请求
*/
@PostMapping("/captcha")
@RateLimit(time = 60, maxRequests = 3, message = "操作过于频繁,请稍后再试")
public Result<Void> sendCaptcha(@RequestParam String phone) {
...
}
}
限流优先级
注解限流 > 配置化限流 > 默认限流
- 如果接口上有
@RateLimit注解,优先使用注解配置 - 如果没有注解,查找配置文件中匹配的规则
- 如果都没有,使用默认配置(
default-max-requests)
Redis 限流实现(滑动窗口算法)
/**
* Redis 限流工具类
*/
@Component
public class RateLimiter {
private final RedisTemplate<String, Object> redisTemplate;
public boolean tryAcquire(String key, long maxRequests, long timeWindow, TimeUnit timeUnit) {
long currentTime = System.currentTimeMillis();
long windowStart = currentTime - timeUnit.toMillis(timeWindow);
String redisKey = "rate_limit:" + key;
// 使用 Redis 事务保证原子性
Boolean result = redisTemplate.execute(connection -> {
// 移除窗口外的请求记录
connection.zSetCommands().zRemRangeByScore(
redisKey.getBytes(), 0, windowStart
);
// 获取当前窗口内的请求数
Long count = connection.zSetCommands().zCard(redisKey.getBytes());
if (count != null && count >= maxRequests) {
return false; // 超过限流阈值
}
// 添加当前请求记录
connection.zSetCommands().zAdd(
redisKey.getBytes(), currentTime, String.valueOf(currentTime).getBytes()
);
// 设置过期时间
connection.expire(redisKey.getBytes(), timeWindow);
return true;
}, false);
return Boolean.TRUE.equals(result);
}
}
常见限流场景建议值
| 接口类型 | 时间窗口 | 最大请求数 | 说明 |
|---|---|---|---|
| 登录接口 | 60 秒 | 10 | 防止暴力破解 |
| 验证码/短信 | 60 秒 | 3-5 | 防止短信轰炸 |
| 文件上传 | 60 秒 | 30 | 防止资源滥用 |
| 普通业务接口 | 60 秒 | 100-1000 | 根据业务调整 |
| 导出接口 | 60 秒 | 5-10 | 防止服务器过载 |
注意事项
- Redis 依赖:限流功能依赖 Redis,需确保 Redis 服务可用
- 异常情况放行:当 Redis 不可用时,建议放行请求,避免影响正常业务
- 集群部署:滑动窗口算法天然支持分布式,无需额外配置
- 监控告警:建议添加限流触发的监控告警,及时发现异常流量
十三、中间表设计规范(逻辑删除与唯一索引)
核心原则
中间表(关联表)不使用逻辑删除,使用物理删除,不设置联合唯一索引。
表类型分类
| 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 |
|---|---|---|---|---|
| A 类:纯关联表(中间表) | 只表示 A 和 B 的关系,无额外状态,先删后增的更新模式 | 物理删除(不设 deleted) | 不设置联合唯一索引 | course_collection_package、tenant_course、class_teacher |
| B 类:关系表 | 关系相对稳定,有业务历史价值 | 逻辑删除 | 建议设置联合唯一索引 | parent_student |
| C 类:配置表 | (key, value) 结构,天然应该唯一 | 逻辑删除 | 设置联合唯一索引 | system_setting |
| D 类:合同/授权表 | 有合同性质,需要保留历史 | 逻辑删除 | 不设置联合唯一索引 | tenant_package |
A 类表(纯关联表)设计规范
1. 为什么不使用逻辑删除?
原因:
- 中间表无业务历史价值,删除后不需要保留记录
- 逻辑删除会导致数据无限膨胀(每次删除都保留)
- 与唯一索引配合使用时会产生冲突(同一组合反复删除会冲突)
- 物理删除更符合业务直觉,代码更简洁
2. 为什么不设置联合唯一索引?
原因:
- 单体应用 + 低并发场景下,并发冲突概率极低
- 应用层校验(保存前去重、添加前检查)足够可靠
- 批量操作时,有索引会导致一条失败全回滚,体验差
- 先删后增的更新模式下,唯一索引无实际意义
风险可控:
- 应用层有完善的校验逻辑
- 定时任务检查脏数据
- 接受极端情况下的脏数据风险(概率极低)
3. 实体类写法
package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 课程套餐与课程包关联实体
* <p>
* 中间表,不设置逻辑删除字段,使用物理删除
* </p>
*/
@Schema(description = "课程套餐与课程包关联实体")
@Data
@TableName("course_collection_package")
public class CourseCollectionPackage {
@Schema(description = "主键 ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "课程套餐 ID")
private Long collectionId;
@Schema(description = "课程包 ID")
private Long packageId;
@Schema(description = "排序号")
private Integer sortOrder;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}
关键点:
- 不继承
BaseEntity,因此没有deleted字段 - 保留
createdAt、updatedAt用于审计 - 主键使用
@TableId(type = IdType.AUTO)自增
4. Service 层写法
@Service
@RequiredArgsConstructor
public class CourseCollectionPackageService {
private final CourseCollectionPackageMapper mapper;
/**
* 更新套餐包含的课程包(整包提交模式)
* 先删后增,物理删除
*/
@Transactional(rollbackFor = Exception.class)
public void updatePackages(Long collectionId, List<Long> packageIds) {
if (CollectionUtils.isEmpty(packageIds)) {
throw new BusinessException("课程包不能为空");
}
// 1. 去重(静默处理,不报错)
Set<Long> uniquePackageIds = new LinkedHashSet<>(packageIds);
// 2. 物理删除所有关联(不使用逻辑删除)
mapper.delete(new LambdaQueryWrapper<CourseCollectionPackage>()
.eq(CourseCollectionPackage::getCollectionId, collectionId));
// 3. 批量插入
List<CourseCollectionPackage> records = new ArrayList<>();
int sortOrder = 0;
for (Long packageId : uniquePackageIds) {
CourseCollectionPackage record = new CourseCollectionPackage();
record.setCollectionId(collectionId);
record.setPackageId(packageId);
record.setSortOrder(sortOrder++);
records.add(record);
}
saveBatch(records);
}
/**
* 单个添加课程包(增量添加)
*/
@Transactional(rollbackFor = Exception.class)
public void addPackage(Long collectionId, Long packageId) {
// 1. 应用层校验(友好提示)
Long count = count(new LambdaQueryWrapper<CourseCollectionPackage>()
.eq(CourseCollectionPackage::getCollectionId, collectionId)
.eq(CourseCollectionPackage::getPackageId, packageId));
if (count > 0) {
throw new BusinessException("该课程包已存在于套餐中");
}
// 2. 插入
CourseCollectionPackage record = new CourseCollectionPackage();
record.setCollectionId(collectionId);
record.setPackageId(packageId);
save(record);
}
}
5. 数据库表设计
CREATE TABLE `course_collection_package` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`collection_id` BIGINT NOT NULL COMMENT '课程套餐 ID',
`package_id` BIGINT NOT NULL COMMENT '课程包 ID',
`sort_order` INT DEFAULT 0 COMMENT '排序号',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- 不设置联合唯一索引
KEY `idx_collection_id` (`collection_id`),
KEY `idx_package_id` (`package_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程套餐 - 课程包关联表';
数据一致性保障
应用层校验
// 1. 保存前去重
Set<Long> uniqueIds = new LinkedHashSet<>(ids);
// 2. 添加前检查
Long count = count(new LambdaQueryWrapper<Entity>()
.eq(Entity::getField1, value1)
.eq(Entity::getField2, value2));
if (count > 0) {
throw new BusinessException("记录已存在");
}
// 3. 定时任务检查脏数据
@Scheduled(cron = "0 0 2 * * ?")
public void checkDuplicateData() {
// 检查并告警
}
事务保障
- 所有中间表操作必须使用
@Transactional注解 - 先删后增操作在同一事务中完成
- 异常时自动回滚
B/C/D 类表设计规范
| 表类型 | deleted | 联合唯一索引 | 说明 |
|---|---|---|---|
| B 类(关系表) | ✅ 保留 | ✅ 建议设置 | 如 parent_student |
| C 类(配置表) | ✅ 保留 | ✅ 必须设置 | 如 system_setting |
| D 类(合同/授权表) | ✅ 保留 | ❌ 不设置 | 如 tenant_package |
决策流程图
新表设计 → 是否需要保留删除历史?
├─ 否 → 纯关联表(A 类)→ 物理删除,不设置唯一索引
└─ 是 → 是否有稳定的业务关系?
├─ 是 → 关系表(B 类)→ 逻辑删除,建议设置唯一索引
└─ 否 → 配置表/合同表(C/D 类)→ 逻辑删除
总结
| 维度 | 传统做法 | 推荐做法(单体应用) |
|---|---|---|
| 中间表删除策略 | 逻辑删除 | 物理删除 |
| 中间表唯一索引 | 必须设置 | 不设置(应用层校验) |
| 数据一致性 | 数据库兜底 | 应用层校验 + 事务 |
| 适用场景 | 高并发、分布式 | 单体应用、低并发 |