# 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 &lt;= #{condition.createTimeEnd}
        </if>
        <if test="condition.hasRole != null and condition.hasRole">
            AND id IN (SELECT user_id FROM t_user_role WHERE role_id = #{condition.roleId})
        </if>
    </where>
    ORDER BY create_time DESC
</select>
```

---

#### 7.5 查询方式选择决策树

```
                        开始查询
                           │
                           ▼
                  ┌────────────────┐
                  │ 是否单表查询？  │
                  └────────────────┘
                      │        │
                     是       否
                      │        │
                      ▼        ▼
            ┌──────────────────┐  ┌──────────────────┐
            │ 是否需要分页？   │  │  使用自定义 SQL   │
            └──────────────────┘  │  (XML 或@Select)   │
                  │        │      └──────────────────┘
                 是       否
                  │        │
                  ▼        ▼
        ┌──────────────┐ ┌──────────────────┐
        │ page(Page,   │ │ list(QueryWrapper) │
        │ QueryWrapper)│ │ getOne(QueryWrapper)│
        └──────────────┘ │ count(QueryWrapper) │
                         └──────────────────┘
```

**查询方式选择指南：**

| 场景 | 推荐方式 | 示例方法 |
|------|---------|---------|
| 单表按 ID 查询 | 通用方法 | `getById(id)` |
| 单表条件查询 | QueryWrapper | `list(wrapper)` / `getOne(wrapper)` |
| 单表分页查询 | QueryWrapper + Page | `page(new Page<>(p, s), wrapper)` |
| 单表统计 | QueryWrapper | `count(wrapper)` |
| 两表联查 | 自定义 SQL | `mapper.selectWithXxx()` |
| 三表及以上 | 自定义 SQL | `mapper.selectWithXxxAndYyy()` |
| 聚合统计 | 自定义 SQL | `mapper.selectStats()` |
| 子查询 | 自定义 SQL | `mapper.selectBySubQuery()` |
| 动态复杂条件 | 自定义 SQL(XML) | `mapper.selectByCondition()` |

---

### 架构设计原则

```
┌─────────────────────────────────────────────────────┐
│                    Controller 层                     │
│  • 接收请求参数（DTO）                                │
│  • 参数校验（@Valid）                                 │
│  • 调用 Service 层                                    │
│  • 返回统一响应（Result）                             │
│  • 不包含业务逻辑                                     │
└─────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────┐
│                     Service 层                       │
│  • 处理业务逻辑                                       │
│  • 事务控制（@Transactional）                         │
│  • 调用 Mapper 层                                     │
│  • 调用其他 Service                                   │
│  • 可以包含多个 Mapper 操作                            │
└─────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────┐
│                      Mapper 层                       │
│  • 数据库 CRUD 操作                                    │
│  • 继承 BaseMapper<T>                               │
│  • 复杂查询使用 XML 或@Select                          │
│  • 不包含业务逻辑                                     │
└─────────────────────────────────────────────────────┘
```

### Controller 层规范

```java
package com.company.project.controller;

import com.company.project.common.annotation.RequireRole;
import com.company.project.common.response.Result;
import com.company.project.dto.request.UserCreateRequest;
import com.company.project.dto.response.UserInfoResponse;
import com.company.project.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
 * 用户管理控制器
 *
 * 处理用户相关的 HTTP 请求，包括用户创建、查询、更新、删除等操作。
 * 所有接口需要登录认证，部分接口需要管理员权限。
 *
 * @author developer
 * @since 2024-01-01
 */
@Tag(name = "用户管理", description = "用户信息的增删改查接口")
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController extends BaseController {

    private final UserService userService;

    /**
     * 创建新用户
     *
     * <p>仅管理员可调用，创建后自动发送激活邮件。</p>
     *
     * @param request 用户创建请求，包含用户名、邮箱、角色等信息
     * @return 创建成功的用户信息
     */
    @Operation(summary = "创建用户", description = "创建新用户并返回用户信息，需要管理员权限")
    @RequireRole("ADMIN")
    @PostMapping
    public Result<UserInfoResponse> createUser(
            @Validated @RequestBody UserCreateRequest request) {
        UserInfoResponse response = userService.createUser(request);
        return Result.success(response);
    }

    /**
     * 根据 ID 获取用户详情
     *
     * @param userId 用户 ID，路径变量
     * @return 用户详细信息
     */
    @Operation(summary = "获取用户详情", description = "根据用户 ID 获取详细信息")
    @GetMapping("/{userId}")
    public Result<UserInfoResponse> getUser(
            @Parameter(description = "用户 ID", required = true)
            @PathVariable Long userId) {
        UserInfoResponse response = userService.getUserById(userId);
        return Result.success(response);
    }
}
```

### Service 层规范

```java
package com.company.project.service;

import com.company.project.dto.request.UserCreateRequest;
import com.company.project.dto.response.UserInfoResponse;

/**
 * 用户服务接口
 *
 * <p>定义用户相关的业务操作方法。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
public interface UserService {

    /**
     * 创建用户
     *
     * @param request 创建请求
     * @return 创建后的用户信息
     */
    UserInfoResponse createUser(UserCreateRequest request);

    /**
     * 根据 ID 查询用户
     *
     * @param userId 用户 ID
     * @return 用户信息
     */
    UserInfoResponse getUserById(Long userId);

    /**
     * 分页查询用户
     *
     * @param page 页码
     * @param size 每页数量
     * @param keyword 关键词
     * @return 分页结果
     */
    PageResult<UserInfoResponse> getUserPage(Integer page, Integer size, String keyword);
}
```

```java
package com.company.project.service.impl;

import com.company.project.common.exception.BusinessException;
import com.company.project.dto.request.UserCreateRequest;
import com.company.project.dto.response.UserInfoResponse;
import com.company.project.entity.User;
import com.company.project.mapper.UserMapper;
import com.company.project.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 用户服务实现类
 *
 * <p>实现用户相关的业务逻辑，包括用户创建、查询、更新、删除等操作。</p>
 * <p>所有写操作都需要事务控制。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserMapper userMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public UserInfoResponse createUser(UserCreateRequest request) {
        log.info("创建用户，用户名：{}", request.getUsername());

        // 检查用户名是否存在
        boolean exists = userMapper.existsByUsername(request.getUsername());
        if (exists) {
            throw new BusinessException("用户名已存在");
        }

        // 构建用户实体
        User user = User.builder()
                .username(request.getUsername())
                .email(request.getEmail())
                .status(CommonStatusEnum.ACTIVE.getCode())
                .build();

        // 插入数据库
        userMapper.insert(user);

        log.info("用户创建成功，ID: {}", user.getId());
        return convertToResponse(user);
    }

    @Override
    public UserInfoResponse getUserById(Long userId) {
        log.debug("查询用户，ID: {}", userId);

        User user = userMapper.selectById(userId);
        if (user == null) {
            throw new BusinessException("用户不存在");
        }

        return convertToResponse(user);
    }

    /**
     * 实体转换为响应对象
     */
    private UserInfoResponse convertToResponse(User user) {
        return UserInfoResponse.builder()
                .id(user.getId())
                .username(user.getUsername())
                .email(user.getEmail())
                .status(user.getStatus())
                .createTime(user.getCreateTime())
                .build();
    }
}
```

### Mapper 层规范

```java
package com.company.project.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.company.project.entity.User;

/**
 * 用户数据访问接口
 *
 * <p>继承 MyBatis-Plus 的 BaseMapper，获得基础 CRUD 能力。</p>
 * <p>复杂查询通过 XML 或注解方式实现。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Mapper
public interface UserMapper extends BaseMapper<User> {

    /**
     * 根据用户名检查用户是否存在
     *
     * @param username 用户名
     * @return 是否存在
     */
    @Select("SELECT COUNT(*) FROM t_user WHERE username = #{username}")
    boolean existsByUsername(@Param("username") String username);

    /**
     * 分页查询用户（带关键词搜索）
     *
     * @param page 分页对象
     * @param keyword 关键词（匹配用户名或邮箱）
     * @return 分页结果
     */
    Page<User> selectPageByKeyword(Page<User> page, @Param("keyword") String keyword);
}
```

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.company.project.mapper.UserMapper">

    <!-- 结果映射 -->
    <resultMap id="BaseResultMap" type="com.company.project.entity.User">
        <id column="id" property="id"/>
        <result column="username" property="username"/>
        <result column="email" property="email"/>
        <result column="status" property="status"/>
        <result column="create_time" property="createTime"/>
        <result column="update_time" property="updateTime"/>
    </resultMap>

    <!-- 分页查询（带关键词） -->
    <select id="selectPageByKeyword" resultMap="BaseResultMap">
        SELECT * FROM t_user
        <where>
            deleted = 0
            <if test="keyword != null and keyword != ''">
                AND (username LIKE CONCAT('%', #{keyword}, '%')
                     OR email LIKE CONCAT('%', #{keyword}, '%'))
            </if>
        </where>
        ORDER BY create_time DESC
    </select>

</mapper>
```

