# 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 │ └─────────────────────────────────────────────────────────┘ ↓ 使用 DTO/Entity ┌─────────────────────────────────────────────────────────┐ │ Service 层(业务) │ │ • 接收 Controller 传入的 DTO 或参数 │ │ • 转换为 Entity(用于创建/更新场景) │ │ • 调用 Mapper 层(传入/返回 Entity) │ │ • 处理业务逻辑、事务控制 │ │ • 返回 Entity 或 Entity 列表(给 Controller 转换) │ └─────────────────────────────────────────────────────────┘ ↓ 只使用 Entity ┌─────────────────────────────────────────────────────────┐ │ Mapper 层(数据访问) │ │ • 继承 BaseMapper │ │ • 接收/返回 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 { // 错误:返回自定义 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 listUsersByCondition(UserQueryRequest request) { // 使用 LambdaQueryWrapper 构建条件,返回 Entity 列表 LambdaQueryWrapper 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 } } // ✅ 正确示范:Mapper 层只操作 Entity @Mapper public interface UserMapper extends BaseMapper { /** * 根据用户名检查用户是否存在 * 返回基础类型,不返回 Entity */ @Select("SELECT COUNT(*) FROM t_user WHERE username = #{username}") boolean existsByUsername(@Param("username") String username); /** * 分页查询用户(带关键词搜索) * 返回 Entity 分页对象,不是 DTO 分页 */ Page selectPageByKeyword(Page page, @Param("keyword") String keyword); /** * 根据角色查询用户列表 * 返回 Entity 列表,不是 DTO 列表 */ List selectByRoleId(@Param("roleId") Long roleId); } ``` ```xml ``` #### 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 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 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> listUsers( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { // Service 返回 Entity 分页 Page entityPage = userService.pageUsers(page, size, keyword); // Controller 转换为 VO 分页 List voList = entityPage.getRecords() .stream() .map(this::convertToVO) .collect(Collectors.toList()); PageResult result = new PageResult<>(); result.setItems(voList); result.setTotal(entityPage.getTotal()); result.setPage(entityPage.getCurrent()); result.setPageSize(entityPage.getSize()); return Result.success(result); } private UserInfoVO convertToVO(User user) { return UserInfoVO.builder() .id(user.getId()) .username(user.getUsername()) .email(user.getEmail()) .phone(user.getPhone()) .avatar(user.getAvatar()) .status(user.getStatus()) .createTime(user.getCreateTime()) .build(); } } ``` #### 总结:各层数据使用规范 | 层级 | 接收数据 | 返回数据 | 说明 | |------|---------|---------|------| | **Controller** | DTO/Request | VO/Response | 负责 HTTP ↔ 业务层转换 | | **Service** | DTO/Entity | Entity | 业务逻辑,不调用 convert | | **Mapper** | Entity/条件 | Entity | 数据访问,只操作 Entity | **黄金法则:** - Service ↔ Mapper 之间:**只用 Entity** - Controller ↔ Service 之间:**可以 DTO/Entity 混用** - Controller ↔ HTTP 之间:**DTO 进,VO 出** --- ### 7. Service 层继承与查询方式规范 #### 7.1 Service 层必须继承 IService **规范:所有与 ORM 实体类相关的 Service 层接口都必须继承 MyBatis-Plus 的 `IService` 接口** ```java // ✅ 正确示范:Service 接口继承 IService public interface UserService extends IService { /** * 创建用户 */ User createUser(UserCreateRequest request); /** * 根据 ID 查询用户 */ User getUserById(Long userId); /** * 分页查询用户 */ Page pageUsers(Integer page, Integer size, String keyword); } ``` ```java // ✅ 正确示范:Service 实现类继承 ServiceImpl 并实现 UserService @Service @Slf4j @RequiredArgsConstructor public class UserServiceImpl extends ServiceImpl 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> listUsers() { List users = userService.list(); // 返回所有用户,可能成千上万条 return Result.success(users); } ``` ```java // ✅ 正确示范:分页返回数据 @GetMapping("/page") public Result> pageUsers( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { // 使用 MP 的分页方法 Page userPage = userService.page( new Page<>(page, size), Wrappers.lambdaQuery() .like(StringUtils.hasText(keyword), User::getUsername, keyword) ); // 转换为 VO 分页结果 PageResult 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 listActiveUsers() { // 使用 lambda 表达式构建类型安全的查询条件 LambdaQueryWrapper 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 wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, username); // 使用继承的 getOne 方法 return this.getOne(wrapper); } ``` ##### 场景二:单表分页查询 ```java // ✅ 正确示范:Controller 层直接使用 IService 的 page 方法 @GetMapping("/page") public Result> 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 userPage = this.page( new Page<>(page, size), Wrappers.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.lambdaQuery() .eq(User::getStatus, CommonStatusEnum.ACTIVE.getCode())); } ``` **QueryWrapper 使用规范:** ```java // 基础用法 LambdaQueryWrapper 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 { /** * 查询用户及其角色信息(多表联查) */ Page selectPageWithRole(Page page, @Param("keyword") String keyword); } ``` ```xml ``` ```java // Service 层调用自定义查询 @Service public class UserServiceImpl extends ServiceImpl implements UserService { @Override public Page pageUsersWithRole(Integer page, Integer size, String keyword) { // 复杂查询使用自定义 SQL return baseMapper.selectPageWithRole(new Page<>(page, size), keyword); } } ``` ##### 场景二:聚合统计 ```java // Mapper 层 @Mapper public interface OrderMapper extends BaseMapper { /** * 统计每月订单金额 */ List selectMonthlyStats(@Param("year") Integer year); } ``` ```xml ``` ##### 场景三:复杂条件查询 ```java // 当查询条件过于复杂,使用 QueryWrapper 难以表达时 @Mapper public interface UserMapper extends BaseMapper { /** * 高级搜索(多条件组合) */ Page selectPageByAdvancedCondition(Page page, @Param("condition") UserAdvancedCondition condition); } ``` ```xml ``` --- #### 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 │ │ • 复杂查询使用 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; /** * 创建新用户 * *

