【听见课堂 HarmonyOS NEXT 实战系列 17】课程删除不是删一行:跨表级联与二次确认设计

在课堂应用里,“删除课程”很少只是删除一条课程信息。课程下面还挂着实时字幕、板书扫描、任务候选、已确认任务以及由这些数据计算出的复盘和历史视图。如果只删除 courses 表的一行,页面也许暂时看起来少了一张课程卡,但数据库中会留下无法归属的证据记录;如果先删子表却在中途失败,又可能出现只剩一半数据的危险状态。

听见课堂把这项操作拆成三层:页面提供明确的二次确认和取消路径,Service 拒绝空课程 ID,Repository 在一个事务中依次删除字幕、扫描、任务和课程。成功后页面不手工清空几个数组,而是重新读取 canonical data,进入可解释的空态。本文结合真实代码与 P03 破坏性流程证据,完整分析这条删除链路。

课程跨表级联删除与二次确认

一、先画清楚“课程”下面到底有什么

当前 RelationalStore 数据层以课程为聚合根,围绕一门课程保存四类业务数据:

数据 与课程的关系 删除后的产品影响
课程元信息 courses 聚合根 首页课程卡、课程编辑入口消失
字幕片段 transcript_segments course_id 归属课程 实时字幕回读、重点统计和时间线清空
扫描笔记 scan_notes course_id 归属课程 板书、OCR 内容和证据来源清空
任务 tasks course_id 归属课程 候选、已确认、完成状态和任务统计清空

复盘页、任务中心和历史页没有各自独立的“统计真相”,它们都是 Service 从这四类数据重新聚合出来的投影视图。因此删除范围不能只看当前页面展示了什么,而要沿着数据归属关系找到所有下游消费者。

二、为什么只执行 DELETE FROM courses 不够

如果数据库没有启用并验证外键级联,只删除父表会留下孤儿数据:

courses:course-physics-01 已不存在
transcript_segments:仍有 4 条 course_id=course-physics-01
scan_notes:仍有 2 条 course_id=course-physics-01
tasks:仍有 3 条 course_id=course-physics-01

这些记录在“今日课程”页面可能不可见,但其他查询若没有同时按有效课程过滤,历史时间线、任务中心或导出功能仍可能读到它们。更麻烦的是,用户重新创建同 ID 课程后,旧证据可能错误地重新出现。

所以删除课程的后置条件应是:目标课程不存在,所有属于该课程的字幕、扫描和任务也不存在,所有聚合视图重新计算后不再引用它。只让页面隐藏课程卡不算删除完成。

三、Repository 接口把删除定义成一个业务动作

ClassroomRepository 没有向页面暴露四个表的删除方法,而是定义一个聚合级动作:

export interface ClassroomRepository {
  getTodayCourse(): Promise<CourseSummary>;
  getTranscript(): Promise<Array<TranscriptSegment>>;
  getScanNotes(): Promise<Array<ScanNote>>;
  getTasks(): Promise<Array<TaskItem>>;
  deleteCourse(courseId: string): Promise<boolean>;
}

这条接口有两个重要含义。第一,调用方只表达“删除这门课程及其关联数据”,不需要知道底层有多少张表。第二,RelationalStore 与内存降级实现必须维持相同业务语义:成功后课程、字幕、扫描和任务一起消失。

如果页面分别调用 deleteTranscript()deleteScans()deleteTasks()deleteCourse(),事务边界会被拆散,未来新增“课堂附件”表时也容易漏删。聚合级接口能把变化封装在 Repository 内。

四、Service 先拒绝无效课程 ID

页面状态可能在加载、删除后刷新或快速返回时发生变化。Service 对空 ID 做了一道低成本门禁:

async deleteCourse(courseId: string): Promise<boolean> {
  if (courseId.length === 0) {
    return false;
  }
  return this.repository.deleteCourse(courseId);
}

这不是安全边界的全部,但可以避免明显无意义的数据库事务。页面层同样会判断当前课程是否存在,用可理解的文案提示“当前没有可删除的课程”。

更严格的版本还可以做 trim()、课程归属校验或版本号校验,但当前项目是本机单课程模型,源码没有账号、租户和远端权限体系,不能把它描述成已实现多用户鉴权。

五、手工级联为什么先删子表再删父表

RelationalStore 实现先删除三个子表,再删除课程父记录:

async deleteCourse(courseId: string): Promise<boolean> {
  const store: relationalStore.RdbStore = this.getStore();
  store.beginTransaction();
  try {
    await this.deleteWhere(TABLE_TRANSCRIPT, 'course_id', courseId);
    await this.deleteWhere(TABLE_SCANS, 'course_id', courseId);
    await this.deleteWhere(TABLE_TASKS, 'course_id', courseId);
    const deletedRows: number = await this.deleteWhere(TABLE_COURSES, 'id', courseId);
    store.commit();
    return deletedRows > 0;
  } catch (error) {
    store.rollBack();
    throw new Error('Failed to delete course data.');
  }
}