---

## 四、ORM 实体类规范

### 1. 表名命名规范

所有数据库表必须添加业务模块前缀，格式为 `t_{模块}_{表名}`：

| 模块分类 | 前缀 | 示例 |
|---------|------|------|
| 用户模块 | t_user_ | t_user, t_user_role, t_user_menu |
| 系统模块 | t_sys_ | t_sys_config, t_sys_log, t_sys_dict |
| 业务模块 | t_biz_ | t_biz_order, t_biz_product |
| 权限模块 | t_auth_ | t_auth_role, t_auth_menu, t_auth_perm |

### 2. 表名与实体类名对应规范

**表名与实体类名必须保持一致**，仅允许下划线与驼峰的区别：

| 表名（下划线命名） | 实体类名（驼峰命名） | 正确/错误 |
|------------------|-------------------|----------|
| `t_auth_role` | `AuthRole` | ✅ 正确 |
| `t_auth_menu` | `AuthMenu` | ✅ 正确 |
| `t_user_role` | `UserRole` | ✅ 正确 |
| `t_auth_role` | `Role` | ❌ 错误（缺少模块前缀） |
| `t_auth_menu` | `Menu` | ❌ 错误（缺少模块前缀） |
| `t_biz_order` | `BizOrder` | ✅ 正确 |
| `t_biz_order` | `OrderInfo` | ❌ 错误（名称不一致） |

**规范说明：**
- 表名去掉前缀 `t_` 后，剩余部分与实体类名对应
- 表名下划线转实体类大驼峰（如 `auth_role` → `AuthRole`）
- 禁止表名和实体类名不一致的情况（如 `t_auth_role` 对应 `RoleEntity`）

### 3. 实体类分类规范

实体类根据业务模块添加前缀，保持与服务层一致：

```
entity/
├── BaseEntity.java              # 基础实体类（审计字段）
├── user/
│   ├── User.java                # 用户实体 → t_user_user
│   └── UserRole.java            # 用户角色关联实体 → t_user_user_role
├── sys/
│   ├── SysConfig.java           # 系统配置实体 → t_sys_sys_config
│   └── SysLog.java              # 系统日志实体 → t_sys_sys_log
└── auth/
    ├── AuthRole.java            # 角色实体 → t_auth_auth_role
    └── AuthMenu.java            # 菜单实体 → t_auth_auth_menu
```

**注意：** 为避免表名冗长，模块内实体可省略模块前缀，示例：

```
entity/
├── user/
│   ├── User.java                # 用户实体 → t_user (而非 t_user_user)
│   └── Role.java                # 角色实体 → t_user_role
├── sys/
│   ├── Config.java              # 系统配置实体 → t_sys_config
│   └── Log.java                 # 系统日志实体 → t_sys_log
└── auth/
    ├── Role.java                # 角色实体 → t_auth_role
    └── Menu.java                # 菜单实体 → t_auth_menu
```

**推荐方式：** 使用简短的表名（省略模块内重复前缀），实体类名保持简洁：

| 推荐表名 | 推荐实体类名 | 不推荐的表名 | 不推荐的实体类名 |
|---------|------------|-------------|---------------|
| `t_user` | `User` | `t_user_user` | `User` |
| `t_auth_role` | `AuthRole` | `t_auth_auth_role` | `AuthRole` |
| `t_sys_config` | `SysConfig` | `t_sys_sys_config` | `SysConfig` |

### 4. 不使用外键约束规范

**规范说明：**
- 数据库表之间**不使用 FOREIGN KEY 外键约束**
- 表与表之间的关联关系通过**代码逻辑控制**
- 关联查询通过 `JOIN` 或应用层组装实现

**原因：**
1. **性能考虑**：外键约束会影响数据库写入性能，尤其是大批量数据插入/删除时
2. **灵活性**：应用层控制关联关系更灵活，便于处理复杂的业务逻辑
3. **分库分表**：外键约束会限制数据库的水平/垂直扩展能力
4. **维护性**：外键约束删除数据时需要先删除子记录，增加维护复杂度

**示例对比：**

```sql
-- ❌ 不推荐：使用外键约束
CREATE TABLE t_user_role (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES t_user(id),
    CONSTRAINT fk_role FOREIGN KEY (role_id) REFERENCES t_auth_role(id)
);

-- ✅ 推荐：不使用外键约束
CREATE TABLE t_user_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID（数据库自增）',
    user_id BIGINT NOT NULL COMMENT '用户 ID',
    role_id BIGINT NOT NULL COMMENT '角色 ID',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UNIQUE KEY uk_user_role (user_id, role_id)
) COMMENT='用户角色关联表';
```

**代码层控制关联关系：**

```java
// ✅ 推荐：在 Service 层控制关联关系
@Service
@RequiredArgsConstructor
public class UserRoleService {

    private final UserMapper userMapper;
    private final UserRoleMapper userRoleMapper;

    @Transactional(rollbackFor = Exception.class)
    public void deleteUser(Long userId) {
        // 1. 先删除关联关系
        userRoleMapper.delete(new LambdaQueryWrapper<UserRole>()
                .eq(UserRole::getUserId, userId));

        // 2. 再删除用户记录
        userMapper.deleteById(userId);
    }

    @Transactional(rollbackFor = Exception.class)
    public void assignRole(Long userId, Long roleId) {
        // 1. 验证用户是否存在
        User user = userMapper.selectById(userId);
        if (user == null) {
            throw new BusinessException("用户不存在");
        }

        // 2. 验证角色是否存在
        AuthRole role = authRoleMapper.selectById(roleId);
        if (role == null) {
            throw new BusinessException("角色不存在");
        }

        // 3. 添加用户角色关联
        UserRole userRole = UserRole.builder()
                .userId(userId)
                .roleId(roleId)
                .build();
        userRoleMapper.insert(userRole);
    }
}
```

**关联查询示例：**

```java
// ✅ 推荐：使用 JOIN 查询关联数据
@Mapper
public interface UserMapper extends BaseMapper<User> {

    /**
     * 查询用户及其角色信息
     */
    @Select("SELECT u.*, r.role_code, r.role_name " +
            "FROM t_user u " +
            "LEFT JOIN t_user_role ur ON u.id = ur.user_id " +
            "LEFT JOIN t_auth_role r ON ur.role_id = r.id " +
            "WHERE u.id = #{userId} AND u.deleted = 0")
    UserWithRolesVO getUserWithRoles(Long userId);
}

// ✅ 或者：在 Service 层组装关联数据
public UserWithRolesVO getUserWithRoles(Long userId) {
    // 1. 查询用户
    User user = userMapper.selectById(userId);

    // 2. 查询用户的角色列表
    List<AuthRole> roles = authRoleMapper.selectByUserId(userId);

    // 3. 组装 VO
    return UserWithRolesVO.builder()
            .user(user)
            .roles(roles)
            .build();
}
```

### 3.5 多对多关系中间表命名规范

**核心原则：中间表名必须清晰表达两个关联实体的关系，避免歧义。**

#### 1. 标准命名格式

```
t_{实体 A}_{实体 B}
```

- 按**字母顺序**或**业务逻辑顺序**排列
- 不使用 `relation`、`rel`、`link`、`mapping` 等冗余词
- 不使用 `map`、`assoc`、`junction` 等缩写
- 实体类名直接使用两个实体名的组合（如 `ParentStudent`）

#### 2. 项目中的实际案例

| 表名 | 实体类 | 说明 | 关联关系 |
|------|--------|------|---------|
| `t_parent_student` | `ParentStudent` | 家长 - 学生关联 | 一个家长可关联多个学生 |
| `t_class_teacher` | `ClassTeacher` | 班级 - 教师关联 | 一个班级有多个教师，一个教师可带多个班级 |
| `t_course_package_course` | `CoursePackageCourse` | 课程包 - 课程关联 | 一个课程包包含多门课程 |
| `t_product_bundle_course_package` | `ProductBundleCoursePackage` | 套餐 - 课程包关联 | 一个套餐包含多个课程包 |
| `t_tenant_product_bundle` | `TenantProductBundle` | 租户 - 套餐关联 | 一个租户可订购多个套餐 |