仅管理员可调用,创建后自动发送激活邮件。

* * @param request 用户创建请求,包含用户名、邮箱、角色等信息 * @return 创建成功的用户信息 */ @Operation(summary = "创建用户", description = "创建新用户并返回用户信息,需要管理员权限") @RequireRole("ADMIN") @PostMapping public Result 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 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; /** * 用户服务接口 * *

定义用户相关的业务操作方法。

* * @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 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; /** * 用户服务实现类 * *

实现用户相关的业务逻辑,包括用户创建、查询、更新、删除等操作。

*

所有写操作都需要事务控制。

* * @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; /** * 用户数据访问接口 * *

继承 MyBatis-Plus 的 BaseMapper,获得基础 CRUD 能力。

*

复杂查询通过 XML 或注解方式实现。

* * @author developer * @since 2024-01-01 */ @Mapper public interface UserMapper extends BaseMapper { /** * 根据用户名检查用户是否存在 * * @param username 用户名 * @return 是否存在 */ @Select("SELECT COUNT(*) FROM t_user WHERE username = #{username}") boolean existsByUsername(@Param("username") String username); /** * 分页查询用户(带关键词搜索) * * @param page 分页对象 * @param keyword 关键词(匹配用户名或邮箱) * @return 分页结果 */ Page selectPageByKeyword(Page page, @Param("keyword") String keyword); } ``` ```xml ``` --- ## 四、ORM 实体类规范 ### 1. 表名命名规范 所有数据库表必须添加业务模块前缀,格式为 `t_{模块}_{表名}`: | 模块分类 | 前缀 | 示例 | |---------|------|------| | 用户模块 | t_user_ | t_user, t_user_role, t_user_menu | | 系统模块 | t_sys_ | t_sys_config, t_sys_log, t_sys_dict | | 业务模块 | t_biz_ | t_biz_order, t_biz_product | | 权限模块 | t_auth_ | t_auth_role, t_auth_menu, t_auth_perm | ### 2. 表名与实体类名对应规范 **表名与实体类名必须保持一致**,仅允许下划线与驼峰的区别: | 表名(下划线命名) | 实体类名(驼峰命名) | 正确/错误 | |------------------|-------------------|----------| | `t_auth_role` | `AuthRole` | ✅ 正确 | | `t_auth_menu` | `AuthMenu` | ✅ 正确 | | `t_user_role` | `UserRole` | ✅ 正确 | | `t_auth_role` | `Role` | ❌ 错误(缺少模块前缀) | | `t_auth_menu` | `Menu` | ❌ 错误(缺少模块前缀) | | `t_biz_order` | `BizOrder` | ✅ 正确 | | `t_biz_order` | `OrderInfo` | ❌ 错误(名称不一致) | **规范说明:** - 表名去掉前缀 `t_` 后,剩余部分与实体类名对应 - 表名下划线转实体类大驼峰(如 `auth_role` → `AuthRole`) - 禁止表名和实体类名不一致的情况(如 `t_auth_role` 对应 `RoleEntity`) ### 3. 实体类分类规范 实体类根据业务模块添加前缀,保持与服务层一致: ``` entity/ ├── BaseEntity.java # 基础实体类(审计字段) ├── user/ │ ├── User.java # 用户实体 → t_user_user │ └── UserRole.java # 用户角色关联实体 → t_user_user_role ├── sys/ │ ├── SysConfig.java # 系统配置实体 → t_sys_sys_config │ └── SysLog.java # 系统日志实体 → t_sys_sys_log └── auth/ ├── AuthRole.java # 角色实体 → t_auth_auth_role └── AuthMenu.java # 菜单实体 → t_auth_auth_menu ``` **注意:** 为避免表名冗长,模块内实体可省略模块前缀,示例: ``` entity/ ├── user/ │ ├── User.java # 用户实体 → t_user (而非 t_user_user) │ └── Role.java # 角色实体 → t_user_role ├── sys/ │ ├── Config.java # 系统配置实体 → t_sys_config │ └── Log.java # 系统日志实体 → t_sys_log └── auth/ ├── Role.java # 角色实体 → t_auth_role └── Menu.java # 菜单实体 → t_auth_menu ``` **推荐方式:** 使用简短的表名(省略模块内重复前缀),实体类名保持简洁: | 推荐表名 | 推荐实体类名 | 不推荐的表名 | 不推荐的实体类名 | |---------|------------|-------------|---------------| | `t_user` | `User` | `t_user_user` | `User` | | `t_auth_role` | `AuthRole` | `t_auth_auth_role` | `AuthRole` | | `t_sys_config` | `SysConfig` | `t_sys_sys_config` | `SysConfig` | ### 4. 不使用外键约束规范 **规范说明:** - 数据库表之间**不使用 FOREIGN KEY 外键约束** - 表与表之间的关联关系通过**代码逻辑控制** - 关联查询通过 `JOIN` 或应用层组装实现 **原因:** 1. **性能考虑**:外键约束会影响数据库写入性能,尤其是大批量数据插入/删除时 2. **灵活性**:应用层控制关联关系更灵活,便于处理复杂的业务逻辑 3. **分库分表**:外键约束会限制数据库的水平/垂直扩展能力 4. **维护性**:外键约束删除数据时需要先删除子记录,增加维护复杂度 **示例对比:** ```sql -- ❌ 不推荐:使用外键约束 CREATE TABLE t_user_role ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES t_user(id), CONSTRAINT fk_role FOREIGN KEY (role_id) REFERENCES t_auth_role(id) ); -- ✅ 推荐:不使用外键约束 CREATE TABLE t_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)', user_id BIGINT NOT NULL COMMENT '用户 ID', role_id BIGINT NOT NULL COMMENT '角色 ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE KEY uk_user_role (user_id, role_id) ) COMMENT='用户角色关联表'; ``` **代码层控制关联关系:** ```java // ✅ 推荐:在 Service 层控制关联关系 @Service @RequiredArgsConstructor public class UserRoleService { private final UserMapper userMapper; private final UserRoleMapper userRoleMapper; @Transactional(rollbackFor = Exception.class) public void deleteUser(Long userId) { // 1. 先删除关联关系 userRoleMapper.delete(new LambdaQueryWrapper() .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 { /** * 查询用户及其角色信息 */ @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 roles = authRoleMapper.selectByUserId(userId); // 3. 组装 VO return UserWithRolesVO.builder() .user(user) .roles(roles) .build(); } ``` ### 3.5 多对多关系中间表命名规范 **核心原则:中间表名必须清晰表达两个关联实体的关系,避免歧义。** #### 1. 标准命名格式 ``` t_{实体 A}_{实体 B} ``` - 按**字母顺序**或**业务逻辑顺序**排列 - 不使用 `relation`、`rel`、`link`、`mapping` 等冗余词 - 不使用 `map`、`assoc`、`junction` 等缩写 - 实体类名直接使用两个实体名的组合(如 `ParentStudent`) #### 2. 项目中的实际案例 | 表名 | 实体类 | 说明 | 关联关系 | |------|--------|------|---------| | `t_parent_student` | `ParentStudent` | 家长 - 学生关联 | 一个家长可关联多个学生 | | `t_class_teacher` | `ClassTeacher` | 班级 - 教师关联 | 一个班级有多个教师,一个教师可带多个班级 | | `t_course_package_course` | `CoursePackageCourse` | 课程包 - 课程关联 | 一个课程包包含多门课程 | | `t_product_bundle_course_package` | `ProductBundleCoursePackage` | 套餐 - 课程包关联 | 一个套餐包含多个课程包 | | `t_tenant_product_bundle` | `TenantProductBundle` | 租户 - 套餐关联 | 一个租户可订购多个套餐 | #### 3. 避免歧义的建议 ##### 建议 1:当表名本身是复合词时,使用完整名称 ``` ✅ 正确:t_course_package_course(课程包 - 课程) ❌ 错误:t_package_course(歧义:可能是包裹 - 课程) ``` ##### 建议 2:当两个表名组合后可能产生歧义时,添加业务前缀 ``` 场景:order_item 可能是订单 - 条目,也可能是订单项(单个实体) ✅ 方案一:t_order_order_item(明确表示关联表) ✅ 方案二:t_order_product_item(用 product 替代 item) ``` ##### 建议 3:一对多关系 vs 多对多关系的区分 ``` 一对多关系(外键在"多"方): - 不需要中间表,直接在从表添加外键字段 - 示例:一个班级有多个学生 → 在 t_student 表添加 class_id 字段 多对多关系(需要中间表): - 必须使用中间表 - 中间表命名:t_表 A_表 B - 示例:一个家长有多个学生,一个学生有多个家长 → t_parent_student ``` ##### 建议 4:中间表可以包含业务属性 中间表不仅仅是关联关系,还可以包含业务属性: ```java @Data @TableName("t_class_teacher") @Schema(description = "班级教师关联") public class ClassTeacher { @Schema(description = "ID", accessMode = Schema.AccessMode.READ_ONLY) @TableId(type = IdType.AUTO) private Long id; @Schema(description = "班级 ID", requiredMode = Schema.RequiredMode.REQUIRED) private Long classId; @Schema(description = "教师 ID", requiredMode = Schema.RequiredMode.REQUIRED) private Long teacherId; // === 业务属性字段 === @Schema(description = "角色", example = "teacher", requiredMode = Schema.RequiredMode.REQUIRED) private String role; // 主教/助教 @Schema(description = "是否主教", example = "true") private Boolean isPrimary; @Schema(description = "排序", example = "1") private Integer sortOrder; } ``` #### 4. 实体类命名规范 ```java // ✅ 推荐:直接使用两个实体类名的组合 public class ParentStudent { } // 对应 t_parent_student public class ClassTeacher { } // 对应 t_class_teacher // ❌ 避免:使用模糊的后缀 public class ParentStudentRelation { } // 冗余 public class ParentStudentMap { } // 易与 Map 数据结构混淆 public class ParentStudentLink { } // 冗余 public class ParentStudentMapping { } // 冗余 ``` #### 5. 主键策略 ```java // 推荐:使用 AUTO_INCREMENT(数据库自增)- 适用于所有表 @TableId(type = IdType.AUTO) private Long id; ``` #### 6. 唯一索引约束 ```sql -- 防止重复关联(必须在数据库中创建唯一索引) CREATE UNIQUE INDEX uk_parent_student ON t_parent_student(parent_id, student_id); CREATE UNIQUE INDEX uk_class_teacher ON t_class_teacher(class_id, teacher_id); ``` #### 7. 命名决策树 ``` 开始设计关联关系 │ ▼ ┌─────────────────────┐ │ 是几对几关系? │ └─────────────────────┘ │ │ 一对多 多对多 │ │ ▼ ▼ ┌──────────────┐ ┌──────────────────┐ │ 在"多"方表 │ │ 创建中间表 │ │ 添加外键 │ │ t_表 A_表 B │ │ class_id │ │ + 业务属性字段 │ └──────────────┘ └──────────────────┘ │ ▼ ┌──────────────────┐ │ 表名是否复合词? │ └──────────────────┘ │ │ 是 否 │ │ ▼ ▼ ┌──────────┐ ┌──────────┐ │ 使用完整 │ │ 直接组合 │ │ 名称 │ │ 表名 │ └──────────┘ └──────────┘ ``` #### 8. 完整示例 **表结构:** ```sql CREATE TABLE t_parent_student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)', parent_id BIGINT NOT NULL COMMENT '家长 ID', student_id BIGINT NOT NULL COMMENT '学生 ID', relationship VARCHAR(20) COMMENT '关系:father/mother/other', is_primary TINYINT DEFAULT 1 COMMENT '是否主要联系人', created_by VARCHAR(50) COMMENT '创建人', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_by VARCHAR(50) COMMENT '更新人', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除', UNIQUE INDEX uk_parent_student (parent_id, student_id) COMMENT '防止重复关联', INDEX idx_parent (parent_id) COMMENT '按家长查询', INDEX idx_student (student_id) COMMENT '按学生查询' ) COMMENT='家长学生关联表'; ``` **实体类:** ```java package com.reading.platform.entity; import com.baomidou.mybatisplus.annotation.*; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; /** * 家长 - 学生关联实体 * *