“子表在前、父表在后”既符合聚合拆除顺序,也为以后增加外键约束保留了兼容空间。若直接先删父表,启用外键后可能失败;即使当前没有外键,先删父表也会在事务调试期间制造短暂的无父记录状态。

这里的 deleteWhere 把条件限定为目标 course_id,不会执行无条件全表删除。它与设置页的 clearAllData() 是两个不同动作,不能混用。

六、事务保护的是“全删或全不删”

删除链路包含四次写操作。假设字幕和扫描删除成功,删除任务时发生存储异常。如果没有事务,课程还在,但关联证据已经损失一部分;用户重试也无法恢复被提前删除的数据。

事务把后置条件收敛为两种:

成功:课程 + 字幕 + 扫描 + 任务全部删除
失败:四类数据保持删除前状态

beginTransaction() 在第一条删除前开启,全部操作成功后才 commit();任何异常都走 rollBack()。页面收到异常后显示“删除失败,课程数据未变更,请稍后重试”,这条文案与事务承诺保持一致。

需要注意,文章讨论的是当前代码设计。要把“回滚一定成功”提升为运行结论,还应通过故障注入让第 N 次删除抛错,并在异常后回读四张表。正常路径测试不能替代失败路径证明。

七、父表删除行数决定最终返回值

三个子表删除零行并不一定是错误:一门刚创建的课程可以还没有字幕、扫描和任务。因此实现不应要求每个子表都至少删除一行。

最终以课程表删除行数判断目标是否存在:

const deletedRows: number = await this.deleteWhere(
  TABLE_COURSES,
  'id',
  courseId
);
store.commit();
return deletedRows > 0;

这能区分“删除了一门空课程”和“传入了不存在的课程 ID”。不过当前实现会在父记录不存在时提交对子表的清理,然后返回 false。在单课程数据模型下这通常只是幂等清理;若未来需要严格审计,可先查询课程版本,或者把不存在定义为明确的领域结果,而不是只有一个布尔值。

八、二次确认不是装饰,而是产品事务的第一道门

数据库事务保护技术失败,二次确认保护用户误操作。听见课堂使用 courseDeleteConfirmation 控制确认态:

private requestCourseDelete(): void {
  if (this.course.id.length === 0) {
    this.notice = '当前没有可删除的课程';
    return;
  }
  this.courseDeleteConfirmation = true;
  this.notice = '';
}

第一次点击只展开危险操作说明和“取消 / 确认删除”按钮,不立即写数据库。用户需要第二次明确点击确认,才会调用异步删除。

高风险按钮应使用稳定、清楚的动词,不能把“确定”放在没有上下文的弹窗里。确认区域还应说明影响范围,例如课程、字幕、板书和任务都会删除,且删除后不会因为重启自动回填。

从二次确认到事务提交再到空态刷新

九、取消路径必须证明数据真的没变

取消按钮执行的是纯 UI 状态恢复:

private cancelCourseDelete(): void {
  this.courseDeleteConfirmation = false;
  this.notice = '已取消删除,课程数据保持不变';
}

取消操作不应发起 Repository 请求,也不应该顺手清空草稿、筛选条件或统计。验收时不能只看确认区域消失,还要返回首页、复盘和任务中心检查原数据仍然存在。

对于键盘和屏幕阅读器用户,确认区域还要保证焦点顺序可预测,取消和确认按钮具有清晰的可访问名称;不能只依靠红色表示危险。当前项目在手机和 2in1 上都有 48vp 左右的主要操作尺寸基线,但真实屏幕阅读器播报仍属于需要单独复核的边界。

十、确认删除后不要手工清空几个页面变量

页面确认成功后的流程是:清除短期交互状态,调用统一刷新,再导航到首页:

if (await this.service.deleteCourse(courseId)) {
  this.courseDeleteConfirmation = false;
  this.editingCandidateTaskId = '';
  this.lastConfirmedTaskId = '';
  await this.refreshData();
  this.notice = '课程及关联课堂记录已从本机删除';
  this.navigate(RouteId.HOME);
}

这里没有写 this.transcript = []this.scanNotes = []this.confirmedTasks = [] 之类的手工补丁。原因是页面本地数组不是权威数据源。数据库提交后重新读取,才能同时更新课程、字幕、扫描、候选任务、已确认任务、复盘快照、任务中心快照和历史快照。

如果只清空当前页面能看到的数组,切换到另一个 Tab 时很可能仍显示旧统计。这正是下一篇要继续讨论的 canonical data 与写后刷新问题。

十一、删除后的空态也属于业务结果

getTodayCourse() 返回空 ID,首页展示“还没有课程”,并提供重新创建入口;同时说明“本机数据已清理,已删除课程不会在应用重启后自动恢复”。

空态不是失败页,也不应该被默认演示数据偷偷覆盖。它至少要回答三个问题:

  • 现在为什么没有内容;
  • 已有数据发生了什么;
  • 用户下一步可以做什么。