#### 3. 避免歧义的建议

##### 建议 1：当表名本身是复合词时，使用完整名称

```
✅ 正确：t_course_package_course（课程包 - 课程）
❌ 错误：t_package_course（歧义：可能是包裹 - 课程）
```

##### 建议 2：当两个表名组合后可能产生歧义时，添加业务前缀

```
场景：order_item 可能是订单 - 条目，也可能是订单项（单个实体）

✅ 方案一：t_order_order_item（明确表示关联表）
✅ 方案二：t_order_product_item（用 product 替代 item）
```

##### 建议 3：一对多关系 vs 多对多关系的区分

```
一对多关系（外键在"多"方）：
- 不需要中间表，直接在从表添加外键字段
- 示例：一个班级有多个学生 → 在 t_student 表添加 class_id 字段

多对多关系（需要中间表）：
- 必须使用中间表
- 中间表命名：t_表 A_表 B
- 示例：一个家长有多个学生，一个学生有多个家长 → t_parent_student
```

##### 建议 4：中间表可以包含业务属性

中间表不仅仅是关联关系，还可以包含业务属性：

```java
@Data
@TableName("t_class_teacher")
@Schema(description = "班级教师关联")
public class ClassTeacher {

    @Schema(description = "ID", accessMode = Schema.AccessMode.READ_ONLY)
    @TableId(type = IdType.AUTO)
    private Long id;

    @Schema(description = "班级 ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long classId;

    @Schema(description = "教师 ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long teacherId;

    // === 业务属性字段 ===
    @Schema(description = "角色", example = "teacher", requiredMode = Schema.RequiredMode.REQUIRED)
    private String role;  // 主教/助教

    @Schema(description = "是否主教", example = "true")
    private Boolean isPrimary;

    @Schema(description = "排序", example = "1")
    private Integer sortOrder;
}
```

#### 4. 实体类命名规范

```java
// ✅ 推荐：直接使用两个实体类名的组合
public class ParentStudent { }  // 对应 t_parent_student
public class ClassTeacher { }   // 对应 t_class_teacher

// ❌ 避免：使用模糊的后缀
public class ParentStudentRelation { }  // 冗余
public class ParentStudentMap { }       // 易与 Map 数据结构混淆
public class ParentStudentLink { }      // 冗余
public class ParentStudentMapping { }   // 冗余
```

#### 5. 主键策略

```java
// 推荐：使用 AUTO_INCREMENT（数据库自增）- 适用于所有表
@TableId(type = IdType.AUTO)
private Long id;
```

#### 6. 唯一索引约束

```sql
-- 防止重复关联（必须在数据库中创建唯一索引）
CREATE UNIQUE INDEX uk_parent_student ON t_parent_student(parent_id, student_id);
CREATE UNIQUE INDEX uk_class_teacher ON t_class_teacher(class_id, teacher_id);
```

#### 7. 命名决策树

```
          开始设计关联关系
                │
                ▼
      ┌─────────────────────┐
      │ 是几对几关系？       │
      └─────────────────────┘
           │           │
         一对多      多对多
           │           │
           ▼           ▼
    ┌──────────────┐ ┌──────────────────┐
    │ 在"多"方表   │ │ 创建中间表       │
    │ 添加外键     │ │ t_表 A_表 B      │
    │ class_id     │ │ + 业务属性字段   │
    └──────────────┘ └──────────────────┘
                             │
                             ▼
                    ┌──────────────────┐
                    │ 表名是否复合词？ │
                    └──────────────────┘
                         │         │
                        是        否
                         │         │
                         ▼         ▼
                  ┌──────────┐ ┌──────────┐
                  │ 使用完整 │ │ 直接组合 │
                  │ 名称     │ │ 表名     │
                  └──────────┘ └──────────┘
```

#### 8. 完整示例

**表结构：**
```sql
CREATE TABLE t_parent_student (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID（数据库自增）',
    parent_id BIGINT NOT NULL COMMENT '家长 ID',
    student_id BIGINT NOT NULL COMMENT '学生 ID',
    relationship VARCHAR(20) COMMENT '关系：father/mother/other',
    is_primary TINYINT DEFAULT 1 COMMENT '是否主要联系人',
    created_by VARCHAR(50) COMMENT '创建人',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_by VARCHAR(50) COMMENT '更新人',
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
    UNIQUE INDEX uk_parent_student (parent_id, student_id) COMMENT '防止重复关联',
    INDEX idx_parent (parent_id) COMMENT '按家长查询',
    INDEX idx_student (student_id) COMMENT '按学生查询'
) COMMENT='家长学生关联表';
```

**实体类：**
```java
package com.reading.platform.entity;

import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;

/**
 * 家长 - 学生关联实体
 *
 * <p>用于实现家长与学生的多对多关系：一个家长可关联多个学生，一个学生也可被多个家长关联。</p>
 * <p>中间表可包含业务属性：关系（父子/母子）、是否主要联系人等。</p>
 */
@Data
@TableName("t_parent_student")
@Schema(description = "家长学生关联")
public class ParentStudent {

    @Schema(description = "ID", accessMode = Schema.AccessMode.READ_ONLY)
    @TableId(type = IdType.AUTO)
    private Long id;

    @Schema(description = "家长 ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long parentId;

    @Schema(description = "学生 ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long studentId;

    @Schema(description = "关系", example = "mother", allowableValues = {"father", "mother", "other"})
    private String relationship;

    @Schema(description = "是否主要联系人", example = "1")
    private Integer isPrimary;

    @Schema(description = "创建人用户名", accessMode = Schema.AccessMode.READ_ONLY)
    @TableField(fill = FieldFill.INSERT)
    private String createdBy;

    @Schema(description = "创建时间", accessMode = Schema.AccessMode.READ_ONLY)
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;

    @Schema(description = "是否删除 0-未删除 1-已删除", accessMode = Schema.AccessMode.READ_ONLY)
    @TableLogic
    private Integer deleted;
}
```

### 4. 基础实体类

```java
package com.company.project.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.SuperBuilder;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;

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

/**
 * 基础实体类
 *
 * <p>包含所有实体共有的字段：ID（数据库自增）、审计字段、逻辑删除。</p>
 * <p>所有实体类应继承此基类。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Data
@SuperBuilder
@Schema(hidden = true)
public abstract class BaseEntity implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键 ID（数据库自增）
     */
    @Schema(description = "主键 ID")
    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 创建人（username）
     */
    @Schema(description = "创建人")
    @CreatedBy
    @TableField(fill = FieldFill.INSERT)
    private String createBy;

    /**
     * 创建时间
     */
    @Schema(description = "创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    /**
     * 更新人（username）
     */
    @Schema(description = "更新人")
    @LastModifiedBy
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateBy;

    /**
     * 更新时间
     */
    @Schema(description = "更新时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    /**
     * 逻辑删除标识（0-未删除，1-已删除）
     */
    @Schema(description = "删除标识")
    @TableLogic
    @JsonIgnore
    private Integer deleted;
}
```

### 4. 业务实体类示例

```java
package com.company.project.entity.user;

import com.baomidou.mybatisplus.annotation.TableName;
import com.company.project.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;

/**
 * 用户实体类
 *
 * <p>对应数据库表：t_user</p>
 * <p>存储系统用户的基本信息，包括登录账号、联系方式、状态等。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Data
@SuperBuilder
@EqualsAndHashCode(callSuper = true)
@TableName("t_user")
@Schema(description = "用户信息")
public class User extends BaseEntity {

    private static final long serialVersionUID = 1L;

    /**
     * 用户名（登录账号）
     */
    @Schema(description = "用户名", example = "zhangsan")
    private String username;

    /**
     * 密码（BCrypt 加密存储）
     */
    @Schema(description = "密码")
    private String password;

    /**
     * 昵称
     */
    @Schema(description = "昵称", example = "张三")
    private String nickname;

    /**
     * 邮箱
     */
    @Schema(description = "邮箱", example = "zhangsan@example.com")
    private String email;

    /**
     * 手机号
     */
    @Schema(description = "手机号", example = "13800138000")
    private String phone;

    /**
     * 头像 URL
     */
    @Schema(description = "头像 URL")
    private String avatar;

    /**
     * 状态：0-禁用，1-正常，2-锁定
     */
    @Schema(description = "状态", example = "1", allowableValues = {"0", "1", "2"})
    private Integer status;

    /**
     * 备注
     */
    @Schema(description = "备注")
    private String remark;
}
```

### 5. MyBatis-Plus 自动填充配置

