2026-03-12 13:05:20 +08:00
|
|
|
|
# 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. 直接传入 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```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 <= #{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 (
|
2026-03-19 10:51:04 +08:00
|
|
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
|
2026-03-12 13:05:20 +08:00
|
|
|
|
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)
|
2026-03-19 10:51:04 +08:00
|
|
|
|
@TableId(type = IdType.AUTO)
|
|
|
|
|
|
private Long id;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
@Schema(description = "班级 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
2026-03-19 10:51:04 +08:00
|
|
|
|
private Long classId;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
@Schema(description = "教师 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
2026-03-19 10:51:04 +08:00
|
|
|
|
private Long teacherId;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
// === 业务属性字段 ===
|
|
|
|
|
|
@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
|
2026-03-19 10:51:04 +08:00
|
|
|
|
// 推荐:使用 AUTO_INCREMENT(数据库自增)- 适用于所有表
|
|
|
|
|
|
@TableId(type = IdType.AUTO)
|
|
|
|
|
|
private Long id;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 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 (
|
2026-03-19 10:51:04 +08:00
|
|
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
|
|
|
|
|
|
parent_id BIGINT NOT NULL COMMENT '家长 ID',
|
|
|
|
|
|
student_id BIGINT NOT NULL COMMENT '学生 ID',
|
2026-03-12 13:05:20 +08:00
|
|
|
|
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)
|
2026-03-19 10:51:04 +08:00
|
|
|
|
@TableId(type = IdType.AUTO)
|
|
|
|
|
|
private Long id;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
@Schema(description = "家长 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
2026-03-19 10:51:04 +08:00
|
|
|
|
private Long parentId;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
@Schema(description = "学生 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
2026-03-19 10:51:04 +08:00
|
|
|
|
private Long studentId;
|
2026-03-12 13:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
@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;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 基础实体类
|
|
|
|
|
|
*
|
2026-03-19 10:51:04 +08:00
|
|
|
|
* <p>包含所有实体共有的字段:ID(数据库自增)、审计字段、逻辑删除。</p>
|
2026-03-12 13:05:20 +08:00
|
|
|
|
* <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;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-19 10:51:04 +08:00
|
|
|
|
* 主键 ID(数据库自增)
|
2026-03-12 13:05:20 +08:00
|
|
|
|
*/
|
|
|
|
|
|
@Schema(description = "主键 ID")
|
2026-03-19 10:51:04 +08:00
|
|
|
|
@TableId(type = IdType.AUTO)
|
2026-03-12 13:05:20 +08:00
|
|
|
|
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. 权限模型设计
|
|
|
|
|
|
|
|
|
|
|
|
采用标准的 RBAC(Role-Based Access Control)模型:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
用户 → 用户 - 角色关联 → 角色 → 角色 - 菜单关联 → 菜单/按钮权限
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2. 权限相关表结构
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 角色表
|
|
|
|
|
|
CREATE TABLE t_auth_role (
|
2026-03-19 10:51:04 +08:00
|
|
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
|
2026-03-12 13:05:20 +08:00
|
|
|
|
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 (
|
2026-03-19 10:51:04 +08:00
|
|
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
|
2026-03-12 13:05:20 +08:00
|
|
|
|
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 (
|
2026-03-19 10:51:04 +08:00
|
|
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)',
|
2026-03-12 13:05:20 +08:00
|
|
|
|
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. **监控告警**:建议添加限流触发的监控告警,及时发现异常流量
|
2026-03-19 16:38:51 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 十三、中间表设计规范(逻辑删除与唯一索引)
|
|
|
|
|
|
|
|
|
|
|
|
### 核心原则
|
|
|
|
|
|
|
|
|
|
|
|
**中间表(关联表)不使用逻辑删除,使用物理删除,不设置联合唯一索引。**
|
|
|
|
|
|
|
|
|
|
|
|
### 表类型分类
|
|
|
|
|
|
|
|
|
|
|
|
| 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 |
|
|
|
|
|
|
|--------|------|----------|----------|--------|
|
|
|
|
|
|
| **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 类)→ 逻辑删除
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 总结
|
|
|
|
|
|
|
|
|
|
|
|
| 维度 | 传统做法 | 推荐做法(单体应用) |
|
|
|
|
|
|
|------|---------|---------------------|
|
|
|
|
|
|
| 中间表删除策略 | 逻辑删除 | **物理删除** |
|
|
|
|
|
|
| 中间表唯一索引 | 必须设置 | **不设置**(应用层校验) |
|
|
|
|
|
|
| 数据一致性 | 数据库兜底 | 应用层校验 + 事务 |
|
|
|
|
|
|
| 适用场景 | 高并发、分布式 | 单体应用、低并发 |
|