日程表单的日历本下拉只列可写日历本(排除只读共享、系统只读、观影纪念日等非日历本条目) #4

Closed
opened 2026-10-25 22:55:06 +00:00 by laoyang · 2 comments
Owner

现象 / 诉求

网页端「添加日程 / 编辑日程」的日历本下拉框,现在把所有日历本都列出来了,
包括:

  • 别人共享给我的只读日历本
  • 系统自带的只读日历本
  • 观影、纪念日等不是日历本的模块条目(它们本来不该出现在这个选择框里)

用户在添加日程时选了这些,结果是提交必然失败(服务端拒绝写入只读日历本),
或者选了以后根本存不进去 —— 属于"让你选,但选了不work"的坏体验。

期望行为

日历本下拉框里只出现当前用户可写的日历本。

具体:

  1. 别人共享给我的只读日历本 —— 不出现
  2. 系统自带日历本 —— 按实际权限判定,只读的不出现,可写的出现
  3. 观影、纪念日等非日历本模块 —— 不出现
  4. 自己拥有的、或别人共享给我且给了写权限的 —— 正常出现

涉及范围

  • 网页端日程表单的日历本选择控件(新建 + 编辑两处都要一致)
  • 需要同时核对:
    • 后端返回日历本列表的接口/查询是否带出 permission 或 is_read_only 之类字段
    • 前端渲染时是否按该字段过滤
    • 服务端提交校验:即使前端过滤掉了,服务端也必须仍然拒绝写入只读日历本
      (前端过滤是体验,服务端校验是安全,两者都要)

验收标准

  • 用一个只有只读共享的账号打开「添加日程」,下拉框里看不到那个只读日历本
  • 用自己的账号打开「添加日程」,看不到观影/纪念日等模块条目
  • 可写日历本(自己的 + 别人共享给写的)都正常出现在下拉里,并且能成功保存
  • 编辑已有日程时,日历本下拉的候选集与新建时一致
  • 服务端仍然拒绝写入只读日历本(直接构造请求也必须被拒)
## 现象 / 诉求 网页端「添加日程 / 编辑日程」的日历本下拉框,现在把**所有**日历本都列出来了, 包括: - 别人共享给我的**只读**日历本 - 系统自带的只读日历本 - 观影、纪念日等**不是日历本**的模块条目(它们本来不该出现在这个选择框里) 用户在添加日程时选了这些,结果是提交必然失败(服务端拒绝写入只读日历本), 或者选了以后根本存不进去 —— 属于"让你选,但选了不work"的坏体验。 ## 期望行为 日历本下拉框里**只出现当前用户可写的**日历本。 具体: 1. 别人共享给我的只读日历本 —— **不出现** 2. 系统自带日历本 —— 按实际权限判定,只读的不出现,可写的出现 3. 观影、纪念日等非日历本模块 —— **不出现** 4. 自己拥有的、或别人共享给我且给了写权限的 —— 正常出现 ## 涉及范围 - 网页端日程表单的日历本选择控件(新建 + 编辑两处都要一致) - 需要同时核对: - 后端返回日历本列表的接口/查询是否带出 `permission` 或 `is_read_only` 之类字段 - 前端渲染时是否按该字段过滤 - **服务端提交校验**:即使前端过滤掉了,服务端也必须仍然拒绝写入只读日历本 (前端过滤是体验,服务端校验是安全,两者都要) ## 验收标准 - [ ] 用一个**只有只读共享**的账号打开「添加日程」,下拉框里看不到那个只读日历本 - [ ] 用自己的账号打开「添加日程」,看不到观影/纪念日等模块条目 - [ ] 可写日历本(自己的 + 别人共享给写的)都正常出现在下拉里,并且能成功保存 - [ ] 编辑已有日程时,日历本下拉的候选集与新建时一致 - [ ] 服务端仍然拒绝写入只读日历本(直接构造请求也必须被拒)
Author
Owner

实现进展:三条判据(缺一不可)

先记一个实测结论,因为它推翻了最直观的那种写法。

一开始以为写成 array_filter($cals, canWrite) 就够了(原来的代码就是这样),
实测 dump 出来 mine() 里每一本的 perm / canWrite / isReadonlyKind,结果是这样:

kind perm canWrite isReadonlyKind 该不该出现在下拉里
custom(自己的) owner Y n 该
custom(别人共享给我写) write Y n 该
custom(别人共享给我只读) read n n 不该
movie(观影派生本) owner Y n 不该
trip(旅行派生本) owner Y n 不该
lunar / holiday / traditional / solarterm owner Y Y 不该