```java
package com.company.project.common.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.company.project.common.security.UserPrincipal;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;

/**
 * MyBatis-Plus 配置类
 *
 * <p>配置分页插件和自动填充处理器。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Configuration
public class MybatisPlusConfig implements MetaObjectHandler {

    /**
     * 分页插件配置
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    /**
     * 插入时自动填充
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "createBy", String.class, getCurrentUsername());
    }

    /**
     * 更新时自动填充
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
        this.strictUpdateFill(metaObject, "updateBy", String.class, getCurrentUsername());
    }

    /**
     * 获取当前登录用户名
     */
    private String getCurrentUsername() {
        try {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
                return ((UserPrincipal) authentication.getPrincipal()).getUsername();
            }
        } catch (Exception e) {
            // 未登录或获取失败
        }
        return null;
    }
}
```

---

## 五、RBAC 权限体系规范

### 1. 权限模型设计

采用标准的 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 权限注解
 *
 * <p>用于标记需要权限控制的方法。</p>
 * <p>支持角色权限和按钮权限两种校验方式。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireRole {

    /**
     * 角色编码列表（支持多个）
     */
    String[] value() default {};

    /**
     * 权限标识（按钮权限）
     */
    String permission() default "";

    /**
     * 是否校验所有条件（AND 关系）
     */
    boolean requireAll() default false;
}
```

```java
package com.company.project.common.aspect;

import com.company.project.common.annotation.RequireRole;
import com.company.project.common.exception.ForbiddenException;
import com.company.project.common.security.UserPrincipal;
import com.company.project.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.lang.reflect.Method;

/**
 * RBAC 权限切面
 *
 * <p>拦截带有 RequireRole 注解的方法和类，进行权限校验。</p>
 *
 * @author developer
 * @since 2024-01-01
 */
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RBACAspect {

    private final AuthService authService;

    @Around("@annotation(com.company.project.common.annotation.RequireRole) || " +
            "@within(com.company.project.common.annotation.RequireRole)")
    public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !(authentication.getPrincipal() instanceof UserPrincipal)) {
            throw new ForbiddenException("未登录");
        }

        UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        RequireRole requireRole = method.getAnnotation(RequireRole.class);
        if (requireRole == null) {
            requireRole = joinPoint.getTarget().getClass().getAnnotation(RequireRole.class);
        }

        if (requireRole != null) {
            // 角色权限校验
            String[] roles = requireRole.value();
            if (roles.length > 0) {
                boolean hasRole = java.util.Arrays.stream(roles)
                        .anyMatch(role -> principal.getRoles().contains(role));
                if (!hasRole) {
                    throw new ForbiddenException("没有访问权限");
                }
            }

            // 按钮权限校验
            String permission = requireRole.permission();
            if (StringUtils.hasText(permission) && !authService.hasPermission(principal.getId(), permission)) {
                throw new ForbiddenException("没有操作权限：" + permission);
            }
        }

        return joinPoint.proceed();
    }
}
```

---

## 六、日志规范

### 0. 日志打印语言规范（重要）

**核心原则：所有日志打印内容必须使用中文，包括业务日志、调试日志、异常日志等。**

**原因：**
1. **可读性**：项目团队成员都是中文使用者，中文日志更易于理解
2. **排查效率**：中文日志能更准确地描述业务场景，提高问题排查效率
3. **统一规范**：保持整个项目的代码风格和文档一致性

**错误示例：**
```java
// ❌ 错误：使用英文日志
log.info("User created successfully, id: {}", userId);
log.debug("Query user by id: {}", userId);
log.error("Failed to create user", e);
log.warn("User not found, id: {}", userId);
```

**正确示例：**
```java
// ✅ 正确：使用中文日志
log.info("用户创建成功，ID: {}", userId);
log.debug("查询用户，ID: {}", userId);
log.error("创建用户失败", e);
log.warn("用户不存在，ID: {}", userId);
```

**完整示例：**
```java
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    @Override
    @Transactional(rollbackFor = Exception.class)
    public User createUser(UserCreateRequest request) {
        // 记录入参
        log.info("开始创建用户，用户名：{}", request.getUsername());

        // 检查唯一性
        boolean exists = userMapper.existsByUsername(request.getUsername());
        if (exists) {
            log.warn("用户名已存在：{}", request.getUsername());
            throw new BusinessException("用户名已存在");
        }

        // 构建并保存
        User user = User.builder()
                .username(request.getUsername())
                .email(request.getEmail())
                .build();
        userMapper.insert(user);

        // 记录结果
        log.info("用户创建成功，ID: {}", user.getId());
        return user;
    }

    @Override
    public User getUserById(Long userId) {
        log.debug("查询用户，ID: {}", userId);

        User user = this.getById(userId);
        if (user == null) {
            log.warn("用户不存在，ID: {}", userId);
            throw new BusinessException("用户不存在");
        }

        log.info("查询用户成功，用户名：{}", user.getUsername());
        return user;
    }

    @Override
    public void deleteUser(Long userId) {
        log.info("开始删除用户，ID: {}", userId);

        try {
            this.removeById(userId);
            log.info("用户删除成功，ID: {}", userId);
        } catch (Exception e) {
            log.error("用户删除失败，ID: {}", userId, e);
            throw new SystemException("删除用户失败");
        }
    }
}
```

**日志格式规范：**

| 场景 | 推荐格式 | 示例 |
|------|---------|------|
| 操作开始 | "开始{操作}，{关键参数}" | `开始创建用户，用户名：zhangsan` |
| 操作成功 | "{操作}成功，{关键结果}" | `用户创建成功，ID: 123` |
| 操作失败 | "{操作}失败，{关键参数}" | `用户删除失败，ID: 123` |
| 查询操作 | "{动作}{对象}，{关键参数}" | `查询用户，ID: 123` |
| 状态检查 | "{对象}不存在/已存在，{关键参数}" | `用户不存在，ID: 123` |
| 异常日志 | "{操作}异常，{关键参数}" + e | `创建用户异常，ID: 123` + e |

---

### 1. Logback 配置

```xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
    <property name="LOG_PATH" value="${LOG_PATH:-./logs}"/>
    <property name="APP_NAME" value="${APP_NAME:-project}"/>
    <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>

    <!-- 控制台输出 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- INFO 日志文件 -->
    <appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${APP_NAME}-info.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${APP_NAME}-info.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <!-- ERROR 日志文件 -->
    <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${APP_NAME}-error.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${APP_NAME}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>90</maxHistory>
        </rollingPolicy>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <!-- 开发环境 -->
    <springProfile name="dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
        <logger name="com.company.project" level="DEBUG"/>
    </springProfile>

    <!-- 生产环境 -->
    <springProfile name="prod">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="FILE_INFO"/>
            <appender-ref ref="FILE_ERROR"/>
        </root>
    </springProfile>
</configuration>
```

### 2. 日志使用规范

```java
@Slf4j
@Service
public class UserServiceImpl implements UserService {

    @Override
    public UserInfoResponse getUserById(Long userId) {
        // DEBUG - 调试信息
        log.debug("查询用户，ID: {}", userId);

        try {
            User user = userMapper.selectById(userId);
            if (user == null) {
                // WARN - 业务警告
                log.warn("用户不存在，ID: {}", userId);
                throw new BusinessException("用户不存在");
            }

            // INFO - 业务操作记录
            log.info("查询用户成功，用户名：{}", user.getUsername());
            return convertToResponse(user);

        } catch (BusinessException e) {
            // 业务异常
            log.warn("业务异常：{}", e.getMessage());
            throw e;

        } catch (Exception e) {
            // ERROR - 系统异常
            log.error("系统异常，ID: {}", userId, e);
            throw new SystemException("系统异常");
        }
    }
}
```

### 3. 日志级别使用场景

| 级别 | 使用场景 | 示例 |
|------|---------|------|
| ERROR | 系统异常，需要人工介入 | 数据库连接失败、第三方服务调用失败 |
| WARN | 业务异常，可预期的错误 | 参数校验失败、资源不存在 |
| INFO | 重要的业务操作记录 | 用户登录、订单创建、支付完成 |
| DEBUG | 调试信息，开发时使用 | 方法入参、SQL 执行结果 |

---

## 七、FastJSON 配置规范

### 1. FastJSON 配置类

```java
package com.company.project.common.config;

import com.alibaba.fastjson2.support.spring.http.converter.FastJsonHttpMessageConverter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * FastJSON 配置类
 *
 * @author developer
 * @since 2024-01-01
 */
