`。前缀 `task-` 是为了与日历的 UID 空间**隔离**: * 日历的 UID 可能是 `syncal-1758000003001-1d8b60`、`movie-100_familybook`, * 或客户端自造的任意串。加前缀后,即便有人在日历本里放了 UID 叫 `task-5` 的 * 日程,也不会与本模块的待办 5 互相干扰(路由本就分开了,这里是双保险)。 */ declare(strict_types=1); namespace App; final class TaskDav { /** 虚拟本的 href(客户端看到的集合地址) */ public const BOOK_HREF = '/dav/tasks.ics/'; /** * 虚拟本 id。 * * ⚠️ **负数**:`calendar_events.calendar_id` 是 INT UNSIGNED(≥1), * 用负数永远撞不上;且在 parsePath 的返回值里一眼能认出"这是待办本"。 */ public const BOOK_ID = -1; /** 虚拟本显示名 */ public const BOOK_NAME = '家庭待办'; /** 资源名前缀(与日历 UID 空间隔离) */ public const UID_PREFIX = 'task-'; /** 本实现的 ctag 用:客户端拿它判"这整本变没变" */ private const CTAG_VERSION = 1; /** 这个 book id 是不是待办本 */ public static function isTaskBook(int $bookId): bool { return $bookId === self::BOOK_ID; } /** 这个 href(如 `/dav/tasks.ics` 或带尾斜杠)是不是待办本 */ public static function isTaskHref(string $href): bool { $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); return $p === rtrim(self::BOOK_HREF, '/'); } /** * 这个路径是不是待办本**及其子资源**(`/dav/tasks.ics/` 或 `/dav/tasks.ics/task-5.ics`)。 * * 🔴 2026-11-02 修:PUT/GET 单条/DELETE 报 404。 * 根因是原来全用 `isTaskHref()`,它只比"整条路径 == /dav/tasks.ics/", * 带子资源时(`/dav/tasks.ics/task-99999.ics`)必然 false ⇒ * 请求落到原有日历分支,而 `book=0` 又不是任何一本日历 ⇒ 404。 * * ⚠️ 必须用前缀匹配而不是再拆一段: * `/dav/tasks.icsXXX` 这种畸形路径不该被当成待办本, * 所以前缀后面必须是 "/" 或结束。 */ public static function isTaskPath(string $href): bool { $p = rtrim((string)parse_url($href, PHP_URL_PATH), '/'); $base = rtrim(self::BOOK_HREF, '/'); if ($p === $base) { return true; } return strpos($p, $base . '/') === 0; } /** * 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。 * * ⚠️ 与 CalDav::segmentOf() 同口径:**一律用 UID**。 * 客户端 PUT 到 `/dav/tasks.ics/task-5.ics` 后,必须能按同名 GET 回来, * 否则会表现成"写进去了又没了"(CalDav 那边踩过这个坑,注释记着)。 */ public static function segmentOf(array $t): string { $id = (int)($t['id'] ?? 0); return self::UID_PREFIX . $id; } /** 从资源段反解任务 id(`task-5` → 5;认不出来返回 0) */ public static function idOfSegment(string $seg): int { $seg = trim($seg); if ($seg === '') { return 0; } // 兼容客户端在 UID 后面追加后缀的情况(有些客户端会把 UID 再净化) if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) { return (int)$m[1]; } return 0; } /** * 当前用户能看到的全部任务(含已完成),按"父在前、子在后 + 截止时间"排。 * * ⚠️ 只查一次库,**不逐条调 `Task::find()`** —— 那会变成 N+1 * (`find()` 还会顺带查 comments / attachs / shares,对列表来说是浪费)。 * 权限用 `Task::visibleTo()` 逐条判(它是纯内存判断,除 isShared 会查一次 * 子任务父级,量很小)。 * * @return array tasks 行 + `board_name` + `uid` */ public static function all(int $familyId, int $personId): array { $st = Database::get()->prepare( 'SELECT t.*, b.name AS board_name FROM tasks t LEFT JOIN task_boards b ON b.id = t.board_id WHERE t.family_id = ? ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id' ); $st->execute([$familyId]); $out = []; foreach ($st->fetchAll() as $r) { if (!Task::visibleTo($r, $personId)) { continue; } $r['uid'] = self::UID_PREFIX . (int)$r['id']; $out[] = $r; } return $out; } /** 单条任务(含权限判断)。取不到或无权看 → null。 */ public static function one(int $familyId, int $personId, int $taskId): ?array { if ($taskId <= 0) { return null; } $st = Database::get()->prepare( 'SELECT t.*, b.name AS board_name FROM tasks t LEFT JOIN task_boards b ON b.id = t.board_id WHERE t.id = ? AND t.family_id = ?' ); $st->execute([$taskId, $familyId]); $r = $st->fetch(); if (!$r) { return null; } if (!Task::visibleTo($r, $personId)) { return null; } $r['uid'] = self::UID_PREFIX . (int)$r['id']; return $r; } /** * 一条任务 → 资源(ics 正文 + ETag)。 * * ⚠️ ETag 由**内容哈希**算出,不用"当前时间"。 * CalDav 那边为这个踩过坑(ETag 潮汐 ⇒ 客户端每轮全量重拉), * 这里从一开始就按正确做法来。 */ public static function resourceOf(array $t): array { $ics = Ics::exportTodos([$t], self::BOOK_NAME); return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"']; } /** 整本导出(GET / REPORT 用) */ public static function ics(int $familyId, int $personId): string { return Ics::exportTodos(self::all($familyId, $personId), self::BOOK_NAME); } /** * ctag:整本"变没变"的指纹。 * * ⚠️ 必须覆盖**任何会影响导出的变化**: * · 条目增删 ⇒ COUNT 变 * · 任意一条改动 ⇒ MAX(updated_at) / SUM 变 * · **状态变化也算改动** —— `setStatus` 会写 updated_at,所以上面这条能兜住 * (但 `markDone` 之类的旁路如果只改 done_at 不改 updated_at, * 这里会漏;所以额外把 SUM(UNIX_TIMESTAMP(done_at)) 也算进来)。 * * ⚠️ 不用 `MAX(updated_at)` 单打独斗:同一秒内改两条时它不变, * 客户端会漏一次同步。加上 COUNT 与 done_at 的和,碰撞概率足够低。 */ public static function ctag(int $familyId, int $personId): string { /* ⚠️ 这里按「全 family」统计而不是「这个人可见的」: 可见性是内存里判的(Task::visibleTo 有 shares 查询), 放进 SQL 会很复杂。按全家统计的后果只是"别人的任务变了也会让我重拉一次", 属于**偏保守但安全**的方向(宁可多拉,不可漏拉)。 */ $st = Database::get()->prepare( 'SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS mx, COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn FROM tasks WHERE family_id = ?' ); $st->execute([$familyId]); $r = $st->fetch() ?: []; $seed = self::CTAG_VERSION . '|' . (int)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (int)($r['dn'] ?? 0); return '"' . md5($seed) . '"'; } /** * 客户端 PUT 一条待办 → 落库。 * * @param int $taskId URL 段里解析出的 id(0 = 新建) * @param string $body text/calendar 正文 * * @return array{id:int, created:bool} * @throws \RuntimeException 正文不合法 / 无写权限 */ public static function put(int $familyId, int $personId, int $taskId, string $body): array { $parsed = Ics::parseTodos($body); if (!$parsed) { throw new \RuntimeException('正文里没有找到 VTODO。'); } // 一个资源 = 一条待办。出现多个说明客户端行为异常,取第一条(与 CalDav 同口径) $n = Ics::normalizeTodo($parsed[0]); if ($n['summary'] === '') { throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。'); } // ---- 更新已存在的 ---- /* ⚠️ 只有当这条**确实存在且有权访问**时才走更新分支。 否则(比如客户端 PUT 到 task-99999.ics,而 99999 不存在) 必须**新建** —— RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。 原来这里直接抛"任务不存在",会让客户端建不了新待办(实测 403)。 这也兼容"客户端用自己编的 UID 当资源名"的场景。 */ $old = $taskId > 0 ? self::one($familyId, $personId, $taskId) : null; if ($old !== null) { if (!Task::canWrite($old, $personId)) { throw new \RuntimeException('这条任务你没有写权限。'); } /* ⚠️ `Task::update()` 会把**没传的字段清空**(它按 `$d['x'] ?? ''` 取值), 所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。 这是"部分更新"最容易出的 bug。 */ Task::update($familyId, $taskId, [ 'title' => $n['summary'], 'note' => $n['description'], 'status' => $n['status'], 'due_at' => $n['due'] ?? '', 'assignee_id' => (int)($old['assignee_id'] ?? 0), 'repeat_rule' => (string)($old['repeat_rule'] ?? ''), 'parent_id' => (int)($old['parent_id'] ?? 0), 'board_id' => (int)($old['board_id'] ?? 0), 'category_id' => (int)($old['category_id'] ?? 0), ]); // 完成时间:`update()` 走到 done 会自动 markDone;这里补 done_at 的精确值 // (客户端给的 COMPLETED 时间比"服务器当前时间"更准) if ($n['status'] === Task::DONE_STATUS && !empty($n['completed'])) { Database::get()->prepare( 'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?' )->execute([$n['completed'], $taskId, $familyId]); } return ['id' => $taskId, 'created' => false]; } // ---- 新建 ---- $id = Task::create($familyId, $personId, [ 'title' => $n['summary'], 'note' => $n['description'], 'status' => $n['status'], 'due_at' => $n['due'] ?? '', ]); if ($id <= 0) { throw new \RuntimeException('创建任务失败。'); } if ($n['status'] === Task::DONE_STATUS && !empty($n['completed'])) { Database::get()->prepare( 'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?' )->execute([$n['completed'], $id, $familyId]); } return ['id' => $id, 'created' => true]; } /** 客户端 DELETE 一条待办 */ public static function delete(int $familyId, int $personId, int $taskId): bool { $t = self::one($familyId, $personId, $taskId); if (!$t) { return false; } if (!Task::canWrite($t, $personId)) { return false; } Task::destroy($familyId, $taskId); return true; } }