所以:

  • 只判 canWrite():只读共享本能挡住,但系统只读本一本也挡不住
    (它们 owner_id 就是我,perm 是 owner,canWrite 恒真),观影/旅行本也全放行。
  • 只判 isReadonlyKind():系统本能挡住,但只读共享本、观影/旅行本全漏进来。
  • 只判 kind === 'custom':观影/旅行挡得住,只读共享本漏进来。

⇒ 三条一起判,才是用户要的那个集合。已抽成 Calendar::filterWritableForEvent()
(单一口径),日程页和首页「新建日程」弹框都走它 —— 原来首页自己写了一份
array_filter(canWrite),与日程页"看起来一样、实则不是同一份代码",容易改一处忘另一处。

一个附带清理

_add_modal.php 里 v24 那套「列出但禁用」的死代码(disabled 属性、
(自动生成) 后缀、title="系统自动生成…")现在是恒假的,已清掉。
留着它的害处很实际:后来人会以为"禁用的还在、只是没生效",于是把 disabled 加回去,
症状立刻复发、而且看着像"修好了"。

关于「观影/旅行本可写」这一点

它们在服务端是确实可以写的(往观影本里加一条 = 建一条观影记录,见
event_add 里 kind === 'movie' || kind === 'trip' 那个分支),
所以这次只是把它们从**「新建日程」的下拉**里排掉,
没有动那条通道本身 —— 从观影/旅行模块自己的入口建记录仍然正常。

待办

  • 前端下拉过滤(三条判据 + 单一口径)
  • 清掉恒假的 disabled / 「(自动生成)」死代码
  • 全量流水线(含 e2e 1576 条、探针、反向验证)跑绿
  • 部署
  • 真机浏览器验收:用自己的账号 + 用只读共享账号各打开一次「添加日程」
## 实现进展:三条判据(缺一不可) 先记一个实测结论,因为它推翻了最直观的那种写法。 一开始以为写成 `array_filter($cals, canWrite)` 就够了(原来的代码就是这样), 实测 dump 出来 `mine()` 里每一本的 `perm / canWrite / isReadonlyKind`,结果是这样: | kind | perm | canWrite | isReadonlyKind | 该不该出现在下拉里 | |---|---|---|---|---| | `custom`(自己的) | owner | Y | n | **该** | | `custom`(别人共享给我写) | write | Y | n | **该** | | `custom`(别人共享给我只读) | read | **n** | n | 不该 | | `movie`(观影派生本) | owner | **Y** | n | 不该 | | `trip`(旅行派生本) | owner | **Y** | n | 不该 | | `lunar` / `holiday` / `traditional` / `solarterm` | owner | **Y** | **Y** | 不该 | 所以: - **只判 `canWrite()`**:只读共享本能挡住,但**系统只读本一本也挡不住** (它们 `owner_id` 就是我,`perm` 是 owner,`canWrite` 恒真),观影/旅行本也全放行。 - **只判 `isReadonlyKind()`**:系统本能挡住,但只读共享本、观影/旅行本全漏进来。 - **只判 `kind === 'custom'`**:观影/旅行挡得住,只读共享本漏进来。 ⇒ 三条一起判,才是用户要的那个集合。已抽成 `Calendar::filterWritableForEvent()` (单一口径),日程页和首页「新建日程」弹框都走它 —— 原来首页自己写了一份 `array_filter(canWrite)`,与日程页"看起来一样、实则不是同一份代码",容易改一处忘另一处。 ## 一个附带清理 `_add_modal.php` 里 v24 那套「列出但禁用」的死代码(`disabled` 属性、 `(自动生成)` 后缀、`title="系统自动生成…"`)现在是恒假的,已清掉。 留着它的害处很实际:后来人会以为"禁用的还在、只是没生效",于是把 `disabled` 加回去, 症状立刻复发、而且看着像"修好了"。 ## 关于「观影/旅行本可写」这一点 它们在服务端是**确实可以写**的(往观影本里加一条 = 建一条观影记录,见 `event_add` 里 `kind === 'movie' || kind === 'trip'` 那个分支), 所以这次只是把它们从**「新建日程」的下拉**里排掉, **没有**动那条通道本身 —— 从观影/旅行模块自己的入口建记录仍然正常。 ## 待办 - [x] 前端下拉过滤(三条判据 + 单一口径) - [x] 清掉恒假的 disabled / 「(自动生成)」死代码 - [ ] 全量流水线(含 e2e 1576 条、探针、反向验证)跑绿 - [ ] 部署 - [ ] 真机浏览器验收:用自己的账号 + 用只读共享账号各打开一次「添加日程」
Author
Owner

