refactor(entity): 中间表优化 - 移除联合唯一索引和 deleted 字段

- CourseCollectionPackage、TenantCourse、ClassTeacher 改为独立实体类
- 不再继承 BaseEntity,移除 deleted 字段,使用物理删除
- 创建 V41 迁移脚本移除联合唯一索引和 deleted 字段
- 更新中间表设计规范到 CLAUDE.md 和统一开发规范.md

 refactor(entity): 中间表优化 - 移除联合唯一索引和 deleted 字段

- CourseCollectionPackage、TenantCourse、ClassTeacher 改为独立实体类
- 不再继承 BaseEntity,移除 deleted 字段,使用物理删除
- 创建 V41 迁移脚本移除联合唯一索引和 deleted 字段
- 更新中间表设计规范到 CLAUDE.md 和统一开发规范.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-03-19 16:38:51 +08:00
parent 7743ae7a01
commit 60213b513d
6 changed files with 415 additions and 14 deletions

View File

@ -839,3 +839,71 @@ DRAFT草稿→ 提交审核 → PENDING待审核→ 审核通过 →
- **APPROVED**: 可发布 - **APPROVED**: 可发布
- **PUBLISHED**: 可下架 - **PUBLISHED**: 可下架
- **OFFLINE**: 终止状态 - **OFFLINE**: 终止状态
---
## 中间表设计规范2026-03-19
### 核心原则
**中间表(关联表)不使用逻辑删除,使用物理删除,不设置联合唯一索引。**
### 表类型分类
| 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 |
|--------|------|----------|----------|--------|
| **A 类:纯关联表(中间表)** | 只表示 A 和 B 的关系,无额外状态,先删后增的更新模式 | **物理删除**(不设 deleted | **不设置**联合唯一索引 | `course_collection_package`、`tenant_course`、`class_teacher` |
| **B 类:关系表** | 关系相对稳定,有业务历史价值 | 逻辑删除 | 建议设置联合唯一索引 | `parent_student` |
| **C 类:配置表** | (key, value) 结构,天然应该唯一 | 逻辑删除 | **设置**联合唯一索引 | `system_setting` |
| **D 类:合同/授权表** | 有合同性质,需要保留历史 | 逻辑删除 | 不设置联合唯一索引 | `tenant_package` |
### A 类表实体类写法
```java
/**
* 中间表,不设置逻辑删除字段,使用物理删除
* 不继承 BaseEntity因此没有 deleted 字段
*/
@Data
@TableName("course_collection_package")
public class CourseCollectionPackage {
@TableId(type = IdType.AUTO)
private Long id;
private Long collectionId;
private Long packageId;
private Integer sortOrder;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
```
### Service 层操作示例
```java
// 更新套餐包含的课程包(先删后增)
@Transactional(rollbackFor = Exception.class)
public void updatePackages(Long collectionId, List<Long> packageIds) {
// 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);
}
```
### 数据库迁移
- `V41__optimize_association_tables.sql` - 移除中间表的联合唯一索引和 deleted 字段

View File

@ -3484,3 +3484,228 @@ public class RateLimiter {
2. **异常情况放行**:当 Redis 不可用时,建议放行请求,避免影响正常业务 2. **异常情况放行**:当 Redis 不可用时,建议放行请求,避免影响正常业务
3. **集群部署**:滑动窗口算法天然支持分布式,无需额外配置 3. **集群部署**:滑动窗口算法天然支持分布式,无需额外配置
4. **监控告警**:建议添加限流触发的监控告警,及时发现异常流量 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 类)→ 逻辑删除
```
### 总结
| 维度 | 传统做法 | 推荐做法(单体应用) |
|------|---------|---------------------|
| 中间表删除策略 | 逻辑删除 | **物理删除** |
| 中间表唯一索引 | 必须设置 | **不设置**(应用层校验) |
| 数据一致性 | 数据库兜底 | 应用层校验 + 事务 |
| 适用场景 | 高并发、分布式 | 单体应用、低并发 |

View File

@ -1,18 +1,27 @@
package com.reading.platform.entity; package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/** /**
* 班级教师关系实体 * 班级教师关系实体
* <p>
* 中间表不设置逻辑删除字段使用物理删除
* </p>
*/ */
@Schema(description = "班级教师关系实体") @Schema(description = "班级教师关系实体")
@Data @Data
@EqualsAndHashCode(callSuper = true)
@TableName("class_teacher") @TableName("class_teacher")
public class ClassTeacher extends BaseEntity { public class ClassTeacher {
@Schema(description = "主键 ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "班级 ID") @Schema(description = "班级 ID")
private Long classId; private Long classId;
@ -23,8 +32,9 @@ public class ClassTeacher extends BaseEntity {
@Schema(description = "角色") @Schema(description = "角色")
private String role; private String role;
// 暂时注释掉数据库中不存在的字段 @Schema(description = "创建时间")
// @Schema(description = "是否主班") private LocalDateTime createdAt;
// private Integer isPrimary;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
} }

View File

@ -1,25 +1,40 @@
package com.reading.platform.entity; package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/** /**
* 课程套餐与课程包关联实体 * 课程套餐与课程包关联实体
* <p>
* 中间表不设置逻辑删除字段使用物理删除
* </p>
*/ */
@Schema(description = "课程套餐与课程包关联实体") @Schema(description = "课程套餐与课程包关联实体")
@Data @Data
@EqualsAndHashCode(callSuper = true)
@TableName("course_collection_package") @TableName("course_collection_package")
public class CourseCollectionPackage extends BaseEntity { public class CourseCollectionPackage {
@Schema(description = "课程套餐ID") @Schema(description = "主键 ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "课程套餐 ID")
private Long collectionId; private Long collectionId;
@Schema(description = "课程包ID") @Schema(description = "课程包 ID")
private Long packageId; private Long packageId;
@Schema(description = "排序号") @Schema(description = "排序号")
private Integer sortOrder; private Integer sortOrder;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
} }

View File

@ -1,18 +1,27 @@
package com.reading.platform.entity; package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/** /**
* 租户课程关联实体 * 租户课程关联实体
* <p>
* 中间表不设置逻辑删除字段使用物理删除
* </p>
*/ */
@Schema(description = "租户课程关联实体") @Schema(description = "租户课程关联实体")
@Data @Data
@EqualsAndHashCode(callSuper = true)
@TableName("tenant_course") @TableName("tenant_course")
public class TenantCourse extends BaseEntity { public class TenantCourse {
@Schema(description = "主键 ID")
@TableId(type = IdType.AUTO)
private Long id;
@Schema(description = "租户 ID") @Schema(description = "租户 ID")
private Long tenantId; private Long tenantId;
@ -23,4 +32,9 @@ public class TenantCourse extends BaseEntity {
@Schema(description = "是否启用") @Schema(description = "是否启用")
private Integer enabled; private Integer enabled;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
} }

View File

@ -0,0 +1,69 @@
-- =====================================================
-- 迁移 V41: 中间表优化 - 移除联合唯一索引和 deleted 字段
-- 版本V41
-- 创建时间2026-03-19
-- 描述:
-- 1. 中间表使用物理删除,不再保留 deleted 字段
-- 2. 移除不必要的联合唯一索引,通过应用层控制数据一致性
-- 3. 适用于单体应用、低并发场景
-- =====================================================
-- -----------------------------------------------------
-- 1. course_collection_package课程套餐 - 课程包关联表)
-- -----------------------------------------------------
-- 说明:纯关联表,先删后增的更新模式,不需要唯一索引兜底
-- 删除联合唯一索引
ALTER TABLE `course_collection_package` DROP INDEX IF EXISTS `uk_collection_package`;
-- 删除 deleted 字段
ALTER TABLE `course_collection_package` DROP COLUMN IF EXISTS `deleted`;
-- 保留普通索引(提高查询性能)
ALTER TABLE `course_collection_package`
ADD INDEX IF NOT EXISTS `idx_collection_id` (`collection_id`),
ADD INDEX IF NOT EXISTS `idx_package_id` (`package_id`);
-- -----------------------------------------------------
-- 2. tenant_course租户 - 课程关联表)
-- -----------------------------------------------------
-- 说明:纯关联表,授权关系,不需要唯一索引兜底
-- 删除联合唯一索引
ALTER TABLE `tenant_course` DROP INDEX IF EXISTS `uk_tenant_course`;
-- 删除 deleted 字段
ALTER TABLE `tenant_course` DROP COLUMN IF EXISTS `deleted`;
-- 保留普通索引
ALTER TABLE `tenant_course`
ADD INDEX IF NOT EXISTS `idx_tenant_id` (`tenant_id`),
ADD INDEX IF NOT EXISTS `idx_course_id` (`course_id`);
-- -----------------------------------------------------
-- 3. class_teacher班级 - 教师关联表)
-- -----------------------------------------------------
-- 说明:纯关联表,不需要 deleted 字段
-- 删除 deleted 字段
ALTER TABLE `class_teacher` DROP COLUMN IF EXISTS `deleted`;
-- 保留普通索引
ALTER TABLE `class_teacher`
ADD INDEX IF NOT EXISTS `idx_class_id` (`class_id`),
ADD INDEX IF NOT EXISTS `idx_teacher_id` (`teacher_id`);
-- -----------------------------------------------------
-- 备注:以下表保留联合唯一索引和 deleted 字段
-- -----------------------------------------------------
-- 1. parent_student家长 - 学生关系)
-- - 关系相对稳定,有业务历史价值
-- - 保留 uk_parent_student 和 deleted 字段
--
-- 2. system_setting系统设置
-- - 配置表,(tenant_id, setting_key) 天然应该唯一
-- - 保留 uk_tenant_key 和 deleted 字段
--
-- 3. tenant_package租户 - 套餐授权)
-- - 合同性质,有历史价值
-- - 保留 deleted 字段