From 60213b513d9464777743140161116f9243830091 Mon Sep 17 00:00:00 2001 From: En Date: Thu, 19 Mar 2026 16:38:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor(entity):=20=E4=B8=AD=E9=97=B4=E8=A1=A8?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20-=20=E7=A7=BB=E9=99=A4=E8=81=94=E5=90=88?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95=E5=92=8C=20deleted=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/CLAUDE.md | 68 ++++++ docs/统一开发规范.md | 225 ++++++++++++++++++ .../reading/platform/entity/ClassTeacher.java | 22 +- .../entity/CourseCollectionPackage.java | 25 +- .../reading/platform/entity/TenantCourse.java | 20 +- .../V41__optimize_association_tables.sql | 69 ++++++ 6 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 reading-platform-java/src/main/resources/db/migration/V41__optimize_association_tables.sql diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 40ccda0..e478ebb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -839,3 +839,71 @@ DRAFT(草稿)→ 提交审核 → PENDING(待审核)→ 审核通过 → - **APPROVED**: 可发布 - **PUBLISHED**: 可下架 - **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 packageIds) { + // 1. 去重 + Set uniquePackageIds = new LinkedHashSet<>(packageIds); + + // 2. 物理删除所有关联 + mapper.delete(new LambdaQueryWrapper() + .eq(CourseCollectionPackage::getCollectionId, collectionId)); + + // 3. 批量插入 + List records = new ArrayList<>(); + int sortOrder = 0; + for (Long packageId : uniquePackageIds) { + CourseCollectionPackage record = new CourseCollectionPackage(); + record.setCollectionId(collectionId); + record.setPackageId(packageId); + record.setSortOrder(sortOrder++); + records.add(record); + } + saveBatch(records); +} +``` + +### 数据库迁移 + +- `V41__optimize_association_tables.sql` - 移除中间表的联合唯一索引和 deleted 字段 diff --git a/docs/统一开发规范.md b/docs/统一开发规范.md index d3326ce..4a9e70d 100644 --- a/docs/统一开发规范.md +++ b/docs/统一开发规范.md @@ -3484,3 +3484,228 @@ public class RateLimiter { 2. **异常情况放行**:当 Redis 不可用时,建议放行请求,避免影响正常业务 3. **集群部署**:滑动窗口算法天然支持分布式,无需额外配置 4. **监控告警**:建议添加限流触发的监控告警,及时发现异常流量 + +--- + +## 十三、中间表设计规范(逻辑删除与唯一索引) + +### 核心原则 + +**中间表(关联表)不使用逻辑删除,使用物理删除,不设置联合唯一索引。** + +### 表类型分类 + +| 表类型 | 特点 | 删除策略 | 唯一索引 | 示例表 | +|--------|------|----------|----------|--------| +| **A 类:纯关联表(中间表)** | 只表示 A 和 B 的关系,无额外状态,先删后增的更新模式 | **物理删除**(不设 deleted) | **不设置**联合唯一索引 | `course_collection_package`、`tenant_course`、`class_teacher` | +| **B 类:关系表** | 关系相对稳定,有业务历史价值 | 逻辑删除 | 建议设置联合唯一索引 | `parent_student` | +| **C 类:配置表** | (key, value) 结构,天然应该唯一 | 逻辑删除 | **设置**联合唯一索引 | `system_setting` | +| **D 类:合同/授权表** | 有合同性质,需要保留历史 | 逻辑删除 | 不设置联合唯一索引 | `tenant_package` | + +### A 类表(纯关联表)设计规范 + +#### 1. 为什么不使用逻辑删除? + +**原因**: +- 中间表无业务历史价值,删除后不需要保留记录 +- 逻辑删除会导致数据无限膨胀(每次删除都保留) +- 与唯一索引配合使用时会产生冲突(同一组合反复删除会冲突) +- 物理删除更符合业务直觉,代码更简洁 + +#### 2. 为什么不设置联合唯一索引? + +**原因**: +- 单体应用 + 低并发场景下,并发冲突概率极低 +- 应用层校验(保存前去重、添加前检查)足够可靠 +- 批量操作时,有索引会导致一条失败全回滚,体验差 +- 先删后增的更新模式下,唯一索引无实际意义 + +**风险可控**: +- 应用层有完善的校验逻辑 +- 定时任务检查脏数据 +- 接受极端情况下的脏数据风险(概率极低) + +#### 3. 实体类写法 + +```java +package com.reading.platform.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 课程套餐与课程包关联实体 + *

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

+ */ +@Schema(description = "课程套餐与课程包关联实体") +@Data +@TableName("course_collection_package") +public class CourseCollectionPackage { + + @Schema(description = "主键 ID") + @TableId(type = IdType.AUTO) + private Long id; + + @Schema(description = "课程套餐 ID") + private Long collectionId; + + @Schema(description = "课程包 ID") + private Long packageId; + + @Schema(description = "排序号") + private Integer sortOrder; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} +``` + +**关键点**: +- **不继承 `BaseEntity`**,因此没有 `deleted` 字段 +- 保留 `createdAt`、`updatedAt` 用于审计 +- 主键使用 `@TableId(type = IdType.AUTO)` 自增 + +#### 4. Service 层写法 + +```java +@Service +@RequiredArgsConstructor +public class CourseCollectionPackageService { + + private final CourseCollectionPackageMapper mapper; + + /** + * 更新套餐包含的课程包(整包提交模式) + * 先删后增,物理删除 + */ + @Transactional(rollbackFor = Exception.class) + public void updatePackages(Long collectionId, List packageIds) { + if (CollectionUtils.isEmpty(packageIds)) { + throw new BusinessException("课程包不能为空"); + } + + // 1. 去重(静默处理,不报错) + Set uniquePackageIds = new LinkedHashSet<>(packageIds); + + // 2. 物理删除所有关联(不使用逻辑删除) + mapper.delete(new LambdaQueryWrapper() + .eq(CourseCollectionPackage::getCollectionId, collectionId)); + + // 3. 批量插入 + List records = new ArrayList<>(); + int sortOrder = 0; + for (Long packageId : uniquePackageIds) { + CourseCollectionPackage record = new CourseCollectionPackage(); + record.setCollectionId(collectionId); + record.setPackageId(packageId); + record.setSortOrder(sortOrder++); + records.add(record); + } + saveBatch(records); + } + + /** + * 单个添加课程包(增量添加) + */ + @Transactional(rollbackFor = Exception.class) + public void addPackage(Long collectionId, Long packageId) { + // 1. 应用层校验(友好提示) + Long count = count(new LambdaQueryWrapper() + .eq(CourseCollectionPackage::getCollectionId, collectionId) + .eq(CourseCollectionPackage::getPackageId, packageId)); + + if (count > 0) { + throw new BusinessException("该课程包已存在于套餐中"); + } + + // 2. 插入 + CourseCollectionPackage record = new CourseCollectionPackage(); + record.setCollectionId(collectionId); + record.setPackageId(packageId); + save(record); + } +} +``` + +#### 5. 数据库表设计 + +```sql +CREATE TABLE `course_collection_package` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `collection_id` BIGINT NOT NULL COMMENT '课程套餐 ID', + `package_id` BIGINT NOT NULL COMMENT '课程包 ID', + `sort_order` INT DEFAULT 0 COMMENT '排序号', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- 不设置联合唯一索引 + KEY `idx_collection_id` (`collection_id`), + KEY `idx_package_id` (`package_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程套餐 - 课程包关联表'; +``` + +### 数据一致性保障 + +#### 应用层校验 + +```java +// 1. 保存前去重 +Set uniqueIds = new LinkedHashSet<>(ids); + +// 2. 添加前检查 +Long count = count(new LambdaQueryWrapper() + .eq(Entity::getField1, value1) + .eq(Entity::getField2, value2)); +if (count > 0) { + throw new BusinessException("记录已存在"); +} + +// 3. 定时任务检查脏数据 +@Scheduled(cron = "0 0 2 * * ?") +public void checkDuplicateData() { + // 检查并告警 +} +``` + +#### 事务保障 + +- 所有中间表操作必须使用 `@Transactional` 注解 +- 先删后增操作在同一事务中完成 +- 异常时自动回滚 + +### B/C/D 类表设计规范 + +| 表类型 | deleted | 联合唯一索引 | 说明 | +|--------|----------|-------------|------| +| B 类(关系表) | ✅ 保留 | ✅ 建议设置 | 如 `parent_student` | +| C 类(配置表) | ✅ 保留 | ✅ 必须设置 | 如 `system_setting` | +| D 类(合同/授权表) | ✅ 保留 | ❌ 不设置 | 如 `tenant_package` | + +### 决策流程图 + +``` +新表设计 → 是否需要保留删除历史? + ├─ 否 → 纯关联表(A 类)→ 物理删除,不设置唯一索引 + └─ 是 → 是否有稳定的业务关系? + ├─ 是 → 关系表(B 类)→ 逻辑删除,建议设置唯一索引 + └─ 否 → 配置表/合同表(C/D 类)→ 逻辑删除 +``` + +### 总结 + +| 维度 | 传统做法 | 推荐做法(单体应用) | +|------|---------|---------------------| +| 中间表删除策略 | 逻辑删除 | **物理删除** | +| 中间表唯一索引 | 必须设置 | **不设置**(应用层校验) | +| 数据一致性 | 数据库兜底 | 应用层校验 + 事务 | +| 适用场景 | 高并发、分布式 | 单体应用、低并发 | diff --git a/reading-platform-java/src/main/java/com/reading/platform/entity/ClassTeacher.java b/reading-platform-java/src/main/java/com/reading/platform/entity/ClassTeacher.java index 57b8585..5afa25b 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/entity/ClassTeacher.java +++ b/reading-platform-java/src/main/java/com/reading/platform/entity/ClassTeacher.java @@ -1,18 +1,27 @@ 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 lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; /** * 班级教师关系实体 + *

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

*/ @Schema(description = "班级教师关系实体") @Data -@EqualsAndHashCode(callSuper = true) @TableName("class_teacher") -public class ClassTeacher extends BaseEntity { +public class ClassTeacher { + + @Schema(description = "主键 ID") + @TableId(type = IdType.AUTO) + private Long id; @Schema(description = "班级 ID") private Long classId; @@ -23,8 +32,9 @@ public class ClassTeacher extends BaseEntity { @Schema(description = "角色") private String role; - // 暂时注释掉数据库中不存在的字段 - // @Schema(description = "是否主班") - // private Integer isPrimary; + @Schema(description = "创建时间") + private LocalDateTime createdAt; + @Schema(description = "更新时间") + private LocalDateTime updatedAt; } diff --git a/reading-platform-java/src/main/java/com/reading/platform/entity/CourseCollectionPackage.java b/reading-platform-java/src/main/java/com/reading/platform/entity/CourseCollectionPackage.java index 74bccfb..cc07fc2 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/entity/CourseCollectionPackage.java +++ b/reading-platform-java/src/main/java/com/reading/platform/entity/CourseCollectionPackage.java @@ -1,25 +1,40 @@ 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 lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; /** * 课程套餐与课程包关联实体 + *

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

*/ @Schema(description = "课程套餐与课程包关联实体") @Data -@EqualsAndHashCode(callSuper = true) @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; - @Schema(description = "课程包ID") + @Schema(description = "课程包 ID") private Long packageId; @Schema(description = "排序号") private Integer sortOrder; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; } diff --git a/reading-platform-java/src/main/java/com/reading/platform/entity/TenantCourse.java b/reading-platform-java/src/main/java/com/reading/platform/entity/TenantCourse.java index 7dc1c67..63173e9 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/entity/TenantCourse.java +++ b/reading-platform-java/src/main/java/com/reading/platform/entity/TenantCourse.java @@ -1,18 +1,27 @@ 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 lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; /** * 租户课程关联实体 + *

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

*/ @Schema(description = "租户课程关联实体") @Data -@EqualsAndHashCode(callSuper = true) @TableName("tenant_course") -public class TenantCourse extends BaseEntity { +public class TenantCourse { + + @Schema(description = "主键 ID") + @TableId(type = IdType.AUTO) + private Long id; @Schema(description = "租户 ID") private Long tenantId; @@ -23,4 +32,9 @@ public class TenantCourse extends BaseEntity { @Schema(description = "是否启用") private Integer enabled; + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; } diff --git a/reading-platform-java/src/main/resources/db/migration/V41__optimize_association_tables.sql b/reading-platform-java/src/main/resources/db/migration/V41__optimize_association_tables.sql new file mode 100644 index 0000000..853dca8 --- /dev/null +++ b/reading-platform-java/src/main/resources/db/migration/V41__optimize_association_tables.sql @@ -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 字段