@Configuration
public class FastJsonConfig implements WebMvcConfigurer {

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
        converter.setDefaultCharset(StandardCharsets.UTF_8);

        List<MediaType> mediaTypes = new ArrayList<>();
        mediaTypes.add(MediaType.APPLICATION_JSON);
        converter.setSupportedMediaTypes(mediaTypes);

        converters.add(0, converter);
    }
}
```

### 2. JSON 工具类

```java
package com.company.project.common.util;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;

import java.util.List;

/**
 * FastJSON 工具类
 *
 * @author developer
 * @since 2024-01-01
 */
public class JsonUtils {

    private JsonUtils() {}

    public static String toJson(Object obj) {
        return JSON.toJSONString(obj);
    }

    public static <T> T parseObject(String json, Class<T> clazz) {
        return JSON.parseObject(json, clazz);
    }

    public static <T> List<T> parseArray(String json, Class<T> clazz) {
        return JSON.parseArray(json, clazz);
    }
}
```

---

## 八、阿里云 OSS 文件上传规范

### 1. OSS 配置类

```java
package com.company.project.common.config;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 阿里云 OSS 配置类
 *
 * @author developer
 * @since 2024-01-01
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "aliyun.oss")
public class OssConfig {

    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;
    private String urlPrefix;

    @Bean
    public OSS ossClient() {
        return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    }
}
```

### 2. application.yml 配置

```yaml
# 阿里云 OSS 配置（预留）
aliyun:
  oss:
    endpoint: oss-cn-hangzhou.aliyuncs.com
    access-key-id: ${OSS_ACCESS_KEY_ID:your-access-key-id}
    access-key-secret: ${OSS_ACCESS_KEY_SECRET:your-access-key-secret}
    bucket-name: ${OSS_BUCKET_NAME:your-bucket-name}
    url-prefix: https://your-bucket-name.oss-cn-hangzhou.aliyuncs.com/
```

### 3. OSS 工具类

```java
package com.company.project.common.util;

import com.aliyun.oss.OSS;
import com.aliyun.oss.model.PutObjectRequest;
import com.company.project.common.config.OssConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

/**
 * 阿里云 OSS 工具类
 *
 * @author developer
 * @since 2024-01-01
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class OssUtils {

    private final OSS ossClient;
    private final OssConfig ossConfig;

    /**
     * 上传文件
     */
    public String uploadFile(MultipartFile file) {
        String originalFilename = file.getOriginalFilename();
        String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        String objectName = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd/"))
                + UUID.randomUUID().toString().replace("-", "") + extension;

        try (InputStream inputStream = file.getInputStream()) {
            ossClient.putObject(new PutObjectRequest(ossConfig.getBucketName(), objectName, inputStream));
            String fileUrl = ossConfig.getUrlPrefix() + objectName;
            log.info("文件上传成功：{}", fileUrl);
            return fileUrl;
        } catch (Exception e) {
            log.error("文件上传失败", e);
            throw new BusinessException("文件上传失败");
        }
    }

    /**
     * 删除文件
     */
    public void deleteFile(String fileUrl) {
        String objectName = fileUrl.replace(ossConfig.getUrlPrefix(), "");
        ossClient.deleteObject(ossConfig.getBucketName(), objectName);
    }
}
```

---

### 8. 工具函数使用规范

**核心原则：工具函数必须集中管理，禁止在 Controller 层直接编写工具方法。**

#### 工具函数存放位置决策树

```
需要编写工具函数
        │
        ▼
┌──────────────────┐
│ 调用地方有几个？  │
└──────────────────┘
    │        │
   多处     一处
    │        │
    ▼        ▼
┌─────────┐ ┌─────────────┐
│ 统一写   │ │ 直接写在    │
│ 到工具   │ │ Service 层  │
│ 类中    │ │ 内部方法    │
└─────────┘ └─────────────┘
```

#### 规范说明

| 场景 | 存放位置 | 调用方式 |
|------|---------|---------|
| 多个地方调用（≥2 处） | 统一工具类（如 `CommonUtil`） | 静态方法调用 |
| 仅在一个地方调用 | Service 层内部私有方法 | 本类内调用 |
| 业务无关的通用工具 | 独立工具类（如 `DateUtil`, `FileUtil`） | 静态方法调用 |

#### 错误示例

```java
// ❌ 错误 1：在 Controller 层编写工具方法
@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    /**
     * 错误：工具方法不应该写在 Controller 层
     */
    private String formatUsername(String username) {
        return username.trim().toLowerCase();
    }

    @PostMapping
    public Result<UserVO> createUser(@RequestBody UserRequest request) {
        String formattedUsername = formatUsername(request.getUsername());
        // ...
    }
}

// ❌ 错误 2：工具逻辑散落在各处
@Service
public class UserServiceImpl implements UserService {

    // 每个 Service 都写一遍相同的工具逻辑
    private String generateOrderNo() {
        return "ORD" + System.currentTimeMillis() + RandomUtil.randomInt(1000, 9999);
    }
}

@Service
public class OrderServiceImpl implements OrderService {

    // 重复代码
    private String generateOrderNo() {
        return "ORD" + System.currentTimeMillis() + RandomUtil.randomInt(1000, 9999);
    }
}
```

#### 正确示例

**场景一：多处调用的工具函数 → 统一工具类**

```java
// ✅ 正确：统一工具类
package com.reading.platform.common.util;

import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * 通用工具类
 *
 * <p>集中管理项目中常用的工具方法，避免代码重复。</p>
 */
@Slf4j
public class CommonUtil {

    private CommonUtil() {
        // 私有构造，防止实例化
    }

    /**
     * 生成订单号
     *
     * <p>格式：ORD + 年月日时分秒 + 4 位随机数</p>
     *
     * @return 订单号
     */
    public static String generateOrderNo() {
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        int random = (int) (Math.random() * 9000) + 1000;
        return "ORD" + timestamp + random;
    }

    /**
     * 格式化用户名
     *
     * @param username 原始用户名
     * @return 格式化后的用户名
     */
    public static String formatUsername(String username) {
        if (username == null) {
            return null;
        }
        return username.trim().toLowerCase();
    }

    /**
     * 生成 6 位短信验证码
     *
     * @return 验证码
     */
    public static String generateSmsCode() {
        return String.format("%06d", (int) (Math.random() * 999999));
    }
}
```

**场景二：仅一处调用的工具函数 → Service 层内部方法**

```java
// ✅ 正确：仅在本 Service 内使用的工具方法写在 Service 层
@Service
@Slf4j
@RequiredArgsConstructor
public class LessonServiceImpl extends ServiceImpl<LessonMapper, Lesson> implements LessonService {

    private final StudentRecordMapper studentRecordMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void finishLesson(Long lessonId, List<StudentRecord> records) {
        log.info("开始结束课时，ID: {}", lessonId);

        // 保存学生记录
        for (StudentRecord record : records) {
            record.setLessonId(lessonId);
            // 调用内部工具方法处理数据
            processRecordBeforeSave(record);
            studentRecordMapper.insert(record);
        }

        // 更新课时状态
        Lesson lesson = this.getById(lessonId);
        lesson.setStatus("finished");
        this.updateById(lesson);

        log.info("课时结束完成，ID: {}", lessonId);
    }

    /**
     * 保存记录前处理数据
     *
     * <p>此方法仅在本类中使用，不需要抽取到工具类</p>
     */
    private void processRecordBeforeSave(StudentRecord record) {
        // 计算综合评分
        int totalScore = record.getFocus() + record.getParticipation()
                       + record.getInterest() + record.getUnderstanding();
        record.setTotalScore(totalScore);

        // 根据综合评分设置评价等级
        if (totalScore >= 17) {
            record.setGrade("A");
        } else if (totalScore >= 13) {
            record.setGrade("B");
        } else {
            record.setGrade("C");
        }
    }
}
```

**场景三：通用工具 → 独立工具类**

```java
// ✅ 正确：独立工具类，按功能分类
package com.reading.platform.common.util;

/**
 * 日期工具类
 */
public class DateUtil {

    private DateUtil() {}

    public static String formatDate(LocalDateTime dateTime) {
        return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    public static LocalDateTime parseDate(String dateStr) {
        return LocalDateTime.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }
}

/**
 * 文件工具类
 */
public class FileUtil {

    private FileUtil() {}

    public static String getExtension(String fileName) {
        int lastDot = fileName.lastIndexOf(".");
        return lastDot > 0 ? fileName.substring(lastDot) : "";
    }

    public static boolean isValidImage(String fileName) {
        String ext = getExtension(fileName).toLowerCase();
        return Arrays.asList(".jpg", ".jpeg", ".png", ".gif").contains(ext);
    }
}
```

#### 工具类命名规范

| 类型 | 命名格式 | 示例 |
|------|---------|------|
| 通用工具类 | `XxxUtil` | `CommonUtil`, `DateUtil`, `FileUtil` |
| 业务工具类 | `XxxHelper` | `UserHelper`, `OrderHelper` |
| 转换工具类 | `XxxConverter` | `EntityConverter`, `DtoConverter` |

#### 工具类设计原则

1. **私有构造**：防止实例化
   ```java
   private CommonUtil() {
       throw new UnsupportedOperationException("Utility class cannot be instantiated");
   }
   ```

2. **静态方法**：所有方法都是 `static` 的

3. **无状态**：工具类不应持有状态

4. **线程安全**：工具方法必须是线程安全的

5. **充分注释**：每个方法都要有 JavaDoc 注释，说明用途、参数、返回值

#### 代码审查要点

- [ ] 工具函数是否抽取到工具类或 Service 内部
- [ ] 是否存在 Controller 层直接编写工具方法的情况
- [ ] 多处使用的工具是否统一到工具类中
- [ ] 工具类是否有私有构造防止实例化
- [ ] 工具方法是否为静态方法
- [ ] 工具类是否无状态（不保存成员变量）
- [ ] 工具方法是否有完整的 JavaDoc 注释

---

## 九、Hutool 工具类使用规范

### 1. Maven 依赖

```xml
<!-- Hutool 工具类 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.23</version>
</dependency>

<!-- FastJSON2 -->
<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
    <version>2.0.42</version>
</dependency>
```

### 2. 常用工具方法

```java
package com.company.project.common.util;

import cn.hutool.core.util.*;
import cn.hutool.crypto.SecureUtil;

/**
 * Hutool 工具类使用示例
 *
 * @author developer
 * @since 2024-01-01
 */
public class ToolUtils {

    private ToolUtils() {}

    /** 生成 UUID（不带横杠） */
    public static String uuid() {
        return IdUtil.fastSimpleUUID();
    }

    /** 密码加密（BCrypt） */
    public static String encryptPassword(String password) {
        return SecureUtil.bcrypt(password);
    }

    /** 密码校验 */
    public static boolean verifyPassword(String password, String encrypted) {
        return SecureUtil.bcryptVerify(password, encrypted);
    }

    /** 检查手机号是否有效 */
    public static boolean isValidPhone(String phone) {
        return Validator.isMatchRegex("^1[3-9]\\d{9}$", phone);
    }

    /** 脱敏手机号 */
    public static String maskPhone(String phone) {
        return DesensitizedUtil.mobilePhone(phone);
    }
}
```

---

## 十、Swagger/Knife4j 配置规范

### 1. Swagger 配置类

```java
package com.company.project.common.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

/**
 * Swagger/OpenAPI 配置类
 *
 * @author developer
 * @since 2024-01-01
 */
@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("项目 API 接口文档")
                        .description("基于 Spring Boot + Vue 3 的企业级应用")
                        .version("1.0.0")
                        .contact(new Contact()
                                .name("开发团队")
                                .email("dev@company.com")))
                .servers(List.of(
                        new Server().url("http://localhost:8080").description("本地环境"),
                        new Server().url("http://api-dev.company.com").description("开发环境")
                ))
                .schemaRequirement("BearerAuth", new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")
                        .description("Authorization: Bearer {token}"))
                .addSecurityItem(new SecurityRequirement().addList("BearerAuth"));
    }

    /** 接口分组 */
    @Bean
    public GroupedOpenApi allApi() {
        return GroupedOpenApi.builder()
                .group("全部接口")
                .pathsToMatch("/api/**")
                .build();
    }
}
```

### 2. Maven 依赖

```xml
<!-- Knife4j (SpringDoc) -->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
    <version>4.4.0</version>
</dependency>
```

### 3. 接口文档访问

- Knife4j UI: http://localhost:8080/doc.html
- OpenAPI JSON: http://localhost:8080/v3/api-docs

---

## 十一、统一响应格式

### 1. 响应类定义

```java
package com.company.project.common.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;

/**
 * 统一响应类
 *
 * @author developer
 * @since 2024-01-01
 */
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "统一响应")
public class Result<T> {

    @Schema(description = "状态码", example = "200")
    private Integer code;

    @Schema(description = "消息", example = "success")
    private String message;

    @Schema(description = "数据")
    private T data;

    @Schema(description = "时间戳")
    private Long timestamp;

    public static <T> Result<T> success(T data) {
        return Result.<T>builder()
                .code(200).message("success").data(data)
                .timestamp(System.currentTimeMillis())
                .build();
    }

    public static <T> Result<T> error(Integer code, String message) {
        return Result.<T>builder()
                .code(code).message(message)
                .timestamp(System.currentTimeMillis())
                .build();
    }
}
```

### 2. 错误码枚举

```java
package com.company.project.common.response;

import lombok.Getter;

/**
 * 错误码枚举
 *
 * @author developer
 * @since 2024-01-01
 */
@Getter
public enum ErrorCode {

    SUCCESS(200, "success"),
    BAD_REQUEST(400, "请求参数错误"),
    UNAUTHORIZED(401, "未登录或 Token 已过期"),
    FORBIDDEN(403, "没有访问权限"),
    NOT_FOUND(404, "资源不存在"),
    INTERNAL_ERROR(500, "系统内部错误");

    private final Integer code;
    private final String message;

    ErrorCode(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}
```

### 3. 全局异常处理器

```java
package com.company.project.common.exception;

import com.company.project.common.response.Result;
import com.company.project.common.response.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 *
 * @author developer
 * @since 2024-01-01
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        log.warn("业务异常：{}", e.getMessage());
        return Result.error(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), "系统异常，请稍后重试");
    }
}
```

---

## 十二、多环境配置规范

### 1. 配置文件目录结构

```
backend/src/main/resources/
├── application.yml              # 主配置文件（共用配置）
├── application-dev.yml          # 开发环境配置
├── application-test.yml         # 测试环境配置
├── application-prod.yml         # 生产环境配置
├── db/migration/                # Flyway 迁移脚本
├── logback-spring.xml           # 日志配置
└── mapper/                      # MyBatis XML
```

### 2. 主配置文件 (application.yml)

```yaml
# =================== 主配置文件 ===================
# 所有环境共用的配置，环境特定配置在各环境文件中覆盖

spring:
  application:
    name: ${APP_NAME:project}

  # 激活的环境配置（通过命令行参数或环境变量切换）
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

server:
  port: ${SERVER_PORT:8080}
```

### 3. 开发环境配置 (application-dev.yml)

```yaml
# =================== 开发环境配置 ===================
spring:
  config:
    activate:
      on-profile: dev

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:project}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: ${DB_USER:root}
    password: ${DB_PASSWORD:password}
    type: com.alibaba.druid.pool.DruidDataSource

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      database: ${REDIS_DB:0}

  flyway:
    enabled: true
    locations: classpath:db/migration
    clean-disabled: false

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

jwt:
  secret: ${JWT_SECRET:dev-secret-key-for-development-only}
  expiration: ${JWT_EXPIRATION:86400000}

springdoc:
  api-docs:
    enabled: true
  swagger-ui:
    enabled: true

logging:
  level:
    root: INFO
    com.company.project: DEBUG
    com.company.project.mapper: DEBUG
```

### 4. 测试环境配置 (application-test.yml)

```yaml
# =================== 测试环境配置 ===================
spring:
  config:
    activate:
      on-profile: test

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:project_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: ${DB_USER:test_user}
    password: ${DB_PASSWORD:test_password}
    type: com.alibaba.druid.pool.DruidDataSource

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      database: ${REDIS_DB:0}

  flyway:
    enabled: true
    locations: classpath:db/migration
    clean-disabled: true

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true

jwt:
  secret: ${JWT_SECRET:test-secret-key}
  expiration: ${JWT_EXPIRATION:86400000}

springdoc:
  api-docs:
    enabled: true
  swagger-ui:
    enabled: true

logging:
  level:
    root: INFO
    com.company.project: INFO
    com.company.project.mapper: DEBUG
```

### 5. 生产环境配置 (application-prod.yml)

```yaml
# =================== 生产环境配置 ===================
spring:
  config:
    activate:
      on-profile: prod

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:project}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=true
    username: ${DB_USER:prod_user}
    password: ${DB_PASSWORD:prod_password}
    type: com.alibaba.druid.pool.DruidDataSource

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:prod_password}
      database: ${REDIS_DB:0}
      timeout: 10000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2

  flyway:
    enabled: true
    locations: classpath:db/migration
    clean-disabled: true

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true

