首页
/ cal.diy 取消原因必填配置(Cancellation Reason Requirement):从数据库枚举到服务端校验的完整实现

cal.diy 取消原因必填配置(Cancellation Reason Requirement):从数据库枚举到服务端校验的完整实现

2026-09-09 14:06:19作者:凌朦慧Richard

导读

本文基于 cal.diy 仓库中 specs/cancellation-reason-requirement 功能规格,系统讲解"取消原因必填(Cancellation Reason Requirement)"特性的设计、实现与使用方式。该特性允许事件类型(Event Type)所有者通过一个下拉开关,灵活配置在取消预约时,主持人(host)与参与者(attendee)是否需要填写取消原因。读完本文,你将掌握该功能的四种取值语义、数据库枚举设计、服务端校验逻辑、前端 UI 配置入口以及整个数据流链路,并可直接在仓库源码中逐一印证。

功能概览:四种取消原因必填策略

功能文档 的描述,该特性让事件类型所有者可以配置"何时必须提供取消原因"。在实现之前,cal.diy 的取消原因对所有人都是可选的(optional),主持人无法强制收集取消原因,也就难以对取消行为进行追踪与问责。

配置入口位于 Event Type → Advanced Settings(高级设置),紧跟在 Booking Questions(预约问题) 区块之后。共有四种取值,语义如下:

枚举值 界面选项 含义
MANDATORY_BOTH Mandatory for both 主持人(host)与参与者(attendee)取消时都必须填写原因
MANDATORY_HOST_ONLY Mandatory for host only(默认) 仅主持人取消时必须填写原因
MANDATORY_ATTENDEE_ONLY Mandatory for attendee only 仅参与者取消时必须填写原因
OPTIONAL_BOTH Optional for both 所有人取消时原因均为可选

设计文档 design.md 从三条用户故事出发明确了该特性的动机:

  • 作为主持人,我希望要求参与者填写取消原因,从而理解预约为什么被取消;
  • 作为主持人,我希望要求团队成员填写取消原因,从而保留取消原因的记录;
  • 作为主持人,当不需要取消原因时,我希望保持其可选。

对应地,CLAUDE.md 划定了实现边界:不得添加设计文档之外的功能、不得跳过测试、不得改动改期(reschedule)原因的行为。

数据库设计:独立枚举列而非 Metadata JSON

该特性的核心决策记录在 decisions.md 的 ADR-001 中。当时面临两种存储方案:

  1. 新增数据库列 + Prisma 枚举——需要迁移,但类型安全、查询简洁;
  2. Metadata JSON 字段——无需迁移,但作为核心设置类型安全性较弱。

最终决策采用 独立数据库列 + Prisma 枚举 CancellationReasonRequirement,理由是该设置属于核心预约流程配置,与已有的 disableCancellingdisableReschedulingrequiresConfirmation 等设置同类,数据库层类型安全、在取消校验逻辑中查询更干净,且与既有相似设置的存储方式保持一致。代价是需要一次数据库迁移,同时获得枚举值类型安全与免 JSON 解析的直接列访问。

schema.prisma 中可以看到该枚举的实际定义:

enum CancellationReasonRequirement {
  MANDATORY_BOTH
  MANDATORY_HOST_ONLY
  MANDATORY_ATTENDEE_ONLY
  OPTIONAL_BOTH
}

对应的 EventType 模型新增了可空列并带默认值(schema.prisma):

requiresCancellationReason CancellationReasonRequirement? @default(MANDATORY_HOST_ONLY)

注意该列被声明为可空(?),且默认值为 MANDATORY_HOST_ONLY。可空设计的意义在于兼容迁移前的存量数据:新增列后历史事件类型该字段为 NULL,而在运行时逻辑中 NULL 会被兜底解释为默认行为 MANDATORY_HOST_ONLY(详见下文校验逻辑)。

