用户原话:「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`。
225 lines
10 KiB
PHP
225 lines
10 KiB
PHP
<?php
|
||
/**
|
||
* CardDAV 服务端(v24 第 22 批)
|
||
*
|
||
* 用户原话:「名册,增加cardav同步功能。」
|
||
* 手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)拉到的就是这里。
|
||
*
|
||
* 与 CalDav 是**并列的两个入口**,刻意不合并成一个 /dav/ 前缀:
|
||
* · /dav/ → 日历(本类旁边的 App\CalDav)
|
||
* · /carddav/ → 通讯录(本类)
|
||
* ⚠️ 为什么不共用 /dav/ 一棵树:两边的"资源类型"(calendar vs addressbook)、
|
||
* 命名空间(caldav vs carddav)与属性名完全不同,混在一棵树里要靠
|
||
* PROPFIND 的请求体去猜客户端想要哪种 —— 猜错的表现是"客户端连上了但一本都看不到"。
|
||
* 分开之后各自只有一种语义,iOS/DAVx⁵ 都是探测到哪个地址就用哪套。
|
||
* ⚠️ 但**鉴权是同一套**(Auth::davAuth + dav_credentials 表):一把口令管两种同步,
|
||
* 这正是用户要的"用用户名和口令来同步",也省得在两个页面各配一遍。
|
||
*
|
||
* 客户端的请求顺序(必须按这个最小集实现):
|
||
* OPTIONS /carddav/ → DAV: 1, addressbook
|
||
* PROPFIND /carddav/ (Depth: 0) → 当前用户的通讯录清单(通常只有一本)
|
||
* PROPFIND /carddav/1.vcf/ (Depth: 1) → 这本里所有卡片的资源清单(getetag)
|
||
* REPORT /carddav/1.vcf/ → addressbook-query / multiget,取正文
|
||
* GET /carddav/1.vcf/ → 整本导出成一个 vcf(很多客户端走这条)
|
||
*
|
||
* ⚠️ 只读:写方法(PUT/DELETE/PROPPATCH)一律 403。
|
||
* 与 CalDav 同样的取舍 —— 与其"半支持"(客户端以为改成功了、服务端却没落库),
|
||
* 不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class CardDav
|
||
{
|
||
/** 资源前缀(URL 里的路径)。与 CalDav::ROOT 并列、互不包含 */
|
||
public const ROOT = '/carddav/';
|
||
|
||
/** 默认那一本通讯录的 id。家庭场景只有一本,用固定 id 让 URL 稳定可记 */
|
||
public const BOOK_ID = 1;
|
||
|
||
/**
|
||
* 客户端**发现链**用的固定路径(RFC 6352 §6 / RFC 6764)。
|
||
*
|
||
* 🔴 v31 修的真 bug:与 CalDAV 同一个病 —— 这两个入口原来**完全没实现**。
|
||
* 实测线上:`PROPFIND /carddav/` 问 current-user-principal 回的是"通讯录清单"
|
||
* (一个 principal 都没有)→ 客户端判定"这不是个 CardDAV 服务器" →
|
||
* 建账号那一步就失败,一张卡片都同步不出来。
|
||
*
|
||
* ⚠️ 与 CalDav 刻意**同名同义**(PRINCIPAL_PATH / HOME_PATH 语义一一对应),
|
||
* 两边漂移的话,手机上"日历能连、通讯录不能连"这类半通状态会很难查。
|
||
*/
|
||
public const PRINCIPAL_PATH = '/carddav/principal/';
|
||
|
||
public const HOME_PATH = '/carddav/addressbooks/';
|
||
|
||
/**
|
||
* 当前用户可见的"通讯录"。
|
||
*
|
||
* ⚠️ 家庭场景只有**一本**:你的名册。所以这里不查库、直接回一本固定的,
|
||
* 是为了让 URL(/carddav/1.vcf/)稳定 —— 客户端配好之后,
|
||
* 哪怕名册里的人数变了,地址也不会变(变了就要重新配)。
|
||
* ⚠️ 口径必须与名册页**完全一致**(用户 2026-10-06 第 22 批确认):
|
||
* 名册页看到谁就同步谁。所以名册页怎么取的(Person::roster + kind='member'),
|
||
* 这里就怎么取(见 rows()),不另发明一套可见性判据 ——
|
||
* 否则手机上少几个人、网页上有,用户会以为"同步漏了"。
|
||
*/
|
||
public static function books(int $familyId, int $personId): array
|
||
{
|
||
return [[
|
||
'id' => self::BOOK_ID,
|
||
'name' => '家庭名册',
|
||
'description' => '家人与联系人(与网页上的名册一致)',
|
||
'count' => count(self::rows($familyId, $personId)),
|
||
]];
|
||
}
|
||
|
||
public static function bookHref(int $bookId): string
|
||
{
|
||
return self::ROOT . $bookId . '.vcf/';
|
||
}
|
||
|
||
public static function hrefOf(array $book): string
|
||
{
|
||
return self::bookHref((int)$book['id']);
|
||
}
|
||
|
||
/** 从 URL 段解出「这是哪一本 / 要哪一张卡」 */
|
||
public static function parsePath(string $path): array
|
||
{
|
||
$p = (string)parse_url($path, PHP_URL_PATH);
|
||
if (strpos($p, self::ROOT) !== 0) {
|
||
return ['book' => 0, 'card' => 0];
|
||
}
|
||
$rest = trim(substr($p, strlen(self::ROOT)), '/');
|
||
if ($rest === '') {
|
||
return ['book' => 0, 'card' => 0];
|
||
}
|
||
$parts = explode('/', $rest);
|
||
$bookSeg = (string)$parts[0];
|
||
$book = (int)preg_replace('/\.vcf$/', '', $bookSeg);
|
||
$card = 0;
|
||
if (isset($parts[1]) && $parts[1] !== '') {
|
||
// 单张卡:<id>.vcf —— 我们用的是 persons.id
|
||
$card = (int)preg_replace('/\.vcf$/', '', (string)$parts[1]);
|
||
}
|
||
return ['book' => $book, 'card' => $card];
|
||
}
|
||
|
||
/**
|
||
* 这本通讯录里的所有"卡"(一个人一张),并且**已经把字段备齐**——
|
||
* 电话解密、关系称呼、我叫他什么都在这里算好,Vcard 那层只管写文本。
|
||
*
|
||
* ⚠️ 解密(persons.phone_encrypted 是 AES 密文)为什么放在这一层:
|
||
* Vcard 是纯文本生成器,不该碰密钥;而且"取数据"这一步与网页名册
|
||
* 走同一个 PersonQuery::phonePlain(**同一套判据**),
|
||
* 不会出现"页面上显示得出手机号、同步出去却是空的"。
|
||
*/
|
||
public static function rows(int $familyId, int $personId): array
|
||
{
|
||
if ($familyId <= 0 || $personId <= 0) {
|
||
return [];
|
||
}
|
||
$rows = Person::roster($familyId, $personId, 'member', '');
|
||
$people = Person::map($familyId);
|
||
$graph = Kinship::graph($familyId);
|
||
|
||
$out = [];
|
||
foreach ($rows as $r) {
|
||
$pid = (int)$r['id'];
|
||
$r['phone'] = PersonQuery::phonePlain($r);
|
||
// 生日:走 Birthday::solarOf 取**真实阳历**(本项目里 birthday 列就是这个语义,
|
||
// 生日表单字段叫 birthday_solar,两者是两个键名、同一个东西 —— 见该函数注释)。
|
||
// ⚠️ 不写农历:vCard 没有农历字段,硬塞进 NOTE 会让手机上出现一句
|
||
// 看不懂的"农历三月廿一",而过年过生日那件事日历那边已经管了。
|
||
// 也只有"有年份"的阳历生日才写(Vcard 那边会把 "YYYY-MM-DD 00:00:00"
|
||
// 截成日期段)。
|
||
$r['birthday'] = Birthday::solarOf($r);
|
||
// 与我的关系(名册页上那句"我该怎么称呼他")
|
||
$r['relation_word'] = Kinship::label($graph, $personId, $pid, $people);
|
||
// 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父")
|
||
$nick = Nickname::display($personId, $r);
|
||
$r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : '';
|
||
// 补充联系方式(多个电话/邮箱/地址/网址)
|
||
$r['contacts'] = Person::contactsOf($pid);
|
||
$out[] = $r;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 单张卡(按 persons.id)。
|
||
*
|
||
* ⚠️ 必须**先判断这张卡在不在这个人的名册里**,而不是直接按 id 查 persons:
|
||
* 直接查的话,任何人拿到一个 id 就能把全家的信息拉走
|
||
* (CardDAV 走的是 Basic 口令、无会话,越权在这里尤其危险)。
|
||
*/
|
||
public static function one(int $familyId, int $personId, int $cardId): ?array
|
||
{
|
||
if ($cardId <= 0) {
|
||
return null;
|
||
}
|
||
foreach (self::rows($familyId, $personId) as $r) {
|
||
if ((int)$r['id'] === $cardId) {
|
||
return $r;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 整本导出成 vcf 文本(GET 与 REPORT 都用它) */
|
||
public static function vcf(int $familyId, int $personId): string
|
||
{
|
||
return Vcard::export(self::rows($familyId, $personId), '家庭名册');
|
||
}
|
||
|
||
/**
|
||
* 一张卡:**正文与 ETag 成对返回**。
|
||
*
|
||
* 🔴 v24 第 22 批修的真 bug:这两个值原本在 carddav.php 里各算一次,
|
||
* 于是 PROPFIND 用 `etag(Vcard::card($r))`、REPORT 用
|
||
* `etag(Vcard::card($r) . "\r\n")` —— **同一张卡算出两个不同的 ETag**。
|
||
* 客户端的典型行为是:先 PROPFIND 拿清单(记下 ETag A),
|
||
* 再 REPORT 取正文(拿到 ETag B),发现"对不上" → 认为这张卡一直在变,
|
||
* 于是**反复重拉**(同步永远不收敛),或者干脆把本地那份标成脏的。
|
||
* 症状是"手机通讯录一直在转圈同步",而服务端日志里一切正常。
|
||
*
|
||
* ⚠️ 修法不是"两处都改成一样",而是**让它们没有机会不一样**:
|
||
* 正文里的结尾 CRLF(REPORT 的 address-data 需要一个完整的 vCard 文本)
|
||
* 由调用方按需加,ETag **只由这一处**从**不带结尾 CRLF** 的卡算出来。
|
||
* 以后再加第三条路径时,它也只能走这里。
|
||
*
|
||
* @return array{body:string, etag:string}
|
||
*/
|
||
public static function cardOf(array $row): array
|
||
{
|
||
$body = Vcard::card($row);
|
||
return ['body' => $body, 'etag' => self::etag($body)];
|
||
}
|
||
|
||
/**
|
||
* ETag:客户端靠它判断"这张卡改过没有"。
|
||
*
|
||
* ⚠️ 用**内容**哈希而不是 updated_at 的最大值:名册的可见性还受"关系/分享"影响
|
||
* (同一个人没改,但别人把他分享给了我 → 我这本的内容变了,而 updated_at 没变)。
|
||
* 内容哈希对这种"看不见的改动"也敏感,是这里唯一稳的判据。
|
||
*/
|
||
public static function etag(string $vcf): string
|
||
{
|
||
return '"' . md5($vcf) . '"';
|
||
}
|
||
|
||
// ---------------- 应答辅助(与 CalDav 同名同义,刻意保持一致) ----------------
|
||
|
||
public static function multis(int $code = 207): void
|
||
{
|
||
http_response_code($code);
|
||
header('Content-Type: application/xml; charset=utf-8');
|
||
}
|
||
|
||
public static function x(string $s): string
|
||
{
|
||
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||
}
|
||
} |