听见课堂此前通过 seed_initialized 与业务表状态区分首次安装和用户主动清空。只有真正首次初始化才写入演示基线,删除后的正常启动不会再次 Seed。这让“删除”成为可持续的状态,而不是一刷新就反悔的动画。

十二、内存降级实现也要保持级联语义

内存 Repository 没有事务对象,但删除后同样清空整个课程聚合:

async deleteCourse(courseId: string): Promise<boolean> {
  if (courseId.length === 0 || courseId !== this.course.id) {
    return false;
  }
  this.course = new CourseSummary('', '', '', '', '', 0);
  this.transcript = [];
  this.scans = [];
  this.tasks = [];
  return true;
}

这种实现保证页面不需要根据存储模式写两套删除逻辑。但能力边界必须透明:内存删除只影响当前进程内数据,RelationalStore 删除才具有本机持久化回读语义。页面顶部展示当前存储模式,可以避免把降级体验误认为数据库已正常工作。

十三、课程删除和“清空全部数据”不是同一个按钮

课程编辑页的删除针对一个 courseId,设置页的 clearAllData() 则无条件清空所有课程业务表。当前项目虽然是单课程模型,两者结果可能接近,但产品语义不同:

  • 删除课程:从某门课程的编辑上下文发起,影响该课程聚合;
  • 清空全部数据:从数据管理入口发起,强调所有课堂业务数据;
  • 两者都不修改系统麦克风、相机权限;
  • 两者都不应删除应用外部文件;
  • 设置偏好是否保留,需要由产品单独定义。

听见课堂的全量清理会保留主题、字幕字号等设置偏好,并重置实时会话和页面选择态。这种边界应写在确认文案和测试用例里,而不是由用户猜测。

十四、故障、重复点击和并发要怎样防

危险操作还需要考虑三个边界。

第一,快速重复点击。确认按钮应在请求进行中禁用,避免同时开启两个删除事务。当前课程删除流程还没有独立的 busy 状态,后续可复用设置页的 isSettingsMutationBusy 思路。

第二,旧页面状态。用户在确认区域展开后,如果课程被其他流程修改或删除,页面手里的 ID 可能过期。严格实现可以在 Repository 中校验版本号或更新时间,冲突时返回“内容已变化,请刷新后重试”。

第三,回滚和日志。错误日志应记录操作类别、课程匿名标识和错误码,不要打印完整字幕、扫描正文或教师姓名。若回滚失败,应将存储标记为需要重新初始化,而不是继续假装可写。

当前源码没有完整的并发版本控制和课程删除 busy 门禁,文章把它们列为演进项,不声称已经实现。

十五、怎样验证跨表删除不是“看起来成功”

一套有效的删除测试至少包括:

  1. 准备一门课程,并确保四张表都存在可识别记录;
  2. 第一次点击删除,确认只出现二次确认,数据库未变化;
  3. 点击取消,回读课程、字幕、扫描和任务,数量与内容不变;
  4. 再次发起并确认删除,四张表按目标课程回读为空;
  5. 检查首页空态、复盘统计、任务中心、历史记录和导出结果;
  6. 强停应用,确认进程变化后重启,再次检查空态;
  7. 验证主题、字幕字号等 Preferences 是否按约定保留;
  8. 恢复约定的测试基线,并再次重启确认恢复不是自动回填造成的假象。

项目 docs/qa/task007-destructive-flow-20260713/ 保存了取消、确认、删除后空态和重启后空态等运行证据。它证明了该轮约定环境中的产品流程,但不等于所有设备、升级迁移和故障注入都已覆盖。

十六、总结与检查清单

课程删除的核心不是 SQL 行数,而是让一个业务聚合完整、可解释地消失。听见课堂通过 Repository 聚合接口、子表优先的手工级联、RelationalStore 事务、页面二次确认和写后统一刷新,把技术原子性与用户意图串在一起。

交付前可以用这份清单复核:

  • 删除范围覆盖课程、字幕、扫描和任务;
  • 删除条件严格限定目标 courseId
  • 子表与父表操作位于同一事务;
  • 异常会回滚,页面不会展示成功态;
  • 第一次点击只进入确认态,不写数据;
  • 取消路径不调用 Repository,数据保持不变;
  • 成功后从 canonical data 统一刷新所有快照;
  • 删除后显示可行动的空态,不自动 Seed;
  • 内存降级与数据库实现保持相同业务语义;
  • 课程删除与全量清理的影响范围文案不同;
  • 权限、应用外文件和设置偏好的边界明确;
  • 重复点击、故障注入、强停重启和基线恢复分别验证;
  • 未完成的并发控制和真机覆盖不包装成已完成能力。

下一篇将继续沿着删除后的页面刷新链路,分析为什么写完数据不能在界面里手工“加一减一”,而应该重新读取 canonical data 并由 Service 重算复盘、任务和历史快照。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