按照 implementation.md 的记录,实现过程中已创建对应的数据库迁移(20260115111819_add_cancellation_reason_require),并同步在英文语言包 common.json 中补充了翻译键。

服务端校验:按"谁在取消"动态判定

判定函数 isCancellationReasonRequired

校验的核心是一个纯函数,位于 packages/features/bookings/lib/cancellationReason.ts,完整实现如下:

import { CancellationReasonRequirement } from "@calcom/prisma/enums";

export function isCancellationReasonRequired(
  setting: CancellationReasonRequirement | null | undefined,
  isHost: boolean
): boolean {
  const requirement = setting ?? CancellationReasonRequirement.MANDATORY_HOST_ONLY;

  switch (requirement) {
    case CancellationReasonRequirement.OPTIONAL_BOTH:
      return false;
    case CancellationReasonRequirement.MANDATORY_BOTH:
      return true;
    case CancellationReasonRequirement.MANDATORY_HOST_ONLY:
      return isHost;
    case CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY:
      return !isHost;
    default:
      return false;
  }
}

从源码结构可以清晰看到其语义:

  • 设置值为 null/undefined(即存量数据或未显式配置)时,统一回退到 MANDATORY_HOST_ONLY,与设计文档 design.md 中"Null column value: Default to MANDATORY_HOST_ONLY behavior"的边界约定完全一致;
  • MANDATORY_BOTH 恒返回 trueOPTIONAL_BOTH 恒返回 false
  • 两个单向必填模式根据调用方是否为 host 决定结果:host 取消时只对 MANDATORY_HOST_ONLY 生效,attendee 取消时只对 MANDATORY_ATTENDEE_ONLY 生效。

取消流程中的调用链

服务端入口在 packages/features/bookings/lib/handleCancelBooking.ts。取消请求到达后,先判断"取消者是否为 host",再据此计算原因是否必填,缺失时抛出 400 错误:

const isCancellationUserHost =
  bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy;

const isReasonRequired = isCancellationReasonRequired(
  bookingToDelete.eventType?.requiresCancellationReason,
  isCancellationUserHost
);

if (!platformClientId && !cancellationReason?.trim() && isReasonRequired && !skipCancellationReasonValidation) {
  throw new HttpError({
    statusCode: 400,
    message: "Cancellation reason is required",
  });
}

这里有几处值得注意的实现细节:

  • host 判定bookingToDelete.userId === userIdbookingToDelete.user.email === cancelledBy,二者满足其一即视为主持人侧取消;
  • 平台 API 豁免platformClientId 存在(即通过平台 API 发起取消)时不强制校验,属于对平台调用方的一处兼容处理;
  • 显式跳过开关skipCancellationReasonValidation 允许调用方在特定场景跳过该校验;
  • 空白处理cancellationReason?.trim() 意味着只含空白字符的原因同样被视为"未填写"。

前端 UI:高级设置下拉与属性透传链

事件类型高级设置

UI 配置项位于 apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx,按设计文档要求放在 Booking Questions 区块之后、RequiresConfirmationController 之前,表单字段核心定义如下:

name="requiresCancellationReason"
defaultValue={eventType.requiresCancellationReason ?? CancellationReasonRequirement.MANDATORY_HOST_ONLY}
  • 标签:"Require cancellation reason"
  • 描述:"Ask for a reason when someone cancels a booking"
  • 默认值:当事件类型的字段为空时,下拉回退显示 MANDATORY_HOST_ONLY(与数据库默认值、服务端兜底逻辑三方一致)。

CLAUDE.md 的实现约定可知,该 UI 遵循了 RequiresConfirmationController 的既有模式,翻译键沿用项目现有翻译规范。

取消弹窗的属性透传

