用户原话:「carddav 和 caldav 好像都不好用,没有任何数据被同步出来。
同步的客户端,在 D:/Yongquan/code/SyncCalendar 中有,你可以用这个里面的代码试试同步一下。」
三处真根因**全部靠实测**拿到(不是"看代码猜"):写了 _fb_dav_diag.py,
逐字复刻 SyncCalendar 的 DavClient 请求序列(PROPFIND/REPORT/GET + 它那几个 XML 体),
打线上一次就复现出用户症状(15 条失败),修完线上 28/0。
1. 发现链(discovery)**整段缺失** ← 这就是"没有任何数据"的根因
标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号按 RFC 6764 走:
GET /.well-known/caldav → 302 到 /dav/ ← 原来 404(落到 index.php 去登录页)
PROPFIND <上下文路径> → current-user-principal ← 原来回的是"日历本清单",没有 principal
PROPFIND <principal> → calendar-home-set ← 原来没这个属性
PROPFIND <home> Depth:1 / <book> / REPORT ← 这两步一直是通的
① 就断 ⇒ 客户端**在填完账号点保存那一刻**就失败,一条数据都拉不到。
为什么历次验收全绿:上批只验了 PROPFIND /dav/ 与 /carddav/ —— 恰好是**一直通**的那两步,
断点在其**前面**,从来没被测过。
改:两个入口补 PRINCIPAL_PATH/HOME_PATH + /.well-known/* 301(**在鉴权之前**)+
根/principal/列表三处都给 current-user-principal。.htaccess 用**独立标记块**追加
well-known 重写规则(本仓库第 4 次"独立入口必须独立块")。
2. DTSTAMP 取 time() → ETag 潮汐(增量同步**永远不收敛**)
App\Ics::stampOf() 原为"没给时间就 time()",而**派生条目**(观影/行程/生日/农历/
节假日/节气)没有 updated_at/created_at ⇒ 每生成一次 ICS 就写一个新"现在" ⇒ 正文变
⇒ ETag 变。线上实测:同一本连续两次 REPORT **每一条 ETag 都不同**,整本 GET 的 ETag
也在变 ⇒ 客户端把"增量同步"退化成"每轮全量重拉",且拉回来的对不上、永远不收敛。
改:缺时间写固定值 19700101T000000Z(确定优先于"诚实的当前时间")。
3. 派生本把"日历视图条目"当"CalDAV 资源"
Calendar::exportRows() 对派生本按**日历视图**取数(重复日程展开 N 次、跨天逐日、
生日/农历逐日派生)。线上 /dav/1.ics/ 实测 **2981 个 response / 只有 196 个不同 href**
(anniv-0_familybook.ics 自己重复 **2399** 次)。CalDAV 的资源是**一个 .ics 文件** ⇒
客户端要么把同一条反复入库,要么判定服务器异常而中止本轮同步。
改:CalDav::resources() 按 **UID 去重**(资源名本就是 UID)。
4. 连带两个真 bug(同一段代码顺出来的)
· exportRows() 派生分支**没按本过滤**:month() 里"任务""纪念日"两段不带 kind 判断
(它们 cal=0)⇒ 取"观影本"会把全家所有生日一起带出来(2981 条里绝大多数是 anniv)。
· CalDav::parsePath() 的 $event=(int)$evSeg:派生资源名(movie-100_familybook)
(int) 后全是 0 ⇒ 单条资源请求被当"整本",GET /dav/4.ics/lunar-...ics 实测回 **400 条**。
5. 顺带补全:系统日历本(农历/节假日/传统节日/节气)原来**导出是空的**
它们只走派生、没有 calendar_events 行,而 exportRows 只认 custom/movie/trip ⇒
手机上订阅这几个本一条都没有。改:按 [今年-1, 今年+5] 派生(**封顶**,农历每天一条,
130 年会有 4.7 万条)。实测耗时 lunarRange 31ms / traditional 51ms / Anniversary 30ms。
改:.htaccess、router.php、dav.php、carddav.php、app/CalDav.php、app/CardDav.php、
app/Calendar.php、app/Ics.php、templates/settings/me.php(补"或只填服务器地址"的提示)。
验证(全部 0 失败):DAV 探针 28/0(**线上修前 15 失败 → 修后 28/0**,同一套探针两处都跑)、
DAV 反向验证 8/0(7 处修复各自改回旧行为,断言全变红)、e2e 1478/0、升级 153/0、
城市 32/0、几何 428/0 + 移动 680/0 + 交互 92/0、b24 41/0、b27 34/0、b28 39/0、b29 38/0、
b30_import 26/0、b30_ui2 16/0、b30_notify 23/0;部署静态断言新增 12 条守卫并逐条证伪通过。
线上 7 个文件 SHA256 与本地一致;/.well-known/caldav→301 到 /dav/ 实测生效。
⚠️ 本轮在部署脚本上真踩到并修掉两个坑(已写进 pitfalls.md):
· router.php **不发到线上**,把它写进部署断言 → grep "No such file" → 部署中止在
"改了一半"的状态(迁到 push.sh 第 1 段)。
· `set -euo pipefail` 下 `X=$(... | grep -c ...)` 在 **0 匹配**时退出码 1 ⇒
**整个脚本静默退出**(不打 FAIL,日志停在上一行)。已给全部 7 处补 `|| true`。
72 lines
3.3 KiB
PHP
72 lines
3.3 KiB
PHP
<?php
|
||
/**
|
||
* 前端控制器(路由表)—— 只给 **PHP 内置服务器** 用(php -S ... router.php)。
|
||
*
|
||
* 为什么要它:
|
||
* · 线上是 Apache,`/dav/3.ics/` 这种没有对应文件的 URL 靠 .htaccess 重写到 dav.php;
|
||
* · 本地 `php -S` **不读 .htaccess**,所以同一个地址会被当成"文件不存在"直接 404,
|
||
* 于是「CalDAV 在本地测不了、只能上线验证」——这正是最容易上线才发现问题的写法。
|
||
* 这个文件把那几条重写规则用 PHP 重述一遍,本地与线上一模一样的路径行为。
|
||
* (v24 第 22 批加了 CardDAV,同样两条:/dav/ 与 /carddav/。)
|
||
*
|
||
* ⚠️ 里面**不写任何业务**:只决定"交给哪个脚本",所有业务仍在 index.php / dav.php / media.php。
|
||
* 线上部署不该带它(Apache 走 .htaccess),但带着也无害 —— 它不会被执行。
|
||
*/
|
||
|
||
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
||
|
||
// 🔴 v31:`/.well-known/caldav` 与 `/.well-known/carddav`(RFC 6764)。
|
||
// 标准客户端(DAVx⁵ / iOS / Evolution)添加账号时**第一句**就试这里 ——
|
||
// 原来没有这条路由,于是落到 index.php、被 302 去登录页,
|
||
// 客户端判定"域名下没有 DAV 服务",连口令框都不弹。
|
||
// 症状正是用户报的「同步不出任何数据」(账号根本建不起来)。
|
||
// ⚠️ 必须排在下面 dav/carddav 通配两条**之前**?不需要(前缀互不包含),
|
||
// 但**必须**排在后面"真实文件"判断之前 —— 这个目录线上并不存在。
|
||
if ($path === '/.well-known/caldav') {
|
||
require __DIR__ . '/dav.php';
|
||
return true;
|
||
}
|
||
if ($path === '/.well-known/carddav') {
|
||
require __DIR__ . '/carddav.php';
|
||
return true;
|
||
}
|
||
|
||
// CalDAV:/dav/ 及其子路径(/dav/、/dav/3.ics/、/dav/3.ics/12.ics)
|
||
if (preg_match('#^/dav(/.*)?$#', $path)) {
|
||
require __DIR__ . '/dav.php';
|
||
return true;
|
||
}
|
||
|
||
// CardDAV:/carddav/ 及其子路径(v24 第 22 批)
|
||
// ⚠️ 这条必须在下面那条"真实文件"判断**之前**:carddav.php 本身是个真文件,
|
||
// 但我们要的是把 /carddav/1.vcf/ 这种**不存在的路径**交给它处理;
|
||
// 顺序反了的话 /carddav/ 会去要一个不存在的目录 → 404。
|
||
if (preg_match('#^/carddav(/.*)?$#', $path)) {
|
||
require __DIR__ . '/carddav.php';
|
||
return true;
|
||
}
|
||
|
||
// 导航**展示页**(v24 第 24 批 用户第 4 条):`/nav/?<短语>` —— 全站唯一免登录页面。
|
||
// ⚠️ 三条顺序**不能换**:
|
||
// ① `/nav/suggest.php` 是联想接口(免登录,但凭短语门禁放行);
|
||
// ② `/nav/` 恰好是展示页;
|
||
// ③ 两者都必须排在下面"真实文件"判断**之前** —— 否则 `/nav/` 会去找一个
|
||
// 不存在的目录 → 404(与上面 carddav 那条同一个坑)。
|
||
if ($path === '/nav/suggest.php') {
|
||
require __DIR__ . '/nav_suggest.php';
|
||
return true;
|
||
}
|
||
if (preg_match('#^/nav/?$#', $path)) {
|
||
require __DIR__ . '/nav_show.php';
|
||
return true;
|
||
}
|
||
|
||
// 真实存在的文件(静态资源、media.php 等)→ 交给内置服务器自己处理
|
||
$file = __DIR__ . $path;
|
||
if ($path !== '/' && is_file($file)) {
|
||
return false;
|
||
}
|
||
|
||
// 其余交给主路由
|
||
require __DIR__ . '/index.php';
|
||
return true; |