jwt:
  secret: ${JWT_SECRET}  # 生产环境必须设置环境变量
  expiration: ${JWT_EXPIRATION:86400000}

springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: false

logging:
  level:
    root: WARN
    com.company.project: INFO
    com.company.project.mapper: WARN
```

### 6. 环境切换方式

#### 方式一：环境变量（推荐）

```bash
# Linux/Mac
export SPRING_PROFILES_ACTIVE=prod
java -jar project.jar

# Windows
set SPRING_PROFILES_ACTIVE=prod
java -jar project.jar
```

#### 方式二：命令行参数

```bash
java -jar project.jar --spring.profiles.active=prod
```

#### 方式三：Docker 环境变量

```yaml
# docker-compose.yml
services:
  backend:
    environment:
      - SPRING_PROFILES_ACTIVE=prod
```

### 7. 环境配置说明

| 配置项 | 开发环境 (dev) | 测试环境 (test) | 生产环境 (prod) |
|--------|---------------|---------------|---------------|
| 数据库 | 本地 MySQL | 测试服务器 | 生产服务器 |
| SQL 日志 | 开启 | 开启 | 关闭 |
| Swagger | 开启 | 开启 | 关闭 |
| Flyway Clean | 允许 | 禁止 | 禁止 |
| JWT 密钥 | 默认值 | 默认值 | 必须环境变量 |
| Redis 连接池 | 默认 | 默认 | 优化配置 |

---

## 十三、Docker Compose 本地开发

```yaml
version: '3.8'
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: project
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    networks:
      - project-network

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - project-network

  backend:
    build: ./backend
    ports:
      - "8080:8080"
    depends_on:
      - mysql
      - redis
    environment:
      - SPRING_PROFILES_ACTIVE=dev
      - DB_HOST=mysql
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_HOST=redis
    networks:
      - project-network
    volumes:
      - ./logs:/app/logs

  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    depends_on:
      - backend
    networks:
      - project-network

networks:
  project-network:
    driver: bridge

volumes:
  mysql_data:
  redis_data:
```

---

## 版本历史

| 版本 | 日期 | 更新内容 |
|------|------|----------|
| 1.0 | 2024-01-01 | 初始版本 - 通用企业级开发规范 |
| 1.1 | 2024-01-01 | 新增多环境配置规范 |
| 1.2 | 2024-01-02 | 新增限流规范（Redis 滑动窗口 + 配置化 + 注解） |

---

## 快速参考

### 环境激活方式

| 方式 | 命令/配置 | 说明 |
|------|----------|------|
| 环境变量 | `SPRING_PROFILES_ACTIVE=prod` | 推荐用于生产环境 |
| 命令行参数 | `--spring.profiles.active=prod` | 临时切换环境 |
| Docker | `environment: - SPRING_PROFILES_ACTIVE=prod` | 容器化部署 |

### 环境配置对比

| 配置项 | 开发环境 (dev) | 测试环境 (test) | 生产环境 (prod) |
|--------|---------------|---------------|---------------|
| SQL 日志 | 开启 | 开启 | 关闭 |
| Swagger | 开启 | 开启 | 关闭 |
| Flyway Clean | 允许 | 禁止 | 禁止 |
| JWT 密钥 | 默认值 | 默认值 | 必须环境变量 |
| Redis 连接池 | 默认 | 默认 | 优化配置 |

### 核心配置速查

| 配置项 | 环境变量 | 默认值 |
|--------|----------|--------|
| 活跃环境 | `SPRING_PROFILES_ACTIVE` | dev |
| 数据库地址 | `DB_HOST` | localhost |
| 数据库密码 | `DB_PASSWORD` | password |
| Redis 地址 | `REDIS_HOST` | localhost |
| Redis 密码 | `REDIS_PASSWORD` | (空) |
| JWT 密钥 | `JWT_SECRET` | dev-secret-key... |
| OSS AccessKey | `OSS_ACCESS_KEY_ID` | your-access-key-id |
| OSS Secret | `OSS_ACCESS_KEY_SECRET` | your-access-key-secret |

### 表名命名规范

- `t_user_*` - 用户模块
- `t_sys_*` - 系统模块
- `t_biz_*` - 业务模块
- `t_auth_*` - 权限模块

### Redis Key 命名规范

- `auth:token:{token}` - 用户 Token
- `user:info:{userId}` - 用户信息缓存
- `dict:{type}` - 数据字典
- `lock:{resource}:{id}` - 分布式锁
- `rate_limit:{key}` - 限流计数器

---

## 限流规范

### 技术选型

| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|---------|
| **Redis 滑动窗口** | 精确控制、支持分布式 | 依赖 Redis | **推荐：全局限流** |
| 自定义注解 + AOP | 灵活、可针对特定接口 | 需要手动添加注解 | **推荐：特殊接口限流** |
| 令牌桶算法 | 平滑限流、允许突发 | 实现复杂 | 特殊场景 |
| 计数器算法 | 简单、性能好 | 临界问题 | 不推荐 |

### 推荐方案：配置化 + 注解 fallback

采用 **配置化全局限流** 作为基础，**注解限流** 作为特殊场景的补充。

#### 方案一：配置化限流（推荐作为基础）

在 `application.yml` 中配置全局限流规则：

```yaml
rate-limit:
  enabled: true                      # 是否启用限流
  default-time-window: 60            # 默认时间窗口（秒）
  default-max-requests: 1000         # 默认时间窗口内最大请求数
  rules:                             # 限流规则列表
    - pattern: "/api/v1/auth/login"  # 接口路径（支持 Ant 风格）
      time-window: 60                # 时间窗口（秒）
      max-requests: 10               # 时间窗口内最大请求数
    - pattern: "/api/v1/sms/**"
      time-window: 60
      max-requests: 3
  exclude-patterns:                  # 排除限流的接口
    - "/doc.html"
    - "/v3/api-docs/**"
    - "/uploads/**"
```

**配置说明：**
- 按照 `pattern` 匹配接口路径，支持 `*` 和 `**` 通配符
- 每个接口按照 `IP 地址 + pattern` 作为限流键，实现 per-IP 限流
- 未在规则中的接口使用默认配置（`default-time-window`, `default-max-requests`）

#### 方案三：注解限流（作为 fallback）

针对特殊接口使用 `@RateLimit` 注解进行限流：

```java
package com.company.project.common.annotation;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
    String keyPrefix() default "";     // 限流键前缀
    long time() default 60;            // 时间窗口大小
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    long maxRequests() default 100;    // 最大请求数
    String message() default "请求过于频繁，请稍后再试";
    boolean enabled() default true;
}
```

**使用示例：**

```java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    /**
     * 登录接口 - 限流防止暴力破解
     * 60 秒内最多 10 次请求
     */
    @PostMapping("/login")
    @RateLimit(time = 60, maxRequests = 10, message = "登录过于频繁，请稍后再试")
    public Result<LoginResponse> login(@RequestBody LoginRequest request) {
        ...
    }

    /**
     * 发送短信验证码 - 严格限流
     * 60 秒内最多 3 次请求
     */
    @PostMapping("/captcha")
    @RateLimit(time = 60, maxRequests = 3, message = "操作过于频繁，请稍后再试")
    public Result<Void> sendCaptcha(@RequestParam String phone) {
        ...
    }
}
```

### 限流优先级

**注解限流 > 配置化限流 > 默认限流**

1. 如果接口上有 `@RateLimit` 注解，优先使用注解配置
2. 如果没有注解，查找配置文件中匹配的规则
3. 如果都没有，使用默认配置（`default-max-requests`）

### Redis 限流实现（滑动窗口算法）

```java
/**
 * Redis 限流工具类
 */
@Component
public class RateLimiter {

    private final RedisTemplate<String, Object> redisTemplate;