用于实现家长与学生的多对多关系:一个家长可关联多个学生,一个学生也可被多个家长关联。

*

中间表可包含业务属性:关系(父子/母子)、是否主要联系人等。

*/ @Data @TableName("t_parent_student") @Schema(description = "家长学生关联") public class ParentStudent { @Schema(description = "ID", accessMode = Schema.AccessMode.READ_ONLY) @TableId(type = IdType.AUTO) private Long id; @Schema(description = "家长 ID", requiredMode = Schema.RequiredMode.REQUIRED) private Long parentId; @Schema(description = "学生 ID", requiredMode = Schema.RequiredMode.REQUIRED) private Long studentId; @Schema(description = "关系", example = "mother", allowableValues = {"father", "mother", "other"}) private String relationship; @Schema(description = "是否主要联系人", example = "1") private Integer isPrimary; @Schema(description = "创建人用户名", accessMode = Schema.AccessMode.READ_ONLY) @TableField(fill = FieldFill.INSERT) private String createdBy; @Schema(description = "创建时间", accessMode = Schema.AccessMode.READ_ONLY) @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @Schema(description = "是否删除 0-未删除 1-已删除", accessMode = Schema.AccessMode.READ_ONLY) @TableLogic private Integer deleted; } ``` ### 4. 基础实体类 ```java package com.company.project.entity; import com.baomidou.mybatisplus.annotation.*; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.experimental.SuperBuilder; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.LastModifiedBy; import java.io.Serializable; import java.time.LocalDateTime; /** * 基础实体类 * *

包含所有实体共有的字段:ID(数据库自增)、审计字段、逻辑删除。

*

所有实体类应继承此基类。

* * @author developer * @since 2024-01-01 */ @Data @SuperBuilder @Schema(hidden = true) public abstract class BaseEntity implements Serializable { private static final long serialVersionUID = 1L; /** * 主键 ID(数据库自增) */ @Schema(description = "主键 ID") @TableId(type = IdType.AUTO) private Long id; /** * 创建人(username) */ @Schema(description = "创建人") @CreatedBy @TableField(fill = FieldFill.INSERT) private String createBy; /** * 创建时间 */ @Schema(description = "创建时间") @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; /** * 更新人(username) */ @Schema(description = "更新人") @LastModifiedBy @TableField(fill = FieldFill.INSERT_UPDATE) private String updateBy; /** * 更新时间 */ @Schema(description = "更新时间") @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; /** * 逻辑删除标识(0-未删除,1-已删除) */ @Schema(description = "删除标识") @TableLogic @JsonIgnore private Integer deleted; } ``` ### 4. 业务实体类示例 ```java package com.company.project.entity.user; import com.baomidou.mybatisplus.annotation.TableName; import com.company.project.entity.BaseEntity; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.SuperBuilder; /** * 用户实体类 * *

对应数据库表:t_user

*

存储系统用户的基本信息,包括登录账号、联系方式、状态等。

* * @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 配置类 * *

配置分页插件和自动填充处理器。

* * @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 ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)', role_code VARCHAR(50) NOT NULL COMMENT '角色编码', role_name VARCHAR(100) NOT NULL COMMENT '角色名称', sort_order INT DEFAULT 0 COMMENT '排序', status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-正常', create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人(username)', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人(username)', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '删除标识', UNIQUE KEY uk_role_code (role_code) ) COMMENT='角色表'; -- 菜单权限表 CREATE TABLE t_auth_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)', parent_id BIGINT DEFAULT 0 COMMENT '父菜单 ID', menu_type VARCHAR(20) NOT NULL COMMENT '菜单类型:menu-菜单,button-按钮', menu_name VARCHAR(100) NOT NULL COMMENT '菜单名称', menu_path VARCHAR(200) COMMENT '菜单路径', permission VARCHAR(100) COMMENT '权限标识', icon VARCHAR(100) COMMENT '菜单图标', sort_order INT DEFAULT 0 COMMENT '排序', status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-正常', create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人(username)', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人(username)', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '删除标识' ) COMMENT='菜单权限表'; -- 用户角色关联表 CREATE TABLE t_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID(数据库自增)', user_id BIGINT NOT NULL COMMENT '用户 ID', role_id BIGINT NOT NULL COMMENT '角色 ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE KEY uk_user_role (user_id, role_id) ) COMMENT='用户角色关联表'; -- 角色菜单关联表 CREATE TABLE t_role_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', role_id BIGINT NOT NULL COMMENT '角色 ID', menu_id BIGINT NOT NULL COMMENT '菜单 ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE KEY uk_role_menu (role_id, menu_id) ) COMMENT='角色菜单关联表'; ``` ### 3. 权限注解与切面 ```java package com.company.project.common.annotation; import java.lang.annotation.*; /** * RBAC 权限注解 * *

用于标记需要权限控制的方法。

*

支持角色权限和按钮权限两种校验方式。

* * @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 权限切面 * *

拦截带有 RequireRole 注解的方法和类,进行权限校验。

* * @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 ${LOG_PATTERN} UTF-8 ${LOG_PATH}/${APP_NAME}-info.log ${LOG_PATH}/${APP_NAME}-info.%d{yyyy-MM-dd}.log 30 ${LOG_PATTERN} UTF-8 INFO ACCEPT DENY ${LOG_PATH}/${APP_NAME}-error.log ${LOG_PATH}/${APP_NAME}-error.%d{yyyy-MM-dd}.log 90 ERROR ACCEPT DENY ``` ### 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> converters) { FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter(); converter.setDefaultCharset(StandardCharsets.UTF_8); List 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 parseObject(String json, Class clazz) { return JSON.parseObject(json, clazz); } public static List parseArray(String json, Class 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 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; /** * 通用工具类 * *

集中管理项目中常用的工具方法,避免代码重复。

*/ @Slf4j public class CommonUtil { private CommonUtil() { // 私有构造,防止实例化 } /** * 生成订单号 * *

格式:ORD + 年月日时分秒 + 4 位随机数

* * @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 implements LessonService { private final StudentRecordMapper studentRecordMapper; @Override @Transactional(rollbackFor = Exception.class) public void finishLesson(Long lessonId, List 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); } /** * 保存记录前处理数据 * *

此方法仅在本类中使用,不需要抽取到工具类

*/ 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 cn.hutool hutool-all 5.8.23 com.alibaba.fastjson2 fastjson2 2.0.42 ``` ### 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 com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.4.0 ``` ### 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 { @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 Result success(T data) { return Result.builder() .code(200).message("success").data(data) .timestamp(System.currentTimeMillis()) .build(); } public static Result error(Integer code, String message) { return Result.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 handleBusinessException(BusinessException e) { log.warn("业务异常:{}", e.getMessage()); return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result 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 login(@RequestBody LoginRequest request) { ... } /** * 发送短信验证码 - 严格限流 * 60 秒内最多 3 次请求 */ @PostMapping("/captcha") @RateLimit(time = 60, maxRequests = 3, message = "操作过于频繁,请稍后再试") public Result sendCaptcha(@RequestParam String phone) { ... } } ``` ### 限流优先级 **注解限流 > 配置化限流 > 默认限流** 1. 如果接口上有 `@RateLimit` 注解,优先使用注解配置 2. 如果没有注解,查找配置文件中匹配的规则 3. 如果都没有,使用默认配置(`default-max-requests`) ### Redis 限流实现(滑动窗口算法) ```java /** * Redis 限流工具类 */ @Component public class RateLimiter { private final RedisTemplate redisTemplate; public boolean tryAcquire(String key, long maxRequests, long timeWindow, TimeUnit timeUnit) { long currentTime = System.currentTimeMillis(); long windowStart = currentTime - timeUnit.toMillis(timeWindow); String redisKey = "rate_limit:" + key; // 使用 Redis 事务保证原子性 Boolean result = redisTemplate.execute(connection -> { // 移除窗口外的请求记录 connection.zSetCommands().zRemRangeByScore( redisKey.getBytes(), 0, windowStart ); // 获取当前窗口内的请求数 Long count = connection.zSetCommands().zCard(redisKey.getBytes()); if (count != null && count >= maxRequests) { return false; // 超过限流阈值 } // 添加当前请求记录 connection.zSetCommands().zAdd( redisKey.getBytes(), currentTime, String.valueOf(currentTime).getBytes() ); // 设置过期时间 connection.expire(redisKey.getBytes(), timeWindow); return true; }, false); return Boolean.TRUE.equals(result); } } ``` ### 常见限流场景建议值 | 接口类型 | 时间窗口 | 最大请求数 | 说明 | |---------|---------|-----------|------| | 登录接口 | 60 秒 | 10 | 防止暴力破解 | | 验证码/短信 | 60 秒 | 3-5 | 防止短信轰炸 | | 文件上传 | 60 秒 | 30 | 防止资源滥用 | | 普通业务接口 | 60 秒 | 100-1000 | 根据业务调整 | | 导出接口 | 60 秒 | 5-10 | 防止服务器过载 | ### 注意事项 1. **Redis 依赖**:限流功能依赖 Redis,需确保 Redis 服务可用 2. **异常情况放行**:当 Redis 不可用时,建议放行请求,避免影响正常业务 3. **集群部署**:滑动窗口算法天然支持分布式,无需额外配置 4. **监控告警**:建议添加限流触发的监控告警,及时发现异常流量 --- ## 十三、中间表设计规范(逻辑删除与唯一索引) ### 核心原则 **中间表(关联表)不使用逻辑删除,使用物理删除,不设置联合唯一索引。** ### 表类型分类 | 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 | |--------|------|----------|----------|--------| | **A 类:纯关联表(中间表)** | 只表示 A 和 B 的关系,无额外状态,先删后增的更新模式 | **物理删除**(不设 deleted) | **不设置**联合唯一索引 | `course_collection_package`、`tenant_course`、`class_teacher` | | **B 类:关系表** | 关系相对稳定,有业务历史价值 | 逻辑删除 | 建议设置联合唯一索引 | `parent_student` | | **C 类:配置表** | (key, value) 结构,天然应该唯一 | 逻辑删除 | **设置**联合唯一索引 | `system_setting` | | **D 类:合同/授权表** | 有合同性质,需要保留历史 | 逻辑删除 | 不设置联合唯一索引 | `tenant_package` | ### A 类表(纯关联表)设计规范 #### 1. 为什么不使用逻辑删除? **原因**: - 中间表无业务历史价值,删除后不需要保留记录 - 逻辑删除会导致数据无限膨胀(每次删除都保留) - 与唯一索引配合使用时会产生冲突(同一组合反复删除会冲突) - 物理删除更符合业务直觉,代码更简洁 #### 2. 为什么不设置联合唯一索引? **原因**: - 单体应用 + 低并发场景下,并发冲突概率极低 - 应用层校验(保存前去重、添加前检查)足够可靠 - 批量操作时,有索引会导致一条失败全回滚,体验差 - 先删后增的更新模式下,唯一索引无实际意义 **风险可控**: - 应用层有完善的校验逻辑 - 定时任务检查脏数据 - 接受极端情况下的脏数据风险(概率极低) #### 3. 实体类写法 ```java package com.reading.platform.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; /** * 课程套餐与课程包关联实体 *

* 中间表,不设置逻辑删除字段,使用物理删除 *

*/ @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 packageIds) { if (CollectionUtils.isEmpty(packageIds)) { throw new BusinessException("课程包不能为空"); } // 1. 去重(静默处理,不报错) Set uniquePackageIds = new LinkedHashSet<>(packageIds); // 2. 物理删除所有关联(不使用逻辑删除) mapper.delete(new LambdaQueryWrapper() .eq(CourseCollectionPackage::getCollectionId, collectionId)); // 3. 批量插入 List 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() .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 uniqueIds = new LinkedHashSet<>(ids); // 2. 添加前检查 Long count = count(new LambdaQueryWrapper() .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 类)→ 逻辑删除 ``` ### 总结 | 维度 | 传统做法 | 推荐做法(单体应用) | |------|---------|---------------------| | 中间表删除策略 | 逻辑删除 | **物理删除** | | 中间表唯一索引 | 必须设置 | **不设置**(应用层校验) | | 数据一致性 | 数据库兜底 | 应用层校验 + 事务 | | 适用场景 | 高并发、分布式 | 单体应用、低并发 |