收尾:已实现、已验证、已部署、已提交

commit a7fc7dc(main 已推送)。全量流水线 --all 退出码 0,已部署到
family.yangyq.net,线上四个文件 php -l 全通过。

三条判据(只做一条都修不好)

判据 挡住谁 为什么不能省
canWrite() 别人共享给我只读 只有这一类能被它拦住
!isReadonlyKind() 系统只读本(农历/节假日/传统节日/二十四节气) 它们的 owner_id 就是我 ⇒ canWrite() 恒真
kind === 'custom' 观影 / 旅行派生本 它们确实可写(加一条 = 建一条观影/旅程记录),但不是"放日程的地方"

统一收在 Calendar::writableForEvent() / filterWritableForEvent() 一处,
日历页、首页卡片、API 都走它 —— 避免 v82「两个入口的下拉内容可以不一样」的老毛病。

验收标准逐条对照

  • 只读共享的本看不到 —— 夹具新增 calendar_shares.perm='read' 一本,
    断言它不在下拉里;并且绕过前端直接 POST 打 calendar/event_add,
    服务端回「这本日历你没有写权限。」且库里无新增。
    ⚠️ 这一类此前一次都没被测过:原来的 calendar_shares 是空表。
  • 看不到观影/纪念日等模块条目 —— 观影/旅行派生本不在下拉里。
  • 可写本正常出现且能保存 —— 自己的本 + 别人共享给我写的本都出现,
    探针真的选一本保存成功。
  • 编辑时的候选集与新建一致 —— 两条渲染路径共用同一个过滤函数。
  • 服务端仍拒绝写入只读本(直接构造请求也必须被拒) —— 见上面第一项。

验证是怎么做的(以及为什么这么费劲)

浏览器探针 28 通过 / 0 失败:

  • 自建夹具(只读共享 / 可写共享 / 自己的),并断言三本确实造出来了
    —— 否则"X 不出现"这类否定式断言在空集上恒真,一片绿等于没测。
  • 期望集由 PHP 侧独立重算(不复用被测函数),与页面真实下拉逐项比对。
  • 绕过前端直接 POST:forbid 类(只读共享 + 系统只读本)要求
    回包 ok:false 且库无新增(两条都要);derived 类(观影/旅行)要求
    回包 ok:true 且 events 表按 kind 真多一行。

反向验证 6/6 全抓住:退回 canWrite / 只看 kind / 不管 custom / 全滤空 /
只读共享漏进下拉 / 拆掉 event_add 的 canWrite 闸门。
最后一条注入时只有 ⑪ 变红,这精确证明 ⑪ 测的是服务端拦截,
不是靠前端下拉在兜。

过程中修掉的四个"绿着但没测"的洞(都不是本需求的功能代码)

  1. 反向验证脚本硬编码的账号在库里不存在 ⇒ 基线 7 条红 ⇒ [FATAL] 早退
    ⇒ 那个"六类注入"的反向验证从来没跑出过一次完整结果,
    但文件名、注册行、说明全都在,看着完全正常。已改成跟随探针默认账号。
  2. ⑪ 第一版是假绿:判据写成 blocked = (ok is False) or (条数没变),
    而观影/旅行回 ok:true 且不写 calendar_events ⇒ 被判成"已挡住"。
    实际它们明明成功写入了一条记录 —— 全绿,但结论是反的。
  3. 探针一行夹具都没有,全靠 v51 遗留的 26 个本子(见上面第一项验收)。
  4. _fb_b21_ui.py 一条假红:.mv-slot 数量判据是 v24 口径,
    v35 把票根并进 .photo-grid 后恒假。已修正为 1 个(75/0)。

待用户本人确认

线上冒烟需要登录态(19 条),当前口令未知,这部分我没有代跑。
不需要登录的断言(未登录 302、登录页 200)全绿,
calendar 未登录正确 302。

麻烦你用浏览器打开「日程 → 新建日程」看一眼下拉框:
应当只剩你自己的本和"别人共享给你且可写"的本,观影/纪念日/农历等都不在了。