    public boolean tryAcquire(String key, long maxRequests, long timeWindow, TimeUnit timeUnit) {
        long currentTime = System.currentTimeMillis();
        long windowStart = currentTime - timeUnit.toMillis(timeWindow);
        String redisKey = "rate_limit:" + key;

        // 使用 Redis 事务保证原子性
        Boolean result = redisTemplate.execute(connection -> {
            // 移除窗口外的请求记录
            connection.zSetCommands().zRemRangeByScore(
                redisKey.getBytes(), 0, windowStart
            );

            // 获取当前窗口内的请求数
            Long count = connection.zSetCommands().zCard(redisKey.getBytes());

            if (count != null && count >= maxRequests) {
                return false;  // 超过限流阈值
            }

            // 添加当前请求记录
            connection.zSetCommands().zAdd(
                redisKey.getBytes(), currentTime, String.valueOf(currentTime).getBytes()
            );

            // 设置过期时间
            connection.expire(redisKey.getBytes(), timeWindow);

            return true;
        }, false);

        return Boolean.TRUE.equals(result);
    }
}
```

### 常见限流场景建议值

| 接口类型 | 时间窗口 | 最大请求数 | 说明 |
|---------|---------|-----------|------|
| 登录接口 | 60 秒 | 10 | 防止暴力破解 |
| 验证码/短信 | 60 秒 | 3-5 | 防止短信轰炸 |
| 文件上传 | 60 秒 | 30 | 防止资源滥用 |
| 普通业务接口 | 60 秒 | 100-1000 | 根据业务调整 |
| 导出接口 | 60 秒 | 5-10 | 防止服务器过载 |

### 注意事项

1. **Redis 依赖**：限流功能依赖 Redis，需确保 Redis 服务可用
2. **异常情况放行**：当 Redis 不可用时，建议放行请求，避免影响正常业务
3. **集群部署**：滑动窗口算法天然支持分布式，无需额外配置
4. **监控告警**：建议添加限流触发的监控告警，及时发现异常流量

---

## 十三、中间表设计规范（逻辑删除与唯一索引）

### 核心原则

**中间表（关联表）不使用逻辑删除，使用物理删除，不设置联合唯一索引。**

### 表类型分类

| 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 |
|--------|------|----------|----------|--------|
| **A 类：纯关联表（中间表）** | 只表示 A 和 B 的关系，无额外状态，先删后增的更新模式 | **物理删除**（不设 deleted） | **不设置**联合唯一索引 | `course_collection_package`、`tenant_course`、`class_teacher` |
| **B 类：关系表** | 关系相对稳定，有业务历史价值 | 逻辑删除 | 建议设置联合唯一索引 | `parent_student` |
| **C 类：配置表** | (key, value) 结构，天然应该唯一 | 逻辑删除 | **设置**联合唯一索引 | `system_setting` |
| **D 类：合同/授权表** | 有合同性质，需要保留历史 | 逻辑删除 | 不设置联合唯一索引 | `tenant_package` |

### A 类表（纯关联表）设计规范

#### 1. 为什么不使用逻辑删除？

**原因**：
- 中间表无业务历史价值，删除后不需要保留记录
- 逻辑删除会导致数据无限膨胀（每次删除都保留）
- 与唯一索引配合使用时会产生冲突（同一组合反复删除会冲突）
- 物理删除更符合业务直觉，代码更简洁

#### 2. 为什么不设置联合唯一索引？

**原因**：
- 单体应用 + 低并发场景下，并发冲突概率极低
- 应用层校验（保存前去重、添加前检查）足够可靠
- 批量操作时，有索引会导致一条失败全回滚，体验差
- 先删后增的更新模式下，唯一索引无实际意义

**风险可控**：
- 应用层有完善的校验逻辑
- 定时任务检查脏数据
- 接受极端情况下的脏数据风险（概率极低）

#### 3. 实体类写法

```java
package com.reading.platform.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 课程套餐与课程包关联实体
 * <p>
 * 中间表，不设置逻辑删除字段，使用物理删除
 * </p>
 */
@Schema(description = "课程套餐与课程包关联实体")
@Data
@TableName("course_collection_package")
public class CourseCollectionPackage {

    @Schema(description = "主键 ID")
    @TableId(type = IdType.AUTO)
    private Long id;

    @Schema(description = "课程套餐 ID")
    private Long collectionId;

    @Schema(description = "课程包 ID")
    private Long packageId;

    @Schema(description = "排序号")
    private Integer sortOrder;

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

    @Schema(description = "更新时间")
    private LocalDateTime updatedAt;
}
```

**关键点**：
- **不继承 `BaseEntity`**，因此没有 `deleted` 字段
- 保留 `createdAt`、`updatedAt` 用于审计
- 主键使用 `@TableId(type = IdType.AUTO)` 自增

#### 4. Service 层写法

```java
@Service
@RequiredArgsConstructor
public class CourseCollectionPackageService {

    private final CourseCollectionPackageMapper mapper;

    /**
     * 更新套餐包含的课程包（整包提交模式）
     * 先删后增，物理删除
     */
    @Transactional(rollbackFor = Exception.class)
    public void updatePackages(Long collectionId, List<Long> packageIds) {
        if (CollectionUtils.isEmpty(packageIds)) {
            throw new BusinessException("课程包不能为空");
        }

        // 1. 去重（静默处理，不报错）
        Set<Long> uniquePackageIds = new LinkedHashSet<>(packageIds);

        // 2. 物理删除所有关联（不使用逻辑删除）
        mapper.delete(new LambdaQueryWrapper<CourseCollectionPackage>()
            .eq(CourseCollectionPackage::getCollectionId, collectionId));

        // 3. 批量插入
        List<CourseCollectionPackage> records = new ArrayList<>();
        int sortOrder = 0;
        for (Long packageId : uniquePackageIds) {
            CourseCollectionPackage record = new CourseCollectionPackage();
            record.setCollectionId(collectionId);
            record.setPackageId(packageId);
            record.setSortOrder(sortOrder++);
            records.add(record);
        }
        saveBatch(records);
    }

    /**
     * 单个添加课程包（增量添加）
     */
    @Transactional(rollbackFor = Exception.class)
    public void addPackage(Long collectionId, Long packageId) {
        // 1. 应用层校验（友好提示）
        Long count = count(new LambdaQueryWrapper<CourseCollectionPackage>()
            .eq(CourseCollectionPackage::getCollectionId, collectionId)
            .eq(CourseCollectionPackage::getPackageId, packageId));

        if (count > 0) {
            throw new BusinessException("该课程包已存在于套餐中");
        }

        // 2. 插入
        CourseCollectionPackage record = new CourseCollectionPackage();
        record.setCollectionId(collectionId);
        record.setPackageId(packageId);
        save(record);
    }
}
```

#### 5. 数据库表设计

```sql
CREATE TABLE `course_collection_package` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `collection_id` BIGINT NOT NULL COMMENT '课程套餐 ID',
    `package_id` BIGINT NOT NULL COMMENT '课程包 ID',
    `sort_order` INT DEFAULT 0 COMMENT '排序号',
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    -- 不设置联合唯一索引
    KEY `idx_collection_id` (`collection_id`),
    KEY `idx_package_id` (`package_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程套餐 - 课程包关联表';
```

### 数据一致性保障

#### 应用层校验

```java
// 1. 保存前去重
Set<Long> uniqueIds = new LinkedHashSet<>(ids);

// 2. 添加前检查
Long count = count(new LambdaQueryWrapper<Entity>()
    .eq(Entity::getField1, value1)
    .eq(Entity::getField2, value2));
if (count > 0) {
    throw new BusinessException("记录已存在");
}

// 3. 定时任务检查脏数据
@Scheduled(cron = "0 0 2 * * ?")
public void checkDuplicateData() {
    // 检查并告警
}
```

#### 事务保障

- 所有中间表操作必须使用 `@Transactional` 注解
- 先删后增操作在同一事务中完成
- 异常时自动回滚

### B/C/D 类表设计规范

| 表类型 |  deleted  | 联合唯一索引 | 说明 |
|--------|----------|-------------|------|
| B 类（关系表） | ✅ 保留 | ✅ 建议设置 | 如 `parent_student` |
| C 类（配置表） | ✅ 保留 | ✅ 必须设置 | 如 `system_setting` |
| D 类（合同/授权表） | ✅ 保留 | ❌ 不设置 | 如 `tenant_package` |

### 决策流程图

```
新表设计 → 是否需要保留删除历史？
             ├─ 否 → 纯关联表（A 类）→ 物理删除，不设置唯一索引
             └─ 是 → 是否有稳定的业务关系？
                       ├─ 是 → 关系表（B 类）→ 逻辑删除，建议设置唯一索引
                       └─ 否 → 配置表/合同表（C/D 类）→ 逻辑删除
```

### 总结

| 维度 | 传统做法 | 推荐做法（单体应用） |
|------|---------|---------------------|
| 中间表删除策略 | 逻辑删除 | **物理删除** |
| 中间表唯一索引 | 必须设置 | **不设置**（应用层校验） |
| 数据一致性 | 数据库兜底 | 应用层校验 + 事务 |
| 适用场景 | 高并发、分布式 | 单体应用、低并发 |