设计文档 design.md 明确列出了需要透传 requiresCancellationReason 的三个文件,源码中均可印证:

  1. apps/web/lib/booking.tsgetEventTypesFromDB 将该字段加入 select,保证从数据库查询事件类型时能取到该配置;
  2. apps/web/modules/bookings/views/bookings-single-view.tsx 作为预约详情视图,将 eventType.requiresCancellationReason 传给取消弹窗;
  3. apps/web/components/dialog/CancelBookingDialog.tsx 接收 requiresCancellationReason?: CancellationReasonRequirement | null 可选 prop 并继续下传。

最终消费端是 apps/web/components/booking/CancelBooking.tsx。该组件:

  • 声明了 requiresCancellationReason?: CancellationReasonRequirement | null 可选 prop;
  • 用配置化的校验取代了原先硬编码的 hostMissingCancellationReason 逻辑,实际校验同样复用 isCancellationReasonRequired 判定函数(源码第 168 行的调用);
  • 当判定"必填"时在文本域上显示必填标识;当判定"非必填"时,动态标签只在 isReasonRequiredForUser() 返回 false 时附加 "(optional)" 后缀(见 implementation.md 第 11 条)。

端到端数据流

综合设计与源码,完整的数据流可归纳为:

  1. 主持人保存高级设置,requiresCancellationReason 以枚举值写入 EventType 表;
  2. 读取预约/事件类型时,getEventTypesFromDB 的 select 显式包含该字段;
  3. 字段随页面 props 依次流经 bookings-single-view.tsxCancelBookingDialog.tsxCancelBooking.tsx
  4. CancelBooking 依据当前取消者角色(host/attendee)调用 isCancellationReasonRequired 决定是否必填;
  5. 服务端 handleCancelBooking 再次执行同一套判定并抛出 400,形成前后端双重校验。

边界情况与默认行为

设计文档 design.md 专门列出并给出了处理策略:

场景 处理策略
平台(Platform)用户取消 尊重事件类型的该设置
团队(Team)预约 无论团队上下文如何,设置均生效
数据库列为 NULL(存量数据) 运行时回退到 MANDATORY_HOST_ONLY 行为
默认事件类型(无 eventTypeId 使用默认值 MANDATORY_HOST_ONLY

其中"NULL 回退"在数据库默认值(schema 中 @default(MANDATORY_HOST_ONLY))、表单默认值(?? CancellationReasonRequirement.MANDATORY_HOST_ONLY)与判定函数(setting ?? ...)三层保持一致,确保迁移前后行为不出现歧义。

实施状态与后续演进

根据 implementation.md,该特性主体实现已完成,包括:schema 枚举与列、数据库迁移、英文翻译键、高级设置下拉、select 查询字段、取消弹窗 prop 透传、CancelBooking 组件校验与动态 "(optional)" 标签、服务端 handleCancelBooking 校验等。剩余工作主要是端到端测试、四个下拉选项的逐一验证,以及动态标签展示条件的回归验证。

设计文档同时明确了本特性明确不做(Out of Scope)的事项:改期(reschedule)原因配置、自定义原因下拉选项、原因分析/报表。这些被推迟到后续规划中,future-work.md 记录了更完整的演进方向:

  • 改期原因必填:复用同一模式,做成独立设置项;
  • 预定义原因选项:从自由文本升级为下拉预设项;
  • 原因分析仪表盘:对取消原因做统计与报表;
  • 按用户覆盖:支持对单个用户的必填规则覆盖;
  • 原因模板:提供可复用的原因填写模板。

小结

取消原因必填配置是 cal.diy 预约流程中一个典型的"小而完整"的端到端特性:从 ADR 的存储方案权衡,到 Prisma 枚举与可空列设计,再到前后端共用的纯函数判定与跨组件 prop 透传,每一个环节都能在仓库源码中找到对应实现。对于希望理解 cal.diy 如何演进预约设置项、或需要在其基础上扩展类似"必填策略"类功能的开发者而言,specs/cancellation-reason-requirement 目录(docs/README.mddesign.mdimplementation.mddecisions.md)与 cancellationReason.tshandleCancelBooking.ts 等源码共同构成了完整的可参考范本。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395