## 收尾:已实现、已验证、已部署、已提交 commit `a7fc7dc`(`main` 已推送)。全量流水线 `--all` 退出码 0,已部署到 `family.yangyq.net`,线上四个文件 `php -l` 全通过。 ### 三条判据(只做一条都修不好) | 判据 | 挡住谁 | 为什么不能省 | |---|---|---| | `canWrite()` | 别人共享给我**只读** | 只有这一类能被它拦住 | | `!isReadonlyKind()` | 系统只读本(农历/节假日/传统节日/二十四节气) | 它们的 `owner_id` 就是我 ⇒ `canWrite()` **恒真** | | `kind === 'custom'` | 观影 / 旅行派生本 | 它们**确实可写**(加一条 = 建一条观影/旅程记录),但不是"放日程的地方" | 统一收在 `Calendar::writableForEvent()` / `filterWritableForEvent()` 一处, 日历页、首页卡片、API 都走它 —— 避免 v82「两个入口的下拉内容可以不一样」的老毛病。 ### 验收标准逐条对照 - [x] **只读共享的本看不到** —— 夹具新增 `calendar_shares.perm='read'` 一本, 断言它不在下拉里;并且**绕过前端直接 POST** 打 `calendar/event_add`, 服务端回「这本日历你没有写权限。」且库里无新增。 ⚠️ 这一类此前**一次都没被测过**:原来的 `calendar_shares` 是**空表**。 - [x] **看不到观影/纪念日等模块条目** —— 观影/旅行派生本不在下拉里。 - [x] **可写本正常出现且能保存** —— 自己的本 + 别人共享给我写的本都出现, 探针真的选一本保存成功。 - [x] **编辑时的候选集与新建一致** —— 两条渲染路径共用同一个过滤函数。 - [x] **服务端仍拒绝写入只读本(直接构造请求也必须被拒)** —— 见上面第一项。 ### 验证是怎么做的(以及为什么这么费劲) 浏览器探针 **28 通过 / 0 失败**: - 自建夹具(只读共享 / 可写共享 / 自己的),并断言三本**确实造出来了** —— 否则"X 不出现"这类否定式断言在**空集上恒真**,一片绿等于没测。 - 期望集由 PHP 侧**独立重算**(不复用被测函数),与页面真实下拉逐项比对。 - 绕过前端直接 POST:`forbid` 类(只读共享 + 系统只读本)要求 **回包 ok:false 且库无新增**(两条都要);`derived` 类(观影/旅行)要求 **回包 ok:true 且 `events` 表按 kind 真多一行**。 反向验证 **6/6 全抓住**:退回 canWrite / 只看 kind / 不管 custom / 全滤空 / 只读共享漏进下拉 / 拆掉 `event_add` 的 `canWrite` 闸门。 最后一条注入时**只有 ⑪ 变红**,这精确证明 ⑪ 测的是**服务端拦截**, 不是靠前端下拉在兜。 ## 过程中修掉的四个"绿着但没测"的洞(都不是本需求的功能代码) 1. **反向验证脚本硬编码的账号在库里不存在** ⇒ 基线 7 条红 ⇒ `[FATAL]` 早退 ⇒ 那个"六类注入"的反向验证**从来没跑出过一次完整结果**, 但文件名、注册行、说明全都在,看着完全正常。已改成跟随探针默认账号。 2. **⑪ 第一版是假绿**:判据写成 `blocked = (ok is False) or (条数没变)`, 而观影/旅行回 `ok:true` 且不写 `calendar_events` ⇒ 被判成"已挡住"。 实际它们明明成功写入了一条记录 —— **全绿,但结论是反的**。 3. **探针一行夹具都没有**,全靠 v51 遗留的 26 个本子(见上面第一项验收)。 4. **`_fb_b21_ui.py` 一条假红**:`.mv-slot` 数量判据是 v24 口径, v35 把票根并进 `.photo-grid` 后恒假。已修正为 1 个(75/0)。 ## 待用户本人确认 线上冒烟需要**登录态**(19 条),当前口令未知,这部分我没有代跑。 不需要登录的断言(未登录 302、登录页 200)全绿, `calendar` 未登录正确 302。 麻烦你用浏览器打开「日程 → 新建日程」看一眼下拉框: 应当只剩你自己的本和"别人共享给你且可写"的本,观影/纪念日/农历等都不在了。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook#4