第 21 批收尾:按需补「我的日程」本(老库也能拿到)

线上实测发现第一版没覆盖到的情况:把 'custom' => 「我的日程」加进
`Calendar::DEFAULT_KINDS` **是不够的** —— `seedDefaults()` 只在
**安装**与**结构升级**两条路径上被调用,而线上这套库早就升完级了,
于是老用户永远拿不到那一本。线上实测:owner=1 可写的普通本 0 本
(唯一那本 custom「工作」是用户自己建的)。

后果不只是"少一本":新建日程的"放在哪本"里全是派生本、默认还选中排最前的
**观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**(建错东西,
事后极难发现:日历里看着是多了条日程,观影列表里莫名多一条)。

修法:
- 新增 `Calendar::ensurePersonalBook()` —— 按需补,幂等判据是
  `owner_id + kind='custom'`(**刻意不看名字**,否则用户把自己的本子改名之后
  会又冒出一本「我的日程」)。
- 由 `calendar` 路由在打开日历时顺手调一次(一次请求只多一条 SELECT)。
- 模板里再兜一道:默认选中的不是可写普通本时显式提示
  "你还没有自己的日程本,上面选的是一本派生本(加进去会变成观影/旅程记录)"。

验证:e2e **1064/0**(新增 5 条:删本 → 打开日历自己补回来 → 名字对 → 幂等 →
改名后也不会又冒一本);线上以 Web 用户(www)身份实测 before=1 after=1
(正确幂等,不动用户自己那本);真机探针 75/0 · 桌面几何 428/0 · 部署自检 0 失败。

⚠️ 测试侧顺带记一个坑:夹具删"有日程的本子"时 `calendar_events.calendar_id`
外键是 **RESTRICT**,而用**多语句** `mysql -e "SET FOREIGN_KEY_CHECKS=0; DELETE...;"`
时一旦有一句被挡下,**整个 -e 静默失败**(返回码 0、无 stderr),
症状是夹具自检报"还有 2 本"、后面断言一路红,单独手跑同样语句却是通的。
改成**一条一条发并检查返回码**后就好了。
This commit is contained in:
2026-10-06 13:44:24 +08:00
parent f0437e6c93
commit 41572b77e7
3 changed files with 57 additions and 2 deletions
+35 -1
View File
@@ -296,7 +296,7 @@ final class Calendar
} }
} }
/** 升级 / 安装时给全家每个账号补一遍(老库也要有这两本) */ /** 升级 / 安装时给全家每个账号补一遍(老库也要有这些本) */
public static function seedDefaultsForFamily(int $familyId): void public static function seedDefaultsForFamily(int $familyId): void
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
@@ -309,6 +309,40 @@ final class Calendar
} }
} }
/**
* 补一本「我的日程」(kind='custom')—— 每个人都要有。
*
* 🔴 v24 第 21 批 第 7 条:`DEFAULT_KINDS` 里原来只有 6 本**派生**本,
* 于是"新建日程 → 放在哪本"全是派生本、默认还选中排最前的**观影** ——
* 用户直接打字点「加上」,建出来的是**观影记录**("建错东西",
* 事后极难发现)。所以默认清单里补了 'custom' => 「我的日程」。
*
* ⚠️ **但只补清单是不够的**:`seedDefaults()` 只在**安装**与**结构升级**
* 那两条路径上被调用,而线上这套库早就升过级了 → 老用户**永远拿不到**这一本
* (实测线上:owner=1 可写的普通本 0 本,唯一那本 custom 是用户自己建的)。
* 所以必须有这条"按需补"的路径,由**打开日历时**顺手调一次(见 calendar 路由)。
*
* ⚠️ 幂等:`owner_id + kind='custom'` 只要有一本就不再补 —— 名字**不计入判据**,
* 否则用户把自己的本子改名成别的之后,会又冒出一本「我的日程」
* (`seedDefaults()` 是按 `owner_id + kind + name` 判重的,这里刻意更宽)。
* ⚠️ 一次请求只跑一条 `SELECT`(有就不再写),对打开日历的开销可忽略。
*/
public static function ensurePersonalBook(int $familyId, int $personId): void
{
if ($familyId <= 0 || $personId <= 0) {
return;
}
$st = Database::get()->prepare(
"SELECT id FROM calendars WHERE owner_id = ? AND kind = 'custom' LIMIT 1"
);
$st->execute([$personId]);
if ($st->fetchColumn() !== false) {
return; // 已经有一本可写的普通本了(不管它叫什么名字)
}
[$name, $color] = self::DEFAULT_KINDS['custom'];
self::create($familyId, $personId, $name, $color, 'custom');
}
// ---------------- 共享 ---------------- // ---------------- 共享 ----------------
public static function shares(int $calendarId): array public static function shares(int $calendarId): array
+8
View File
@@ -3764,6 +3764,14 @@ switch ($r) {
$anchor = date('Y-m-d'); $anchor = date('Y-m-d');
} }
$ym = substr($anchor, 0, 7); $ym = substr($anchor, 0, 7);
/* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。
为什么必须在这儿补(而不是只改 DEFAULT_KINDS):
`Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过,
线上这套库早就升完级了 → 老用户永远拿不到那一本。
结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的
**观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。
⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */
Calendar::ensurePersonalBook($familyId, $me);
$cals = Calendar::mine($familyId, $me); $cals = Calendar::mine($familyId, $me);
// 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。 // 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。
// 藏掉的本子**不参与取数**,而不是"取出来再 display:none" —— // 藏掉的本子**不参与取数**,而不是"取出来再 display:none" ——
+14 -1
View File
@@ -181,7 +181,13 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
foreach ($writable as $c) { foreach ($writable as $c) {
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; } if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
} }
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; } ?> if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; }
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
当成正常状态。 */
$hasPersonal = $firstCal !== null
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
<div class="cal-book-row"> <div class="cal-book-row">
<span class="cal-book-dot" id="ce-cal-dot" <span class="cal-book-dot" id="ce-cal-dot"
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span> style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
@@ -200,6 +206,13 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<?php if (!$hasPersonal): ?>
<span class="tip" style="display:block;margin-top:6px">
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
(加进去会变成观影 / 旅程记录)。去
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
</span>
<?php endif; ?>
</div> </div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。 <?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认: 新建时「全天」默认**不勾**(用户原话)。开始时间默认: