kindergarten_java/docs/统一开发规范.md
En 60213b513d refactor(entity): 中间表优化 - 移除联合唯一索引和 deleted 字段
- 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>
2026-03-19 16:38:51 +08:00

3712 lines
115 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spring Boot + Vue 3 企业级应用开发规范
## 制定背景
在 AI 辅助编程时代,每次项目开始都需要重新定义规范,造成大量重复工作。本文档旨在提供一套**可复用的标准化开发规范**,适用于所有采用 Spring Boot + Vue 3 技术栈的企业级应用开发。
## 核心原则
1. **OpenAPI 规范驱动** - 前后端通过接口规范对齐,零沟通成本
2. **类型安全优先** - TypeScript 强制类型校验,早发现早修复
3. **约定大于配置** - 统一代码风格和目录结构,降低认知负担
4. **自动化优先** - 能自动化的绝不手动(代码生成、部署、测试)
5. **三层架构分离** - 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 结果
```java
// ❌ 错误示范:不要在 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 层使用实体类
```java
// ✅ 正确示范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. 直接传入 EntityMP 自动填充 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);
}
```
```xml
<!-- ✅ 正确示范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**
```java
// ✅ 正确示范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<T>
**规范:所有与 ORM 实体类相关的 Service 层接口都必须继承 MyBatis-Plus 的 `IService<T>` 接口**
```java
// ✅ 正确示范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);
}
```
```java
// ✅ 正确示范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 查询列表接口必须分页处理
**规范:所有返回列表的查询接口,默认都应该进行分页处理,避免数据量过大导致性能问题**
```java
// ❌ 错误示范:不分页返回所有数据
@GetMapping("/list")
public Result<List<User>> listUsers() {
List<User> users = userService.list(); // 返回所有用户,可能成千上万条
return Result.success(users);
}
```
```java
// ✅ 正确示范:分页返回数据
@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 提供的通用方法**
##### 场景一:单表条件查询
```java
// ✅ 正确示范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);
}
```
##### 场景二:单表分页查询
```java
// ✅ 正确示范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));
}
```
##### 场景三:条件统计
```java
// ✅ 正确示范:使用 count 方法统计数量
@Override
public Long countActiveUsers() {
return this.count(Wrappers.<User>lambdaQuery()
.eq(User::getStatus, CommonStatusEnum.ACTIVE.getCode()));
}
```
**QueryWrapper 使用规范:**
```java
// 基础用法
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 查询**
##### 场景一:多表联查
```java
// Mapper 层定义自定义查询方法
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 查询用户及其角色信息(多表联查)
*/
Page<UserWithRoleVO> selectPageWithRole(Page<UserWithRoleVO> page,
@Param("keyword") String keyword);
}
```
```xml
<!-- 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>
```
```java
// 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);
}
}
```
##### 场景二:聚合统计
```java
// Mapper 层
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 统计每月订单金额
*/
List<MonthlyStatsVO> selectMonthlyStats(@Param("year") Integer year);
}
```
```xml
<!-- 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>
```
##### 场景三:复杂条件查询
```java
// 当查询条件过于复杂,使用 QueryWrapper 难以表达时
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 高级搜索(多条件组合)
*/
Page<User> selectPageByAdvancedCondition(Page<User> page,
@Param("condition") UserAdvancedCondition condition);
}
```
```xml
<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 &lt;= #{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 层规范
```java
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 层规范
```java
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);
}
```
```java
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 层规范
```java
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
<?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` 或应用层组装实现
**原因:**
1. **性能考虑**外键约束会影响数据库写入性能尤其是大批量数据插入/删除时
2. **灵活性**应用层控制关联关系更灵活便于处理复杂的业务逻辑
3. **分库分表**外键约束会限制数据库的水平/垂直扩展能力
4. **维护性**外键约束删除数据时需要先删除子记录增加维护复杂度
**示例对比:**
```sql
-- ❌ 不推荐:使用外键约束
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='用户角色关联表';
```
**代码层控制关联关系:**
```java
// ✅ 推荐:在 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);
}
}
```
**关联查询示例:**
```java
// ✅ 推荐:使用 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中间表可以包含业务属性
中间表不仅仅是关联关系还可以包含业务属性
```java
@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. 实体类命名规范
```java
// ✅ 推荐:直接使用两个实体类名的组合
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. 主键策略
```java
// 推荐:使用 AUTO_INCREMENT数据库自增- 适用于所有表
@TableId(type = IdType.AUTO)
private Long id;
```
#### 6. 唯一索引约束
```sql
-- 防止重复关联(必须在数据库中创建唯一索引)
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. 完整示例
**表结构:**
```sql
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='家长学生关联表';
```
**实体类:**
```java
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. 基础实体类
```java
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. 业务实体类示例
```java
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 自动填充配置
```java
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. 权限模型设计
采用标准的 RBACRole-Based Access Control模型
```
用户 → 用户 - 角色关联 → 角色 → 角色 - 菜单关联 → 菜单/按钮权限
```
### 2. 权限相关表结构
```sql
-- 角色表
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. 权限注解与切面
```java
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;
}
```
```java
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. 日志打印语言规范(重要)
**核心原则:所有日志打印内容必须使用中文,包括业务日志、调试日志、异常日志等。**
**原因:**
1. **可读性**项目团队成员都是中文使用者中文日志更易于理解
2. **排查效率**中文日志能更准确地描述业务场景提高问题排查效率
3. **统一规范**保持整个项目的代码风格和文档一致性
**错误示例:**
```java
// ❌ 错误:使用英文日志
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);
```
**正确示例:**
```java
// ✅ 正确:使用中文日志
log.info("用户创建成功ID: {}", userId);
log.debug("查询用户ID: {}", userId);
log.error("创建用户失败", e);
log.warn("用户不存在ID: {}", userId);
```
**完整示例:**
```java
@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
<?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. 日志使用规范
```java
@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 配置类
```java
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 工具类
```java
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 配置类
```java
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 配置
```yaml
# 阿里云 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 工具类
```java
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` | 静态方法调用 |
#### 错误示例
```java
// ❌ 错误 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);
}
}
```
#### 正确示例
**场景一:多处调用的工具函数 → 统一工具类**
```java
// ✅ 正确:统一工具类
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 层内部方法**
```java
// ✅ 正确:仅在本 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");
}
}
}
```
**场景三:通用工具 → 独立工具类**
```java
// ✅ 正确:独立工具类,按功能分类
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` |
#### 工具类设计原则
1. **私有构造**防止实例化
```java
private CommonUtil() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
```
2. **静态方法**所有方法都是 `static`
3. **无状态**工具类不应持有状态
4. **线程安全**工具方法必须是线程安全的
5. **充分注释**每个方法都要有 JavaDoc 注释说明用途参数返回值
#### 代码审查要点
- [ ] 工具函数是否抽取到工具类或 Service 内部
- [ ] 是否存在 Controller 层直接编写工具方法的情况
- [ ] 多处使用的工具是否统一到工具类中
- [ ] 工具类是否有私有构造防止实例化
- [ ] 工具方法是否为静态方法
- [ ] 工具类是否无状态不保存成员变量
- [ ] 工具方法是否有完整的 JavaDoc 注释
---
## 九、Hutool 工具类使用规范
### 1. Maven 依赖
```xml
<!-- 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. 常用工具方法
```java
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 配置类
```java
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 依赖
```xml
<!-- 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. 响应类定义
```java
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. 错误码枚举
```java
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. 全局异常处理器
```java
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)
```yaml
# =================== 主配置文件 ===================
# 所有环境共用的配置,环境特定配置在各环境文件中覆盖
spring:
application:
name: ${APP_NAME:project}
# 激活的环境配置(通过命令行参数或环境变量切换)
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
server:
port: ${SERVER_PORT:8080}
```
### 3. 开发环境配置 (application-dev.yml)
```yaml
# =================== 开发环境配置 ===================
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)
```yaml
# =================== 测试环境配置 ===================
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)
```yaml
# =================== 生产环境配置 ===================
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. 环境切换方式
#### 方式一:环境变量(推荐)
```bash
# Linux/Mac
export SPRING_PROFILES_ACTIVE=prod
java -jar project.jar
# Windows
set SPRING_PROFILES_ACTIVE=prod
java -jar project.jar
```
#### 方式二:命令行参数
```bash
java -jar project.jar --spring.profiles.active=prod
```
#### 方式三Docker 环境变量
```yaml
# docker-compose.yml
services:
backend:
environment:
- SPRING_PROFILES_ACTIVE=prod
```
### 7. 环境配置说明
| 配置项 | 开发环境 (dev) | 测试环境 (test) | 生产环境 (prod) |
|--------|---------------|---------------|---------------|
| 数据库 | 本地 MySQL | 测试服务器 | 生产服务器 |
| SQL 日志 | 开启 | 开启 | 关闭 |
| Swagger | 开启 | 开启 | 关闭 |
| Flyway Clean | 允许 | 禁止 | 禁止 |
| JWT 密钥 | 默认值 | 默认值 | 必须环境变量 |
| Redis 连接池 | 默认 | 默认 | 优化配置 |
---
## 十三、Docker Compose 本地开发
```yaml
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}` - 用户 Token
- `user:info:{userId}` - 用户信息缓存
- `dict:{type}` - 数据字典
- `lock:{resource}:{id}` - 分布式锁
- `rate_limit:{key}` - 限流计数器
---
## 限流规范
### 技术选型
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|---------|
| **Redis 滑动窗口** | 精确控制支持分布式 | 依赖 Redis | **推荐:全局限流** |
| 自定义注解 + AOP | 灵活可针对特定接口 | 需要手动添加注解 | **推荐:特殊接口限流** |
| 令牌桶算法 | 平滑限流允许突发 | 实现复杂 | 特殊场景 |
| 计数器算法 | 简单性能好 | 临界问题 | 不推荐 |
### 推荐方案:配置化 + 注解 fallback
采用 **配置化全局限流** 作为基础**注解限流** 作为特殊场景的补充
#### 方案一:配置化限流(推荐作为基础)
`application.yml` 中配置全局限流规则
```yaml
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` 注解进行限流
```java
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;
}
```
**使用示例:**
```java
@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) {
...
}
}
```
### 限流优先级
**注解限流 > 配置化限流 > 默认限流**
1. 如果接口上有 `@RateLimit` 注解优先使用注解配置
2. 如果没有注解查找配置文件中匹配的规则
3. 如果都没有使用默认配置`default-max-requests`
### Redis 限流实现(滑动窗口算法)
```java
/**
* 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 | 防止服务器过载 |
### 注意事项
1. **Redis 依赖**限流功能依赖 Redis需确保 Redis 服务可用
2. **异常情况放行** Redis 不可用时建议放行请求避免影响正常业务
3. **集群部署**滑动窗口算法天然支持分布式无需额外配置
4. **监控告警**建议添加限流触发的监控告警及时发现异常流量
---
## 十三、中间表设计规范(逻辑删除与唯一索引)
### 核心原则
**中间表(关联表)不使用逻辑删除,使用物理删除,不设置联合唯一索引。**
### 表类型分类
| 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 |
|--------|------|----------|----------|--------|
| **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. 实体类写法
```java
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 层写法
```java
@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. 数据库表设计
```sql
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='课程套餐 - 课程包关联表';
```
### 数据一致性保障
#### 应用层校验
```java
// 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 类)→ 逻辑删除
```
### 总结
| 维度 | 传统做法 | 推荐做法单体应用 |
|------|---------|---------------------|
| 中间表删除策略 | 逻辑删除 | **物理删除** |
| 中间表唯一索引 | 必须设置 | **不设置**应用层校验 |
| 数据一致性 | 数据库兜底 | 应用层校验 + 事务 |
| 适用场景 | 高并发分布式 | 单体应用低并发 |