//<子动作>` 后调这里。 * * ⚠️ 为什么不做成"每资源一个文件":本项目的部署与静态断言都是**按文件白名单**走的 * (见 `familybook_deploy.sh`),几十个小文件会让"哪些该进包"变成一件要维护的事。 * 一个文件 + 清晰分段,是这里更稳的选择。 * ⚠️ 每个写操作**都**先调 `Api::requireWrite()` —— 只读账号在网页上不能写, * 在 API 里也必须不能写(权限裂缝是最不该出现的那种 bug)。 */ declare(strict_types=1); namespace App; final class ApiHandler { /** * @param string $res 资源名(people / movies / …) * @param int $id 路径里的数字 id(没有则 0) * @param string $sub 子动作(如 tasks/12/status 里的 status) * @param string $method GET / POST / PATCH / DELETE */ public function route(string $res, int $id, string $sub, string $method): void { // 健康检查 / 版本探测:**不需要令牌**(App 首次填「服务器地址」时就打它) if ($res === 'ping' || $res === '') { Api::ok([ 'service' => 'familybook', 'api' => 'v1', 'time' => date('c'), // 🔴 v55:端上"输地址 → 连接"那一步要能确认 // "这是个家书服务器、且支持账号登录" —— 只回 HTTP 200 不够 // (任何网页都 200,用户填错地址会让他在登录页反复试密码)。 'app' => '家书', 'features' => [ 'login' => 1, // 认手机号也认用户名(见 `login()` 的说明) 'login_account' => 1, // v57:登录即"一台设备一个会话",`/me` 滑动续期 24h, // `/devices` 可在网页端/端上踢设备 'device_session' => 1, /* 🔴 v61:账号自助 —— 端上「个人设置」页要能改密码、 看自己这台设备。`/account` 返回 profile + 当前设备, `POST /account/password` 改密码。 */ 'account' => 1, 'account_password' => 1, 'home_memories' => 1, /* 🔴 v62:影院管理补齐(用户点名「增加影院/修改影院/高级搜索/ 统计/年度报告」)。端上可据此探测:老服务端没有这三条时 只显示影院列表,不显示搜索/统计/报告的入口。 */ 'movies_stats' => 1, 'movies_report' => 1, 'movies_search' => 1, /* 🔴 v64(用户 2026-10-19):端上「记一场」要与网页端**同步实现** —— 片名联想 + 一键获取简介与海报(豆瓣),海报/观影照片多图上传。 ⚠️ 这两件事网页端早就有(`movies/douban` 路由 + `movies/create` 的 poster/photo 槽位),缺的只是**移动端入口**: 网页那条走 `index.php?r=movies/douban`,App 打不到。 ⇒ `movies_douban` = `/movies/douban`(suggest / 抓取 / 缩略图代理); `movie_media` = `/movies//poster|photos`(multipart 上传)。 端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */ /* 🔴 v66:旅程「车辆管理 / 节点类型」两条 CRUD 接口。 端上据此探测:老服务端没有它们时,设置菜单里那两项 只是说明,不显示增删改按钮。 */ 'trips_vehicles' => 1, 'trips_nodetypes' => 1, 'movies_douban' => 1, 'movie_media' => 1, // v58:同步可以"先问一句哪些模块变了",只拉变了的 // (见 `changes()` 的说明;老端上没有这个标志也不会坏) 'changes' => 1, ], ]); } switch ($res) { case 'me': $this->me($method); return; // 🔴 v57:设备会话。`logout` 吊销当前这一枚;`devices` 是"我有哪些设备在登录"。 case 'logout': $this->logout($method); return; case 'devices': $this->devices($id, $method); return; case 'tokens': $this->tokens($id, $method); return; // 🔴 v58:增量同步的"变更指纹"。端上先问一遍,只拉真的变了的模块 // (首次全量、之后增量)。详见 `changes()`。 case 'changes': $this->changes($method); return; case 'home': $this->home(); return; case 'people': $this->people($id, $sub, $method); return; // 🔴 v45:名册(**我名册里的人**,不是全家 800 人)。 // 与 `people` 的区别见 `roster()` 方法的说明。 case 'roster': $this->roster($method); return; // 🔴 v45:家谱(以我为中心的关系图)。 // 端上「家谱」页要用:父母 / 配偶 / 子女 / 兄弟姐妹 + 各代层次。 // ⚠️ 数据来自 `relations` 表(只存相邻两代的边), // 亲属关系靠 `Kinship` 推导 —— 与 Web 端 `kin/tree.php` 同一套。 case 'kin': $this->kin(); return; case 'notifications': $this->notifications($id, $sub, $method); return; case 'calendar': $this->calendar($sub, $method); return; case 'events': $this->events($id, $sub, $method); return; case 'days': $this->days($id, $method); return; // 纪念日 case 'tasks': $this->tasks($id, $sub, $method); return; case 'movies': $this->movies($id, $sub, $method); return; case 'cinemas': $this->cinemas($id, $method); return; case 'trips': $this->trips($id, $sub, $method); return; /* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。 ⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 —— `Api::dispatch()` 只解析三段(`///`), 四段的 `/trips/vehicles` 会被当成「trips 的第 0 条」而落到列表。 */ case 'vehicles': $this->vehicles($id, $method); return; case 'nodetypes': $this->nodeTypes($id, $method); return; case 'ledger': $this->ledger($id, $sub, $method); return; case 'resources': $this->resources($id, $method); return; case 'books': $this->books($id, $sub, $method); return; case 'notes': $this->notes($id, $sub, $method); return; // 🔴 v45:登录(手机号 + 密码 → API 令牌)。 // 为什么必须走 API 而不是网页登录后复制 Cookie: // App 是 Bearer 令牌那套,没有会话;而原先前端只能让人 // **手动去网页签发令牌再粘贴**到 App 里 —— 这个体验没法接受。 case 'login': $this->login($method); return; /* 🔴 v61:账号自助(端上「个人设置」页)。 ⚠️ 与网页那条改密码路由**同一套规则**: 必须验旧密码、两次新密码一致、且过 `Account::validatePassword()` (≥8 位 + 字母与数字都有)—— 端上不该比网页松。 */ case 'account': $this->account($sub, $method); return; } Api::fail('未知的接口资源:' . $res, 'not_found', 404); } /* ═══════════════════════════════════════════════════════════════ 登录(手机号 + 密码 → API 令牌) ═══════════════════════════════════════════════════════════════ */ /** * `POST /api/v1/login` —— 用「手机号 或 用户名」+ 密码换一枚 API 令牌。 * * 🔴 为什么需要这个接口(v45,用户 2026-10-12 "把服务器接进来、跑正式流程"): * 原先 App 只能让人**先去网页「个人设置 → API 令牌」签发、再手动粘贴** * 到 App 的设置页里。这是个"能用但没法给别人用"的流程。 * 有了它,App 首页就能直接登录。 * * 🔴 v55(用户 2026-10-17「完整的登录功能……输入用户名密码」)—— * **同一个入口既认手机号也认用户名**: * · 输入像手机号(纯数字/带 + - 空格)→ 走 `phone_hash` 精确查找; * · 否则当作**用户名**(= 名册里的姓名)→ 只在"有登录账号的人"里找。 * ⚠️ 为什么要这样(而不是只留手机号):一家人里老人记不住手机号, * 但一定记得住自己叫什么;而 `accounts` 表里**没有用户名列** * (见 Schema),为它加列 + 迁移的代价远大于收益。 * 姓名在库里本来就有(`persons.name`),且带账号的人极少(一家几个人), * 重名风险低到可以接受 —— 真重名时**明确报错让他改用手机号**, * 而不是随便挑一个。 * ⚠️ 参数名从 `phone` 扩成 `account`,**仍然接受 `phone`** * (老版本 App 还在用,不能因为服务端升级就把人挡在门外)。 * * ⚠️ 与网页登录(`Auth::login`)的三处**有意不同**: * ① 不建会话 —— API 是无状态 Bearer,登录只是"换令牌"; * ② 限流不能放 `$_SESSION`(API 没有 session)⇒ 落**文件**, * 按"登录名 + 来源 IP"计数(见下方 `loginThrottle`); * ③ 失败**不区分**"没这个人"与"密码错" —— 同一句提示, * 否则接口成了"手机号是否已注册"的探测器。 * * ⚠️ 成功时**顺带补建默认日历本**(与 `Auth::login` 同样的兜底): * 全新安装时 `Schema::ensure()` 跑在"建人"之前, * 那次遍历一个账号都没有 ⇒ 一本日历都不建。放在登录处最稳。 */ private function login(string $method): void { if ($method !== 'POST') { Api::fail('登录要用 POST。', 'method_not_allowed', 405); } // `account` 优先;没有就退回 `phone`(老客户端兼容,见方法注释) $account = Api::str('account', '', 60); if ($account === '') { $account = Api::str('phone', '', 40); } $password = (string)Api::param('password', ''); $label = Api::str('label', 'App 登录', 60); /* ---- v57:设备会话信息(用户 2026-10-17)---- 用户原话:「我们在请求服务端的时候,就需要把我们的客户端的这个设备信息, 就是我的设备信息,提交到我们的服务器端。」 ⚠️ `device_id` 是**端上自己生成并持久化的 UUID**,不是硬件号 —— HarmonyOS 三方应用没有 UDID/序列号权限(那是系统级权限,上架也会被拒)。 所以"卸载重装 = 一台新设备"是预期行为,用户已确认。 ⚠️ 老客户端不带这几个参数:`issueDevice()` 会兜底生成设备号, 不会退化成"没有设备号的行"(那种行在设备列表里看不见、踢不掉)。 */ $deviceId = Api::str('device_id', '', 64); $deviceName = Api::str('device_name', '', 60); $platform = Api::str('platform', '', 30); $appVersion = Api::str('app_version', '', 30); if ($deviceId === '') { // 与 `ApiToken::issueDevice()` 的兜底**保持一致**(否则登录响应里回显的是空串) $deviceId = 'auto-unknown'; } if ($deviceName === '') { $deviceName = $label; // 端上没给名字就用 label,设备列表里至少有个称呼 } if ($account === '' || $password === '') { Api::fail('账号(手机号或用户名)和密码都得填。', 'unprocessable', 422); } // ---- 限流:失败次数有成本(防爆破)---- $gate = self::loginThrottle($account); if ($gate !== null) { Api::fail($gate, 'too_many_requests', 429); } $row = self::findLoginAccount($account); // ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。 // 分开回话就等于告诉对方"这个手机号存在/不存在"。 $ok = $row && (string)$row['status'] === 'active' && password_verify($password, (string)$row['password_hash']); if (!$ok) { self::loginThrottleFail($account); // 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本) usleep(300000); Api::fail('账号或密码不对。', 'unauthorized', 401); } self::loginThrottleClear($account); $personId = (int)$row['person_id']; $familyId = (int)$row['family_id']; /* ⚠️ 设备会话**一律 24 小时滑动续期**(用户 2026-10-17)。 原来这里有个 `days` 参数(0 = 永不过期)。v57 起不再读它: 同一个接口存在两种有效期语义,端上就没法判断"什么时候该锁"—— 而"丢了的手机必须进不来"正是靠 24h 这条线兜住的。 老客户端传 `days` 不会报错,只是不再影响结果。 */ $token = ApiToken::issueDevice($personId, $deviceId, $deviceName, $platform, $appVersion); if ($token === null) { // 设备数已达上限:**这一步没有删任何行**(见 ApiToken::issueDevice 的①) Api::fail( '这个账号登录的设备已经达到 ' . ApiToken::MAX_DEVICES . ' 台。请先在网页端的「设备管理」里退掉不用的设备,再来登录。', 'conflict', 409 ); } Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?') ->execute([(int)$row['account_id']]); // 进门补默认日历本(幂等;失败不挡登录) try { Calendar::seedDefaults($familyId, $personId); } catch (\Throwable $ex) { error_log('[familybook] 登录补建默认日历本失败(已跳过):' . $ex->getMessage()); } // 家庭名(端上登录成功后立刻能显示"已连上 X 家", // 不必再多打一次 `/me`;拿不到就给空串,不影响登录) $familyName = ''; try { $f = Family::find($familyId) ?: []; $familyName = (string)($f['name'] ?? ''); } catch (\Throwable $ex) { error_log('[familybook] 登录取家庭名失败(已跳过):' . $ex->getMessage()); } /* 🔴 2026-10-18:登录响应直接带上**我该显示的那句标语**。 用户要求把抽屉左上角那行(原来是「我的家」)换成标语: 有个人 slogan 用个人的、没有用家庭的、再没有用应用默认口号 (三层优先级见 `Family::slogan()`,与网页端首页同一个函数)。 ⚠️ 放在登录里而不是只放 `/me`:登录后**立刻**就要渲染抽屉, 少一次请求就少一次"先显示旧值再跳变"的闪烁。 */ $slogan = Family::DEFAULT_SLOGAN; try { $slogan = Family::slogan($familyId, $personId); } catch (\Throwable $ex) { error_log('[familybook] 登录取标语失败(回退默认):' . $ex->getMessage()); } $avatarRaw = (string)($row['avatar'] ?? ''); Api::ok([ 'token' => $token, // 到期时间由**服务端**算(端上只拿来显示,绝不据此判断"还能不能用") 'expires_at' => date('Y-m-d H:i:s', time() + ApiToken::TTL_HOURS * 3600), 'ttl_hours' => ApiToken::TTL_HOURS, // 端上要用它记"上次校验成功的时间"(本机时钟可以改,服务端时间不行) 'server_time'=> date('c'), 'person' => [ 'id' => $personId, 'name' => (string)$row['name'], 'avatar' => $avatarRaw, /* 🔴 头像 URL 与 `/me` 同口径(`avatar_url()` 会按线上存储驱动 决定走 /uploads 还是 OSS 直链)。 ⚠️ 端上顶栏原来只拿色块字、拿不到真图 —— 用户 2026-10-18: 「右上角的头像应该是真实的」。 */ 'avatar_url'=> avatar_url($avatarRaw !== '' ? $avatarRaw : null), 'role' => (string)$row['role'], 'family_id' => $familyId, ], 'family' => ['id' => $familyId, 'name' => $familyName, 'slogan' => $slogan], // 回显设备,端上可以核对"服务器记住的是不是这台" 'device' => [ 'id' => $deviceId, 'name' => $deviceName, 'max_devices'=> ApiToken::MAX_DEVICES, ], ]); } /** * 账号自助(端上「个人设置」页)。 * * GET /api/v1/account → 我的资料 + 账号信息 + 当前设备 * POST /api/v1/account/password → 改密码(old_password / password / password2) * * 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、修改密码、 * 系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」 * ⇒ 端上个人设置页要能**自己改密码**,不能再指着用户去网页改。 * * ⚠️ 规则与网页那条路由**逐条对齐**(不能端上比网页松): * · 必须验旧密码; * · 两次新密码必须一致; * · 过 `Account::validatePassword()`(≥8 位 + 字母数字都要有)。 * ⚠️ 新密码**不能与原密码相同**:网页那边没拦,但端上自己拦一道没坏处 * (用户改完发现"还是旧密码"会以为没保存成功)。 * ⚠️ 改完**不动现有令牌**:用户在自己的手机上改密码,不该把自己踹下线 * (其它设备上的会话仍有效 —— 要踢设备请用 `/devices`)。 */ private function account(string $sub, string $method): void { $act = Api::actor(); $pid = (int)$act['person_id']; if ($sub === 'password') { if ($method !== 'POST') { Api::fail('改密码要用 POST。', 'method_not_allowed', 405); } /* ⚠️ 这里**刻意不做写权限校验**: 只读访客(guest)在网页上也能改自己的密码 (那条路由只要求登录,不查写权限)。 端上若加了写权限检查,就会出现"网页能改、App 不能改"的权限裂缝。 */ $old = (string)Api::param('old_password', ''); $new = (string)Api::param('password', ''); $new2 = (string)Api::param('password2', ''); $acc = Account::findByPerson($pid); if ($acc === null) { Api::fail('这个账号不存在(可能只有成员资料、还没开通登录)。', 'not_found', 404); } if (!password_verify($old, (string)$acc['password_hash'])) { // 只回"当前密码不对",不回其它线索 Api::fail('当前密码不对。', 'unauthorized', 401); } if ($new !== $new2) { Api::fail('两次输入的新密码不一致。', 'unprocessable', 422); } if ($new === $old) { Api::fail('新密码不能和当前密码一样。', 'unprocessable', 422); } $errs = Account::validatePassword($new); if (count($errs) > 0) { Api::fail(implode(';', $errs), 'unprocessable', 422); } Account::setPassword((int)$acc['id'], $new); Api::ok(['changed' => 1]); } if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } $p = Person::find($pid) ?: []; $acc = Account::findByPerson($pid); $avatarRaw = (string)($p['avatar'] ?? ''); Api::ok([ 'person' => [ 'id' => $pid, 'name' => (string)($p['name'] ?? ''), 'avatar_url' => avatar_url($avatarRaw !== '' ? $avatarRaw : null), 'role' => (string)($act['role'] ?? 'member'), 'can_write' => (bool)($act['can_write'] ?? false), ], 'account' => [ 'has_login' => $acc !== null ? 1 : 0, 'role' => $acc !== null ? (string)$acc['role'] : '', 'status' => $acc !== null ? (string)$acc['status'] : '', 'last_login' => $acc !== null ? (string)($acc['last_login_at'] ?? '') : '', // ⚠️ 密码规则由**服务端**给,端上只用来写提示语 // (避免两边各写一份"至少 8 位"然后慢慢对不上) 'password_min' => 8, ], 'device' => [ 'id' => (string)($act['device_id'] ?? ''), 'name' => (string)($act['device_name'] ?? ''), ], 'is_admin' => ($act['role'] ?? '') === 'admin' ? 1 : 0, ]); } /** * 按「手机号 或 用户名」找人(登录用)。 * * 🔴 两步查找,顺序**不能反**: * ① **手机号**:`phone_hash` 是确定性哈希 + 唯一索引,一条 SQL 精确命中, * 且绝不会跟"某个人的名字"混淆; * ② **用户名**(= `persons.name`):只在①落空、且输入**不像手机号**时才走。 * 多个人同名 AND 都有账号 ⇒ 报 409 让用户改用手机号, * **绝不随便挑一个**(挑错人 = 让他登进别人的账号)。 * * ⚠️ 为什么用户名不走 `Crypto::hash()`:那个函数是给手机号设计的 * (会剥掉空格/横线/`+86` 前缀),拿它算姓名只会得到一个"和谁都不相等"的哈希。 * 姓名直接明文比对(`utf8mb4_unicode_ci` 天然忽略大小写/全半角差异)。 * ⚠️ 这里**不筛 `a.status`**:账号被停用也要走到"账号或密码不对"那句统一提示, * 否则"这个名字有账号但被停用"就成了一条侧信道。 */ private static function findLoginAccount(string $account): ?array { $cols = 'SELECT a.id AS account_id, a.password_hash, a.role, a.status, p.id AS person_id, p.family_id, p.name, p.avatar FROM accounts a JOIN persons p ON p.id = a.person_id'; // ① 手机号(确定性哈希) $st = Database::get()->prepare($cols . ' WHERE p.phone_hash = ? LIMIT 1'); $st->execute([Crypto::hash($account)]); $row = $st->fetch(); if ($row) { return $row; } // ② 用户名 —— 只有"看起来不是手机号"才试。 // ⚠️ 判据用"全是数字与号码符号"而不是"纯数字":手机号里可能有 + - ( ) 空格。 if (preg_match('/^[0-9+\-()\s]+$/', $account)) { return null; } $st = Database::get()->prepare($cols . ' WHERE p.name = ? LIMIT 2'); $st->execute([$account]); $hits = $st->fetchAll(); if (count($hits) === 1) { return $hits[0]; } if (count($hits) > 1) { // 同名且都有账号:明确拒绝,别猜 Api::fail('这个用户名对应多个账号,请改用手机号登录。', 'conflict', 409); } return null; } /* ---- 登录限流(**文件实现**,不碰数据库结构)---- ⚠️ 为什么不用 session:API 是无状态的,压根没有 session。 ⚠️ 为什么不用数据库表:这只是一个"短期计数器", 为它加表 + 写迁移的代价大于收益(本应用是单机部署)。 ⇒ 落 `sys_get_temp_dir()` 下的小文件,60 秒窗口、5 次失败。 ⚠️ 键里带 IP:防止"只针对某个手机号"刷爆别人的登录 (但也别只按 IP —— 同一家庭可能共用出口 IP)。 */ private static function loginThrottleFile(string $phone): string { $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '0'); // ⚠️ 文件名用 hash,**不要把手机号写进文件名**(临时目录可能被列举) return sys_get_temp_dir() . '/fb_login_' . hash('sha256', $ip . '|' . strtolower(trim($phone))) . '.json'; } /** 返回"拦下来的提示",null = 放行 */ private static function loginThrottle(string $phone): ?string { $f = self::loginThrottleFile($phone); if (!is_file($f)) { return null; } $raw = @file_get_contents($f); $d = $raw !== false ? json_decode($raw, true) : null; if (!is_array($d)) { return null; } $n = (int)($d['n'] ?? 0); $t = (int)($d['t'] ?? 0); if ($n >= 5 && (time() - $t) < 60) { return '尝试太频繁了,请等 1 分钟再试。'; } return null; } private static function loginThrottleFail(string $phone): void { $f = self::loginThrottleFile($phone); $raw = @file_get_contents($f); $d = $raw !== false ? json_decode($raw, true) : null; $n = is_array($d) ? (int)($d['n'] ?? 0) : 0; // 超过窗口就重新计数(否则失败过一次的人永远被算在同一轮里) $t = is_array($d) ? (int)($d['t'] ?? 0) : 0; if ((time() - $t) >= 60) { $n = 0; } @file_put_contents($f, json_encode(['n' => $n + 1, 't' => time()]), LOCK_EX); } private static function loginThrottleClear(string $phone): void { @unlink(self::loginThrottleFile($phone)); } /** * `GET /api/v1/roster` —— 「**我**的名册」(App 名册页的主数据)。 * * 🔴 与 `GET /people` 的**关键区别**(这决定了 App 名册页该用哪个): * · `/people` —— 全家的人(本家 801 人),是"通讯录底账"; * · `/roster` —— **我名册里的人**(我加的 + 别人分享给我的), * 是一个人**自己挑出来、常用**的那一小撮。 * Web 端名册页用的就是后者(`Person::roster()`,见 index.php 的注释)。 * ⇒ App 的名册页必须用 `/roster`,否则一屏 800 人,且是"别人的通讯录"。 * * 组装顺序**照抄 Web 端 `index.php` 的 people 分支**(同一套类、同一套判据): * `Person::roster` → `Kinship::label`(我该怎么称呼他) * → `Nickname::display`(我怎么叫他)/ `PersonQuery::phonePlain`(解密手机号) * → `PersonQuery::birthdaysOf`(阴历阳历只取月日) * → `PersonTag::mapFor`(每人的标签) * ⚠️ 一定要复用这些类而不是在 API 里另写一遍: * 否则会出现"网页里搜得到、App 里搜不到"这类**两端不一致** * (`PersonQuery` 的类注释专门强调过这一点)。 * * 查询参数:`?q=`(关键字,走 PersonQuery 同一套粗筛 + 精筛)、 * `?kind=member|contact`、`?tag=`(只看某个标签)。 */ private function roster(string $method): void { if ($method !== 'GET') { Api::fail('名册只支持 GET。', 'method_not_allowed', 405); } [$fam, $pid] = Api::me(); $kind = Api::str('kind', 'member'); $q = Api::str('q'); $rows = Person::roster($fam, $pid, $kind, $q); // ---- 装饰(与 Web 端同一套)---- $graph = Kinship::graph($fam); $people = Person::map($fam); $tagsOf = PersonTag::mapFor($fam, $pid); foreach ($rows as &$row) { // 「我该怎么称呼他」 $row['label'] = Kinship::label($graph, $pid, (int)$row['id'], $people); // 「我怎么叫他」(昵称优先,没有就用本名) $row['shown'] = Nickname::display($pid, $row) ?: (string)$row['name']; // 手机号**明文**(库里的 phone_encrypted 解密;空则空串) $row['phone_plain'] = PersonQuery::phonePlain($row); // 🔴 **全部电话**(主号在前 + person_contacts 里的补充电话)。 // App 点人名直接拨号要用:有主号打主号,没主号打补充号 // (用户 2026-10-15 定的规则)。见 PersonQuery::phonesOf 的说明。 $row['phones'] = PersonQuery::phonesOf($row); // 生日:阴历/阳历各一条,**只到月日**(年份不外发) $row['birthdays'] = PersonQuery::birthdaysOf($row); // 标签 $row['tags'] = $tagsOf[(int)$row['id']] ?? []; // 首字母(App 侧"输入即筛选"用;与网页 filter.js 的 data-initials 同一来源) $row['initials'] = Pinyin::initials( (string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? '') ); /* 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容, 例如**工作单位、职务**。」 ⇒ 端上列表要显示"当前那段工作经历"的单位/职务。 ⚠️ 具体经历在下面**一次批量**取回(`PersonJob::mapFor`), 这里只准备字段位——**绝不在这里逐人查库**(N+1)。 */ $row['jobs'] = []; // 🔴 脱敏:名册行同样是 `SELECT p.*`,敏感密文一律不下发 // (见 stripSensitivePerson 的说明)。 $row = self::stripSensitivePerson($row); } unset($row); // ---- 标签筛选(`?tag=` 为空 = 全部)---- $tagFilter = Api::str('tag'); if ($tagFilter !== '') { $rows = array_values(array_filter( $rows, static fn(array $r): bool => in_array($tagFilter, $r['tags'] ?? [], true) )); } /* 🔴 v62:**一次**把所有人的工作经历取回来(`IN (…)` 单条 SQL)—— ⚠️ 不能在装饰循环里逐人 `PersonJob::list()`:名册上千人就是上千次查询。 排序与 `PersonJob::list()` 一致,端上取第一段即"当前/最近的那段"。 */ $jobsOf = PersonJob::mapFor(array_map(static fn(array $r): int => (int)$r['id'], $rows)); foreach ($rows as &$row2) { $row2['jobs'] = $jobsOf[(int)$row2['id']] ?? []; } unset($row2); Api::ok([ 'people' => array_values($rows), // 标签云(筛选条用)+ 标签词表(编辑时选已有标签) 'tagCloud' => PersonTag::cloud($fam, $pid), 'groups' => PersonGroup::orderFor($fam, $pid), 'tagFilter' => $tagFilter, 'kind' => $kind, 'meId' => $pid, 'q' => $q, ]); } /* ═══════════════════════════════════════════════════════════════ 我 / 令牌 / 首页 ═══════════════════════════════════════════════════════════════ */ private function me(string $method): void { [$fam, $pid] = Api::me(); $p = Person::find($pid) ?: []; // 🔴 角色与写权限**必须来自 `Api::actor()`**,不能用 `Auth::roleWord()` / // `Auth::canWrite()` —— 那两个读的是**网页 session**,而 API 走 Bearer // 没有 session ⇒ 一律回落成 'member'/false(实测 `/me` 的 role 恒为"家庭成员"、 // `can_edit_all` 恒为 false,管理员在 App 里被当成只读)。 // `Api::actor()` 已经按同一语义(role + status)重算好了。 $act = Api::actor(); Api::ok([ 'id' => $pid, 'name' => (string)($p['name'] ?? ''), 'avatar' => avatar_url($p['avatar'] ?? null), 'role' => (string)($act['role'] ?? 'member'), 'can_write'=> (bool)($act['can_write'] ?? false), 'family' => (function () use ($fam, $pid): array { $f = Family::find($fam) ?: []; /* 🔴 2026-10-18:标语一并给出来(抽屉左上角那行要它)。 每次启动都会打 `/me`,所以网页端改了 slogan 之后 手机下次启动就会跟上 —— 不必专门为它加一个接口。 */ $sl = Family::DEFAULT_SLOGAN; try { $sl = Family::slogan($fam, $pid); } catch (\Throwable $ex) { // 读不到就用默认,不挡登录校验 } return [ 'id' => $fam, 'name' => (string)($f['name'] ?? ''), 'slogan' => $sl, ]; })(), // 各模块主题色:App 端顶部/分类色块直接用它,不必自己维护一张表 'colors' => $this->moduleColors($fam), /* ---- v57:这台设备的会话状态 ---- 端上**每次启动都打一次 `/me`**(用户 2026-10-17 同意): · 200 → 会话有效,服务端已自动续期,端上记下 `server_time`; · 401 → 已被踢/已过期,立刻清本地令牌并退回登录页。 同时这也是"离线超过 24h 要联网确认"落地的触发点 —— 因为一旦断网,`/me` 就失败,端上就只能靠"上次校验成功的时间"判断。 ⚠️ `expires_at` 是**续期后**的新时间(`ApiToken::verify()` 里已更新), 不是旧值;端上显示"还剩多久"直接用它减 `server_time`。 */ 'device' => [ 'id' => (string)($act['device_id'] ?? ''), 'name' => (string)($act['device_name'] ?? ''), 'expires_at' => isset($act['expires_at']) ? (string)$act['expires_at'] : null, 'ttl_hours' => ApiToken::TTL_HOURS, 'max_devices'=> ApiToken::MAX_DEVICES, ], 'server_time' => date('c'), ]); } private function moduleColors(int $fam): array { $out = []; foreach (['calendar', 'tasks', 'movies', 'trips', 'ledger', 'resources', 'books', 'notes', 'people'] as $k) { $out[$k] = Module::color($k, $fam); } return $out; } /** 令牌自助管理(App 里"退出登录/吊销设备") */ /** * 退出登录:吊销**当前这一枚**令牌(用户 2026-10-17)。 * * ⚠️ 端上的顺序必须是「先删本地令牌 → 再尽力调这个接口」: * 离线时这个请求根本发不出去,而"捡到手机的人拿不到本地令牌"才是主防线。 * 所以这里**永远是尽力而为** —— 删不掉也不该报错拦住用户, * 服务端那一条由网页端「设备管理」兜底踢掉。 */ private function logout(string $method): void { if ($method !== 'POST') { Api::fail('退出登录要用 POST。', 'method_not_allowed', 405); } $act = Api::actor(); $ok = ApiToken::revokeCurrent((int)$act['person_id'], (int)$act['token_id']); Api::ok(['revoked' => $ok ? 1 : 0]); } /** * 设备管理(用户 2026-10-17:「我都有哪些设备已经在登录这个东西」)。 * * GET /api/v1/devices → 我登录过的设备(每台一行) * DELETE /api/v1/devices?device_id=xxx → 踢掉某一台 * DELETE /api/v1/devices?others=1 → 踢掉除当前设备外的全部 * * ⚠️ 为什么踢设备用 query 而不是路径里的数字 id: * `device_id` 是 UUID(含字母与横线),而路径段只解析整数。 */ private function devices(int $id, string $method): void { $act = Api::actor(); $pid = (int)$act['person_id']; $currentTokenId = (int)$act['token_id']; $currentDeviceId = (string)($act['device_id'] ?? ''); if ($method === 'GET') { Api::ok([ 'max' => ApiToken::MAX_DEVICES, 'ttl_hours' => ApiToken::TTL_HOURS, 'current' => ['token_id' => $currentTokenId, 'device_id' => $currentDeviceId], 'items' => ApiToken::listDevices($pid, $currentTokenId, $currentDeviceId), ]); } if ($method === 'DELETE') { Api::requireWrite(); // 「其它设备都退掉」——当前这台必须保留,否则用户点一下自己也掉线了 if (Api::int('others', 0) === 1) { $n = ApiToken::revokeOthers($pid, $currentDeviceId, $currentTokenId); Api::ok(['revoked' => $n]); } $deviceId = Api::str('device_id', '', 64); if ($deviceId === '') { Api::fail('要踢哪台设备?请带上 device_id(或 others=1)。', 'unprocessable', 422); } // 不让在这里踢自己:那等于"自己把自己踹下线",语义上属于 logout if ($deviceId === $currentDeviceId) { Api::fail('不能在这里踢掉当前设备;要退出当前设备请用「退出登录」。', 'unprocessable', 422); } Api::ok(['revoked' => ApiToken::revokeDevice($pid, $deviceId)]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } private function tokens(int $id, string $method): void { [, $pid] = Api::me(); if ($method === 'GET') { Api::ok(ApiToken::listFor($pid)); } if ($method === 'DELETE') { Api::requireWrite(); if ($id > 0) { Api::ok(['revoked' => ApiToken::revoke($pid, $id) ? 1 : 0]); } Api::ok(['revoked' => ApiToken::revokeAll($pid)]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } /** * 首页(`GET /api/v1/home`)。 * * 🔴🔴 v54(用户 2026-10-16)—— **补齐网页版首页有的那几样**。 * * 用户原话:「首页上概述性的那个东西先弄好……里面的数据好像有些不符, * 比如**寒露、重阳节、儿子的生日**,这些都是**乱写的**,要用**真实数据**填写一下。」 * * 根因:网页版首页的信息面板(农历 / 节气 / 节日 / 纪念日 / 生日 / 天气 / * 近七天数字)是在 `index.php` 里**算好直接塞给模板**的, * 从来没走过这个接口 ⇒ App 只能拿端上的演示常量顶着, * 于是"寒露/重阳节/儿子生日"这种写死的假数据就一直显示在真实用户面前。 * ⇒ 这里把 `index.php` 那几段**原样搬过来**(同一批函数、同一条 SQL), * 保证两端**同一个数字**。 * * ⚠️ 为什么不在端上自己算农历/节气:那些表(`Holiday::solarTermRange` / * `Lunar::solarToLunar`)在服务端是**一份权威实现**, * 端上再实现一遍,早晚会出现"网页说今天小寒、手机说昨天"这种对不上的事。 */ private function home(): void { [$fam, $pid] = Api::me(); $today = date('Y-m-d'); $cals = Calendar::mine($fam, $pid); $soon = Calendar::month($fam, $cals, $pid, $today, date('Y-m-d', strtotime('+7 days'))); // ⚠️ 农历是**每天一条**的派生条目(7 天就是七八行"廿一 / 廿二…"), // 在首页这种"接下来要做什么"的清单里纯属噪音 —— 与 index.php 同一取舍。 // (节假日保留:"这周六放假"是有信息量的。) $soon = array_values(array_filter($soon, static fn(array $e): bool => (string)($e['kind'] ?? '') !== 'lunar')); $todayEvents = array_values(array_filter($soon, static fn(array $e): bool => (string)($e['date'] ?? '') === $today)); // 全天排最前、其余按时间(与网页版日视图同一顺序 —— 手机首页 // 现在直接复用日程页那块组件,顺序不一致会与日程页看起来"不是同一份数据")。 usort($todayEvents, static function (array $a, array $b): int { $ta = (string)($a['time'] ?? ''); $tb = (string)($b['time'] ?? ''); if ($ta === '' && $tb !== '') { return -1; } if ($tb === '' && $ta !== '') { return 1; } return strcmp($ta, $tb); }); // ---- 任务:进行中 + 待办(用户 2026-10-16:「把待办的也放在这」)---- // ⚠️ 与网页版 `index.php` **同口径**:原来是"doing + 只把**已逾期**的 todo // 收进来",现在 user 明确要待办全部进来。 // ⚠️ 顺序 = 进行中在前、待办在后(各组内部仍按截止时间升序, // `byStatus` 的 ORDER BY 已经排好)—— 用户原话"放在进行中的下面"。 $tasks = Task::byStatus($fam, $pid, 'doing'); foreach (Task::byStatus($fam, $pid, 'todo') as $t) { $tasks[] = $t; } // 给每条任务补**派生看板色**:与网页版首页**同一函数** //(用户 2026-10-08 报过"首页任务圆点颜色没跟看板一致")。 // ⚠️ 不能直接读 `board_color`(没设色时为 null),要走派生: // 同一个看板名永远同一个色,用户才记得住。 foreach ($tasks as &$tk) { $tk['board_color_calc'] = Task::boardColorById($fam, (int)($tk['board_id'] ?? 0) ?: null); } unset($tk); $overdueN = 0; foreach ($tasks as $t) { if (Task::isOverdue($t['due_at'] ?? null)) { $overdueN++; } } // ---- 农历(抬头要"农历几月几日",不要"二〇二六年"那一段)---- $lunarToday = Lunar::solarToLunar((int)date('Y'), (int)date('n'), (int)date('j')); $lunarShort = $lunarToday === null ? '' : ( Lunar::monthName((int)$lunarToday['month'], !empty($lunarToday['is_leap'])) . Lunar::dayName((int)$lunarToday['day'])); $lunarLine = $lunarToday === null ? '' : Lunar::label( (int)$lunarToday['year'], (int)$lunarToday['month'], (int)$lunarToday['day'], !empty($lunarToday['is_leap'])); // ---- 日子标记:节气 / 法定节日 / 传统节日 / 纪念日 / 生日 ---- $marks = Home::todayMarks($fam, $pid, $today); /* ---- 🔴 v59(用户 2026-10-18):最近三个纪念日(含生日),倒计时用 ---- 用户原话:「首页应该加一个『最近纪念日』模块,放在『往年的今天』前面、 『近期任务』后面。内容包括:1. 未来最近的三个纪念日,包括每个人的生日, 以及手动填写的各个纪念日。2. 以倒计时形式展示:距离哪个纪念日还有多少天, 距离谁谁谁的生日还有多少天。」 ⇒ 位置是**近期任务之后、往年的今天之前**(端上按这个顺序摆)。 ⚠️ 为什么要 370 天的窗口(而不是像网页端概述卡那样只取 30 天): 「未来最近的三个」如果窗口太小,一个刚过完生日的家庭会**一个都取不到**, 模块直接空着 —— 而用户要的是一个**永远的倒计时**。 取满一年,保证"接下来的三个"一定有(只要有纪念日或生日)。 ⚠️ 数据来源 `Anniversary::upcoming()` → `occurrences()` → `all()`, 而 `all()` = **派生生日 + 自建纪念日** —— 正好一次满足用户说的两样, 不必再去 `Birthday::` 那套单独取一遍(两处取必然口径不一)。 ⚠️ 已经过完今天的同样包含在内(`from` 从今天起算,含今天)。 今天那条 `days=0` 端上会显示成「就是今天」。 */ $recentDays = []; try { $recentDays = array_slice(Anniversary::upcoming($fam, $pid, 370), 0, 3); } catch (\Throwable $ex) { error_log('[familybook] 首页取最近纪念日失败(已跳过):' . $ex->getMessage()); } // 当前登录者本人(顶栏右上角要显示**真实头像**,用户 2026-10-18: // "右上角的头像应该是真实的")。取不到就留空串,端上回落成色块字。 $meRow = Person::find($pid) ?: []; // ---- 天气(三级回落:个人经纬度 → 内置城市表 → 家庭级兜底 → 不显示)---- $wloc = Home::weatherLocation($pid, $fam); $weather = $wloc === null ? null : Home::weather($fam, (string)$wloc['lat'], (string)$wloc['lon'], (string)$wloc['place']); // ---- 概述卡底部的数字(与网页版 `hb-stats` **同一口径**)---- // 🔴 用户 2026-10-16:「下面……**未来7天**……这几个就是去掉了,不要了。」 // ⇒ 概述卡里原来那格"未来七天"(=`$ev7`,today..+7 天的条数) // **不再显示**;网页版概述卡本来也是这几个: // 条日程(今天)/ 件在做 / 件待办 / 件逾期。 // ⚠️ 数字必须与**下面那块任务列表同源**(v24 第 25 批用户报过 // 「为什么显示 0 条待办呢?任务看板明明有一个进行中的任务啊」 // —— 根因就是"数字和列表两个口径",所以这里直接数 `$tasks`)。 $doingN = 0; $todoRest = 0; foreach ($tasks as $t) { if ((string)($t['status'] ?? 'todo') === 'doing') { $doingN++; } else { $todoRest++; } } $stat = [ 'today_events' => count($todayEvents), 'doing' => $doingN, 'todo' => $todoRest, 'overdue' => $overdueN, ]; Api::ok([ 'greeting' => Home::greeting( Nickname::display($pid, null) ?: '', $today, count($soon), $overdueN), 'date' => $today, 'weekday' => Home::weekday($today), /** 抬头:`n 月 j 日`(与网页版 `todayLabel` 同一格式) */ 'today_label' => date('n 月 j 日'), /** 农历短写:`八月廿二` */ 'lunar_short' => $lunarShort, /** 农历全称(悬停/详情用) */ 'lunar_line' => $lunarLine, /** 日子标记:`[{word, kind}]`,kind ∈ holiday|term|festival|anniv|birthday */ 'marks' => $marks['tags'], /** 天气(没配地点 / 取不到时 **为 null** —— 端上据此整块不画) */ 'weather' => $weather, /** 概述卡数字(与网页版 `hb-stats` 同源、与 tasks 列表同源) */ 'stat' => $stat, /** * 往年的今天(用户 2026-10-16:「近期任务的下面,应该加上往年的今天, * 跟电脑端一样」)。 * ⚠️ 与网页版**同一份实现**(`Home::memories`)—— * 两处各写一遍必然出现"网页 2 年前、手机 3 年前"。 * 每条:`{id, title, starts_at, kind, years_ago, n}`, * `kind` ∈ `movie` | `trip_node`(端上据此决定图标与圆点色)。 */ 'memories' => Home::memories($fam, 6)['rows'], /** * 🔴 v59:未来最近的三个纪念日(用户 2026-10-18 要的「最近纪念日」模块)。 * * 每条:`{id, date, title, kind, person_id, person_name?, source, days, age, nth}` * · `days` —— 距今天还有几天(0 = 就是今天;**永远是 0 或正数**) * · `kind` —— 用户填的类别("生日" / "结婚纪念" / …),端上直接显示 * · `source`—— `'custom'` = 手动填的纪念日;其余(`birthday_solar` * / `birthday_lunar`)= 派生生日,端上据此区分"纪念日 / 生日" * · `title` —— 已含姓名(如「老杨 的生日」「结婚纪念日」),端上直接用 * * ⚠️ 顺序就是"最近的在前"(`upcoming` 内部已按 date 升序), * 端上**不要再排一遍** —— 排序口径分散在两处迟早不一致。 */ 'recent_days' => $recentDays, 'today_events' => $todayEvents, 'tasks' => $tasks, 'unread' => Notification::unreadCount($pid), /** 当前登录者的真实头像 URL(顶栏右上角要显示真图,用户 2026-10-18) */ 'me_avatar' => avatar_url($meRow['avatar'] ?? null), ]); } /* ═══════════════════════════════════════════════════════════════ 名册 / 人 ═══════════════════════════════════════════════════════════════ */ private function people(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $p = Person::find($id); // 只认本家的(跨家 id 直接当不存在,不泄露"别人家有没有这个人") if (!$p || (int)$p['family_id'] !== $fam) { Api::notFound('这个人'); } // 🔴 v52(用户 2026-10-15):**头像上传**。 // App 要"完全脱离电脑端",换头像就必须能在手机上做。 // 走 `POST /api/v1/people//avatar`(multipart,字段名 `avatar`)。 if ($sub === 'avatar') { if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } Api::requireWrite(); $up = Upload::avatar($fam, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); if (!$up['ok']) { Api::fail((string)($up['error'] ?? '头像没存下来'), 'unprocessable', 422); } // 换图就换色(与网页端同一口径:算不出时保留旧色,不清空) Upload::remove($fam, (string)($p['avatar'] ?? '')); Person::setAvatar($id, (string)$up['file']); Person::setAvatarColor($id, (string)($up['color'] ?? '')); Api::ok(self::personDetail((array)Person::find($id), $pid)); } if ($method === 'GET') { Api::ok(self::personDetail($p, $pid)); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); self::applyPersonWrite($id, $p, $fam, $pid); Api::ok(self::personDetail((array)Person::find($id), $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Person::delete($id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $name = Api::str('name', '', 50); if ($name === '') { Api::fail('得写个名字。', 'unprocessable', 422); } $newId = Person::create($fam, self::personWriteInput(null, true)); // 头像(可选,multipart 时才有) $up = Upload::avatar($fam, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); if ($up['ok']) { Person::setAvatar($newId, (string)$up['file']); Person::setAvatarColor($newId, (string)($up['color'] ?? '')); } // 加进**我**的名册(与网页端同一口径:我加的人自动进我的名册) PersonRoster::ensure($fam, $pid, $newId, 0, 'self'); self::applyPersonChildren($newId, $fam, $pid); Api::ok(self::personDetail((array)Person::find($newId), $pid), 201); } // 列表:支持 ?q= 搜索、?kind=member|contact $list = Person::all($fam, Api::str('q'), Api::str('kind', 'member'), $pid); // ⚠️ 逐行脱敏(见 stripSensitivePerson 的说明:网页要、App 不要) $safe = []; foreach ($list as $row) { $safe[] = self::stripSensitivePerson((array)$row); } Api::list($safe); } /** * 单人**完整详情**(App 详情浮层用;用户 2026-10-15:要能"完全脱离电脑端")。 * * 比列表行多出这些(列表行只够画一栏): * · `contacts` 补充联系方式,按 kind 分组(电话/邮箱/地址/网址),电话已解密 * · `jobs` 工作经历(整组:单位/职务/职称/起止/备注) * · `my_note` 「我写的私人备注」(**只有我看得到**,与公共 note 不是一回事) * · `tags` 标签 * · `addr_*` 结构化地址五级(国家/省/市/区/详细) * · `id_card` 身份证(**打码**,只给后 4 位;编辑时用户重新输入才改) * · `birthdays` 阳历 + 农历(只到月日) * · `avatar_url` 头像可访问 URL(App 直接拿去显示,不用自己拼) * · `can_edit_all` 当前登录者能不能改这人的全部字段(对齐网页端 `Auth::canWrite`) */ private static function personDetail(array $p, int $viewerId): array { // 🔴🔴 **顺序极重要:所有"派生字段"必须在脱敏之前取。** // 踩过的坑:先 `stripSensitivePerson()` 再去算 `phonePlain()`, // 而它读的是 `phone_encrypted` —— 那一列已经被删掉了 ⇒ **永远返回空** // (实测详情里 `phone_plain`/`phones` 全空、拨号没号码)。 // 同一个坑还有 `id_card_masked`(`maskPhone` 内部也要 decrypt)。 $id = (int)($p['id'] ?? 0); // 农历原始值先存下来(`stripSensitivePerson` 会把这列删掉,见下) $rawLunar = $p['birthday_lunar'] ?? ''; $rawLunarLeap = $p['birthday_lunar_leap'] ?? 0; // 手机号明文(主号) $p['phone_plain'] = PersonQuery::phonePlain($p); // 全部电话(主号在前 + 补充电话) $p['phones'] = PersonQuery::phonesOf($p); // 生日(阳历/农历,只到月日) $p['birthdays'] = PersonQuery::birthdaysOf($p); // 身份证:**打码**给 App 看(⚠️ `maskPhone` 内部会 decrypt,传明文串进去会被 // 再解一次、解出空 ⇒ 必须传**密文**,也就是直接给原始列) $p['id_card_masked'] = Crypto::maskPhone((string)($p['id_card_encrypted'] ?? '')); // ---- 到这一步才脱敏(把那几列密文摘掉)---- $p = self::stripSensitivePerson($p); // 🔴 农历生日的**完整日期**要还给 App(用户在详情里编辑农历生日要用): // `stripSensitivePerson` 把它当"推算中间量"删了,但它其实是**用户填的数据** // (不是推算),只是脱敏清单按"birthday_ 前缀"一刀切了。 // ⚠️ 只下发中文月日(`birthdays` 数组里那份)**没法回填编辑框** // ("十月初五"不是日期)⇒ 用户就改不了农历生日。 // ⚠️ 这里不存在额外隐私暴露:阳历生日的完整值(含年份)本来就在 // `birthday` 字段里下发了;农历给同样完整度不增加信息。 $p['birthday_lunar'] = (string)($rawLunar ?? ''); $p['birthday_lunar_leap'] = (int)($rawLunarLeap ?? 0) === 1; // 补充联系方式(按 kind 分组;电话解密) $contacts = Person::contactsOf($id); $out = ['phone' => [], 'email' => [], 'address' => [], 'url' => []]; foreach ($out as $kind => $_) { foreach (($contacts[$kind] ?? []) as $row) { $val = (string)($row['value'] ?? ''); if ($kind === 'phone') { try { $val = (string)Crypto::decrypt($val); } catch (\Throwable $ex) { $val = ''; } } if ($val === '') { continue; } $out[$kind][] = ['label' => (string)($row['label'] ?? ''), 'value' => $val]; } } $p['contacts'] = $out; // 工作经历 $p['jobs'] = PersonJob::list($id); // 私人备注(我看他写的那一条;与公共 note 并列) $p['my_note'] = PersonNote::get($id, $viewerId); // 🔴 「我对他的称呼」(用户 2026-10-15:编辑时要能改称呼)。 // 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟")—— // 存在 `nicknames` 表里、按 (viewer, target) 一对一行, // **不挂在被称呼者身上** ⇒ 不能走 `Person::update`,得单独处理。 // ⚠️ `shown` 已经用了它(有称呼就显示称呼),这里单独再给一份**原始值** // 供编辑框回填 —— 从 `shown` 反推不出来(没有称呼时 `shown` = 姓名)。 $p['my_nickname'] = Nickname::get($viewerId, $id); // 标签 $p['tags'] = PersonTag::of($viewerId, $id); // 「我怎么叫他」 $p['shown'] = Nickname::display($viewerId, $p) ?: (string)($p['name'] ?? ''); $p['initials'] = Pinyin::initials((string)($p['name'] ?? '')); // 头像 URL(App 直接显示,不用自己拼 media 路径) $p['avatar_url'] = Upload::avatarUrl((int)($p['family_id'] ?? 0), $p['avatar'] ?? null); // 当前登录者能不能改全部字段(⚠️ 用 `Api::actor()` 的 can_write, // 不是 `Auth::canWrite()` —— 后者读网页 session,API 下恒为 false) $act = Api::actor(); $p['can_edit_all'] = (bool)($act['can_write'] ?? false); $p['role'] = (string)($act['role'] ?? 'member'); // 这个人是不是"我" $p['is_self'] = ($id === $viewerId); // 是管理员(显示徽标)—— `accounts.role = admin` $acc = Account::findByPerson($id); $p['is_admin'] = $acc !== null && (string)($acc['role'] ?? '') === 'admin'; return $p; } /** * 把请求体里的**人员字段**收集成 `Person::update` 要的那一坨。 * * 🔴 与网页端 `index.php` 的 people/edit 分支**逐字段对齐**(用户 2026-10-15: * 「所有的内容都应该可以编辑」)—— 唯一的差别是"改自己"与"改别人"的权限: * 网页端非本人提交时 slogan/birth_rank/is_placeholder 沿用库里旧值, * 这里同样处理(见 `applyPersonWrite`)。 * * ⚠️ 每个字段都**先取旧值兜底**:App 只传变化的那几项时, * 没有字段会被"静默清空"(这是最初版最危险的地方 —— 只传 name 就把备注清了)。 * * @param array|null $old null = 新建 */ private static function personWriteInput(?array $old, bool $isCreate = false): array { $bdSolar = Api::str('birthday_solar', ''); $bdLunar = Api::str('birthday_lunar', ''); // 兼容旧的单一 `birthday`(App 早期只发了这个) $legacyBd = Api::dt('birthday', null); if ($bdSolar === '' && $legacyBd !== null) { $bdSolar = substr((string)$legacyBd, 0, 10); } return [ // ⚠️ `kind` 只有新建时用得上(`Person::create` 要),改名时会被忽略 'kind' => Api::str('kind', $isCreate ? 'member' : (string)($old['kind'] ?? 'member')), 'name' => Api::str('name', $isCreate ? '' : (string)($old['name'] ?? ''), 50), 'gender' => Api::str('gender', $isCreate ? '' : (string)($old['gender'] ?? '')), 'phone' => Api::str('phone', $isCreate ? '' : '', 40), 'email' => Api::str('email', $isCreate ? '' : (string)($old['email'] ?? ''), 120), 'address' => Api::str('address', $isCreate ? '' : (string)($old['address'] ?? ''), 200), // 结构化地址五级 'addr_country' => Api::str('addr_country', $isCreate ? '' : (string)($old['addr_country'] ?? ''), 40), 'addr_province' => Api::str('addr_province', $isCreate ? '' : (string)($old['addr_province'] ?? ''), 40), 'addr_city' => Api::str('addr_city', $isCreate ? '' : (string)($old['addr_city'] ?? ''), 40), 'addr_district' => Api::str('addr_district', $isCreate ? '' : (string)($old['addr_district'] ?? ''), 40), 'org' => Api::str('org', $isCreate ? '' : (string)($old['org'] ?? ''), 120), 'title' => Api::str('title', $isCreate ? '' : (string)($old['title'] ?? ''), 120), 'note' => Api::str('note', $isCreate ? '' : (string)($old['note'] ?? ''), 2000), 'slogan' => Api::str('slogan', $isCreate ? '' : (string)($old['slogan'] ?? ''), 200), 'id_card' => Api::str('id_card', '', 40), 'birth_rank'=> Api::str('birth_rank', $isCreate ? '' : (string)($old['birth_rank'] ?? ''), 4), 'is_placeholder' => Api::bool('is_placeholder', $isCreate ? false : (int)($old['is_placeholder'] ?? 0) === 1) ? 1 : 0, // 生日:阳历 + 农历 + 闰月(与网页端 read_birthday_input 同一口径) 'birthday_source' => $bdSolar !== '' ? 'solar' : ($bdLunar !== '' ? 'lunar' : 'solar'), 'birthday_solar' => $bdSolar, 'birthday_lunar' => $bdLunar, 'birthday_lunar_leap' => Api::bool('birthday_lunar_leap', false) ? 1 : 0, // 身份证生日是"只进不出"的:带回来,别被一次提交清掉 'birthday_id_card' => $isCreate ? '' : (string)($old['birthday_id_card'] ?? ''), // 头像沿用旧值(上传走 avatar 子接口) 'avatar' => $isCreate ? '' : (string)($old['avatar'] ?? ''), ]; } /** 校验 + 落库 + 子表(工作经历/联系方式/标签/私人备注) */ private static function applyPersonWrite(int $id, array $old, int $fam, int $viewerId): void { $isSelf = ($id === $viewerId); // ⚠️ 同 `personDetail`:写权限用 `Api::actor()` 的口径 $canAll = (bool)(Api::actor()['can_write'] ?? false); $d = self::personWriteInput($old, false); // 改自己 vs 改别人:非"能写"人员只能改自己那一组(与网页端 Person::updateSelf 对齐) if ($isSelf && !$canAll) { $discard = ['birth_rank', 'is_placeholder', 'phone', 'id_card']; foreach ($discard as $k) { $d[$k] = $k === 'is_placeholder' ? (int)($old['is_placeholder'] ?? 0) : ''; } if ($d['phone'] === '') { $d['phone'] = Crypto::decrypt((string)($old['phone_encrypted'] ?? '')); } if ($d['id_card'] === '') { $d['id_card'] = Crypto::decrypt((string)($old['id_card_encrypted'] ?? '')); } } else { // 改别人:手机号/身份证没传就沿用旧值(避免"编辑一次号码没了") if ($d['phone'] === '') { $d['phone'] = Crypto::decrypt((string)($old['phone_encrypted'] ?? '')); } if ($d['id_card'] === '') { $d['id_card'] = Crypto::decrypt((string)($old['id_card_encrypted'] ?? '')); } } $errors = Person::validate($d); if ($errors) { $first = reset($errors); // ⚠️ `Api::fail` 只收 (消息, code, http) —— 逐字段错误拼进消息里, // App 端本来也只需要"哪一条不对"这一句可读的话。 Api::fail((string)$first, 'unprocessable', 422); } if ($isSelf && !$canAll) { Person::updateSelf($id, $d); } else { Person::update($id, $d); } PersonJob::replace($id, self::jobsInput()); Person::replaceContacts($id, self::contactsInput()); self::applyPersonChildren($id, $fam, $viewerId); } /** 子表:标签 + 私人备注 */ private static function applyPersonChildren(int $personId, int $fam, int $viewerId): void { // 标签:**只在请求里带了 tags 才动它**(否则会把人身上的标签清空) if (array_key_exists('tags', Api::body())) { $raw = Api::param('tags'); $tags = is_array($raw) ? $raw : explode(',', str_replace([',', '、', ' '], ',', (string)$raw)); PersonTag::setFor($fam, $viewerId, $personId, $tags); } // 私人备注 if (array_key_exists('my_note', Api::body())) { PersonNote::set($personId, $viewerId, Api::str('my_note', '', 500)); } // 「我对他的称呼」:存 `nicknames`(视角属性),**不走 `Person::update`**。 // ⚠️ `Nickname::set` 对空串是"删掉这条"(恢复成用姓名显示), // 正是编辑框清空后的期望行为。 if (array_key_exists('my_nickname', Api::body())) { Nickname::set($fam, $viewerId, $personId, Api::str('my_nickname', '', 50)); } } /** 工作经历行(整组替换;结构对齐网页端 read_jobs_input) */ private static function jobsInput(): array { $raw = Api::param('jobs', []); if (!is_array($raw)) { return []; } $out = []; foreach ($raw as $row) { if (!is_array($row)) { continue; } $out[] = [ 'org' => (string)($row['org'] ?? ''), 'title' => (string)($row['title'] ?? ''), 'rank_name' => (string)($row['rank_name'] ?? ''), 'start_ym' => (string)($row['start_ym'] ?? ''), 'end_ym' => (string)($row['end_ym'] ?? ''), 'note' => (string)($row['note'] ?? ''), ]; } return $out; } /** 补充联系方式(整组替换;结构对齐网页端 read_contacts_input) */ private static function contactsInput(): array { $raw = Api::param('contacts', []); if (!is_array($raw)) { return []; } $out = ['phone' => [], 'email' => [], 'address' => [], 'url' => []]; foreach (['phone', 'email', 'address', 'url'] as $kind) { foreach ((array)($raw[$kind] ?? []) as $row) { if (!is_array($row)) { continue; } $val = trim((string)($row['value'] ?? '')); if ($val === '') { continue; } $out[$kind][] = ['label' => (string)($row['label'] ?? ''), 'value' => $val]; } } return $out; } /** * 把人员行里**不该给到 App 的字段**去掉(就地改,返回同一个数组)。 * * 🔴 v45 新增(用户 2026-10-12 接服务器时发现): * `Person::all()` 是 `SELECT p.*`,于是把 `phone_hash` / * `phone_encrypted` / `id_card_encrypted` 一起吐给了 App。 * 这三个都是**敏感物**: * · `phone_encrypted` / `id_card_encrypted` 是 AES 密文, * 给到客户端就等于把**离线爆破的材料**递出去了; * · `phone_hash` 是"手机号是否属于这个家"的判定键,同样不该外流。 * App 侧一个都用不到(显示手机号要的是**解密后的明文**, * 走的是另一条路)。 * * ⚠️ 为什么在**这一层**脱敏,而不去改 `Person::all()`: * 网页端**确实在用**这些字段 —— `ContactImporter` 靠 `phone_hash` * 做导入去重、`PersonQuery` 靠 `phone_encrypted` 支持"按手机号搜索"。 * 从 `all()` 里删会**打断网页功能**。 * ⇒ 网页要、App 不要 ⇒ 差异就该落在"出口"这一层。 * * ⚠️ 顺手去掉的还有几个**纯服务端内部**字段(`is_placeholder` 之外的 * 统计/审计列),App 的 UI 里没有对应位置,传了只是徒增体积。 */ private static function stripSensitivePerson(array $row): array { unset( $row['phone_hash'], $row['phone_encrypted'], $row['id_card_encrypted'], // 生日相关的三个原始列:App 显示生日只用 `birthday`, // 而 `birthday_id_card` / `birthday_lunar` 是**推算生日**用的中间量 // (农历、证件号),属于隐私推算材料,没必要下发。 $row['birthday_id_card'], $row['birthday_lunar'], $row['birthday_lunar_leap'] ); return $row; } /* ═══════════════════════════════════════════════════════════════ 通知 ═══════════════════════════════════════════════════════════════ */ /** * 家谱(`GET /api/v1/kin`)。 * * 🔴 为什么单独一个接口、而不是塞进 `/roster`: * `/roster` 是"**我名册里**的人"(可筛可搜,数据是**一堆人**); * 家谱是"**以我为中心的关系图**"(谁是谁的父母、配偶、子女, * 是**一张网**)。两者的形状与用途都不同,塞在一起会让 * 名册接口多背一份"大部分人不用"的图谱数据。 * * ⚠️ 用的是 `Kinship::familyMap($fam, $me, $people, up, down)` —— * 与 Web 端 `kin/tree.php` **同一个方法**,所以端上与网页看到的 * 亲属关系必然一致(不会出现"App 说他是堂兄、网页说是表兄")。 * `$up` / `$down` 用默认 2 —— 手机上再往外排就看不下了。 */ private function kin(): void { [$fam, $pid] = Api::me(); $people = Person::map($fam); $map = Kinship::familyMap($fam, $pid, $people, 2, 2); $graph = Kinship::graph($fam); // ---- 把人整理成"一人一条 + 关系字段"的形状,便于端上直接渲染 ---- $byGen = []; $gen = $map['gen'] ?? []; $ago = $map['unit_of'] ?? []; foreach ($gen as $id => $g) { if (!isset($people[$id])) { continue; } $row = $people[$id]; $label = Kinship::label($graph, $pid, (int)$id, $people); $byGen[] = [ 'id' => (int)$id, 'name' => (string)$row['name'], 'shown' => \App\Nickname::display($pid, $row) ?: (string)$row['name'], 'label' => $label, // 「我该怎么称呼他」 'gen' => (int)$g, // 0 = 我;负 = 长辈;正 = 晚辈 'avatar' => $row['avatar'] ?? null, 'avatar_color' => $row['avatar_color'] ?? null, 'unit' => (int)($ago[$id] ?? 0), // 户(夫妻单元)号 // 直接关系(拍平,端上画连线/分组用) 'parents' => array_map('intval', Kinship::parentIds($graph, (int)$id)), 'children' => array_map('intval', Kinship::childIds($graph, (int)$id)), 'spouses' => array_map('intval', Kinship::spouseIds($graph, (int)$id)), 'siblings' => array_map('intval', Kinship::siblingIds($graph, (int)$id)), ]; } // 按代数排序(长辈在前、晚辈在后),同代按名字拼音稳定 usort($byGen, static function (array $a, array $b): int { return $a['gen'] <=> $b['gen'] ?: strcmp($a['name'], $b['name']); }); Api::ok([ 'meId' => $pid, 'people' => $byGen, 'units' => $map['units'] ?? [], 'up' => 2, 'down' => 2, 'total' => count($byGen), ]); } private function notifications(int $id, string $sub, string $method): void { [, $pid] = Api::me(); if ($sub === 'read' || ($method === 'POST' && $sub === 'read')) { Api::ok(['marked' => Notification::markAllRead($pid)]); } if ($sub === 'clear' || $method === 'DELETE') { if ($id > 0) { Api::ok(['deleted' => Notification::delete($id, $pid)]); } Api::ok(['cleared' => Notification::clearMine($pid)]); } Api::ok([ 'unread' => Notification::unreadCount($pid), 'items' => Notification::mine($pid, 50), ]); } /* ═══════════════════════════════════════════════════════════════ 日程 ═══════════════════════════════════════════════════════════════ */ private function calendar(string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($sub === 'books') { // GET /calendar/books Api::ok(Calendar::mine($fam, $pid)); } // GET /calendar?from=2026-10-01&to=2026-10-31 (默认本月) $from = Api::str('from', date('Y-m-01')); $to = Api::str('to', date('Y-m-t')); $cals = Calendar::mine($fam, $pid); $rows = Calendar::month($fam, $cals, $pid, $from, $to); Api::ok($rows); } private function events(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $ev = $this->eventFor($fam, $id); if (!$ev) { Api::notFound('这条日程'); } /* 🔴 v73(2026-10-21 真机实测):**读**不该要求写权限。 原先这里是"先判 canWriteEvent、再分 GET/PATCH/DELETE", 后果:共享给我**只读**的日历本里的日程,连**详情**都打不开 —— 客户端表现为"部分详情没读回来:读不到详情",而那条日程 在列表里好好的(列表走 /calendar,不经过这里)。 允许读的安全依据是 `eventFor()` 已经卡了 `family_id`: 跨家庭的 id 在上一行就 404 了,能走到这里的一定是同家庭成员, 而 `Calendar::month()` 本来就对成员开放全家的日程。 ⇒ 写权限只挡**写**(PATCH/PUT/DELETE),读一律放行。 */ if ($method === 'GET') { Api::ok($ev); } if (!Calendar::canWriteEvent($ev, $pid)) { Api::fail('这本日历你没有写权限。', 'forbidden', 403); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); $this->writeEvent($pid, $ev); } if ($method === 'DELETE') { Api::requireWrite(); $this->deleteEvent($ev); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $calId = Api::int('calendar_id'); $cal = Calendar::mineOne($fam, $pid, $calId); if (!$cal || !Calendar::canWrite($cal, $pid) || Calendar::isReadonlyKind((string)$cal['kind'])) { Api::fail('这本日历不能写入。', 'forbidden', 403); } $d = $this->eventData(null); if ($d['starts_at'] === '') { Api::fail('开始时间不能为空。', 'unprocessable', 422); } if ($d['title'] === '') { Api::fail('日程得有个标题。', 'unprocessable', 422); } $d['created_by'] = $pid; $newId = Calendar::addEvent((int)$cal['id'], $d); Api::ok($this->eventFor($fam, $newId), 201); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } /** 取一条日程(本家范围内;不存在返回 null) */ private function eventFor(int $fam, int $id): ?array { $ev = Calendar::event($id); if (!$ev) { return null; } $cal = Calendar::find((int)$ev['calendar_id']); if (!$cal || (int)$cal['family_id'] !== $fam) { return null; } $ev['skips'] = Calendar::skipsOf($id); return $ev; } /** * 日程写入字段(新建与更新共用;`$cur` 非空做"只改给了的字段"回退)。 * * 🔴 重复日程的**三档范围**(改/删这一次、这一次及以后、全部)—— * 网页那两段逻辑(event_update / event_del)比较长,这里**不复制**它们, * 而是让 App 用同一组 `scope` + `occ` 参数,并在 API 里做同样的三档处理。 * (复制两份业务规则是"必然长歪"的写法;这里把差异收在一个私有方法里。) */ private function eventData(?array $cur): array { $has = static fn(string $k): bool => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); $starts = $has('starts_at') ? Api::dt('starts_at') : (string)($cur['starts_at'] ?? ''); $ends = $has('ends_at') ? Api::dt('ends_at') : ($cur['ends_at'] ?? null); $allDay = $has('all_day') ? Api::bool('all_day') : !empty($cur['all_day']); if ($allDay) { $starts = $starts !== null && $starts !== '' ? substr((string)$starts, 0, 10) . ' 00:00:00' : (string)$starts; $ends = $ends !== null && $ends !== '' ? substr((string)$ends, 0, 10) . ' 00:00:00' : null; } return [ 'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''), 'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''), 'starts_at' => (string)($starts ?? ''), 'ends_at' => $ends, 'all_day' => $allDay, 'reminder_minutes' => $has('reminder_minutes') ? Api::param('reminder_minutes', []) : ($cur['reminder_minutes'] ?? ''), 'status' => $has('status') ? Api::str('status', 'confirmed') : (string)($cur['status'] ?? 'confirmed'), 'transp' => $has('transp') ? Api::bool('transp') : !empty($cur['transp']), 'rrule' => $has('freq') || $has('rrule') ? $this->rruleFromBody() : (string)($cur['rrule'] ?? ''), 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), ]; } /** 更新一条日程(含重复日程的三档范围) */ private function writeEvent(int $pid, array $ev): void { $d = $this->eventData($ev); $scope = Api::str('scope', 'all'); $occ = substr(Api::str('occ'), 0, 10); $rrule = (string)($ev['rrule'] ?? ''); $hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ); if ($rrule !== '' && $hasOcc && $scope === 'once') { // 只改这一次:原系列跳掉那天 + 落一条独立单次 Calendar::detachOnce(['id' => (int)$ev['id'], 'calendar_id' => (int)$ev['calendar_id']], $occ, $d, $pid); Api::ok(['updated' => (int)$ev['id'], 'scope' => 'once']); } if ($rrule !== '' && $hasOcc && $scope === 'future') { // 这一次及以后:老系列截到前一天,新系列从这天起 $rp = Repeat::parse($rrule); $cut = date('Y-m-d', strtotime($occ . ' -1 day')); $rp['until'] = $cut; $rp['count'] = 0; Calendar::updateEvent((int)$ev['id'], [ 'title' => (string)($ev['title'] ?? ''), 'location' => (string)($ev['location'] ?? ''), 'starts_at' => (string)$ev['starts_at'], 'ends_at' => $ev['ends_at'] ?: null, 'all_day' => (int)($ev['all_day'] ?? 0) === 1, 'reminder_minutes' => $ev['reminder_minutes'] ?? '', 'status' => (string)($ev['status'] ?? 'confirmed'), 'transp' => (int)($ev['transp'] ?? 0) === 1, 'rrule' => 'FREQ=' . strtoupper((string)$rp['freq']) . ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '') . ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z', 'note' => (string)($ev['note'] ?? ''), ]); $d2 = $d; $d2['starts_at'] = $occ . ' ' . substr((string)$d['starts_at'], 11, 8); $d2['ends_at'] = $d['ends_at'] !== null ? $occ . ' ' . substr((string)$d['ends_at'], 11, 8) : null; $d2['created_by'] = $pid; $newId = Calendar::addEvent((int)$ev['calendar_id'], $d2); Api::ok(['updated' => (int)$ev['id'], 'new_series' => $newId, 'scope' => 'future']); } Calendar::updateEvent((int)$ev['id'], $d); Api::ok($this->eventFor((int)Api::me()[0], (int)$ev['id'])); } /** 删除一条日程(含重复日程的三档范围) */ private function deleteEvent(array $ev): void { $id = (int)$ev['id']; $scope = Api::str('scope', 'all'); $occ = substr(Api::str('occ'), 0, 10); $rrule = (string)($ev['rrule'] ?? ''); $hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ); if ($rrule !== '' && $hasOcc && $scope === 'once') { Calendar::skipOnce($id, $occ); Api::ok(['skipped' => $occ, 'scope' => 'once']); } if ($rrule !== '' && $hasOcc && $scope === 'future') { $rp = Repeat::parse($rrule); $cut = date('Y-m-d', strtotime($occ . ' -1 day')); $rp['count'] = 0; $rp['until'] = $cut; $firstDay = substr((string)$ev['starts_at'], 0, 10); if ($cut < $firstDay) { Calendar::deleteEvent($id); Api::ok(['deleted' => $id, 'scope' => 'future']); } $newRule = 'FREQ=' . strtoupper((string)$rp['freq']) . (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '') . ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '') . ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z'; Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?') ->execute([$newRule, $id]); Api::ok(['truncated_to' => $cut, 'scope' => 'future']); } Calendar::deleteEvent($id); Api::ok(['deleted' => $id]); } /** * 重复规则:接受两种写法 —— * · `rrule` 直接给 RFC 5545 原文(如 `FREQ=WEEKLY;COUNT=5`); * · 或用 `freq` + `rep_end` + `rep_count` / `rep_until` 三个字段(与网页表单同构)。 * ⚠️ 后者是关键:App 端做重复设置时最自然的就是"选频率、选结束方式", * 不该逼它去拼 RRULE 字符串(那是把服务端的实现细节漏给了客户端)。 */ private function rruleFromBody(): string { $raw = Api::str('rrule'); if ($raw !== '') { return $raw; } $freq = Api::str('freq'); if ($freq === '') { return ''; } return Repeat::build([ 'freq' => $freq, 'rep_end' => Api::str('rep_end', 'never'), 'rep_count' => Api::int('rep_count', 10), 'rep_until' => Api::str('rep_until', ''), ]); } /* ═══════════════════════════════════════════════════════════════ 纪念日(days) ═══════════════════════════════════════════════════════════════ */ private function days(int $id, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $a = Anniversary::find($fam, $id); if (!$a) { Api::notFound('这条纪念日'); } if ($method === 'GET') { // 单条也要带上关联人,否则端上"编辑"没法回填(见 annivData 的说明) $a['person_ids'] = Anniversary::peopleOf($a); Api::ok($a); } if ($method === 'DELETE') { Api::requireWrite(); Anniversary::remove($fam, $id); Api::ok(['deleted' => $id]); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); Anniversary::save($fam, $this->annivData($a), $id); Api::ok(Anniversary::find($fam, $id)); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $d = $this->annivData(null); if ($d['title'] === '') { Api::fail('纪念日得有个名字。', 'unprocessable', 422); } $newId = Anniversary::save($fam, $d, 0); Api::ok(Anniversary::find($fam, $newId), 201); } /* 列表:自定义的每行补一个 `person_ids`。 ⚠️ `Anniversary::list()` 是 `SELECT * FROM anniversaries` —— 不含关联人, 端上就只能显示"没有关联人",编辑时也回填不了。 (派生出来的生日行 `source` 是 `birthday_*`,人就在 `person_id` 里,不用补。) */ $rows = Anniversary::all($fam, $pid); foreach ($rows as &$r) { if ((string)($r['source'] ?? 'custom') === 'custom') { $r['person_ids'] = Anniversary::peopleOf($r); } } unset($r); Api::ok($rows); } /** 纪念日字段(契约与 days/save 路由一致) */ private function annivData(?array $cur): array { $has = static fn(string $k): bool => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); return [ 'title' => $has('title') ? Api::str('title', '', 100) : (string)($cur['title'] ?? ''), 'repeat_type' => $has('repeat_type') ? Api::str('repeat_type', 'solar') : (string)($cur['repeat_type'] ?? 'solar'), 'month' => $has('month') ? Api::int('month', 1) : (int)($cur['month'] ?? 1), 'day' => $has('day') ? Api::int('day', 1) : (int)($cur['day'] ?? 1), 'is_leap' => $has('is_leap') ? Api::bool('is_leap') : !empty($cur['is_leap']), 'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''), 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), 'person_ids' => $has('person_ids') ? Api::ints('person_ids') : $this->annivPeopleOf($cur), ]; } /** * 现有纪念日的关联人(PATCH 未带 `person_ids` 时的兜底)。 * * 🔴🔴 2026-10-20 修的数据丢失:原来写的是 `$cur['people'] ?? []`, * 而 `$cur` 来自 `Anniversary::find()` —— 那条 SQL 是 `SELECT * FROM anniversaries`, * **根本不含 `people` 这个键** ⇒ 兜底恒为 `[]` ⇒ * 「只改个标题」的 PATCH 会把这条纪念日的关联人**全部清空**。 * 网页端没暴露,是因为网页表单每次都会把选人框整份提交上来; * 而 App 端做「编辑」时很自然只发改动的那几个字段 —— 一改就丢人。 * 这里改成真去 `anniversary_people` 读一次。 * * ⚠️ `peopleOf()` 自己带静态缓存,同一请求里反复读同一 id 不会反复查库。 * * @param array|null $cur 当前记录(新建时为 null) * @return int[] 关联人 person id */ private function annivPeopleOf(?array $cur): array { if (!$cur) { return []; } // 调用方**已经**把 people 填进来的话,优先用它的(省一次查询) if (is_array($cur['people'] ?? null) && $cur['people']) { return array_map(static fn($p) => (int)($p['id'] ?? 0), $cur['people']); } return Anniversary::peopleOf($cur); } /* ═══════════════════════════════════════════════════════════════ 任务 ═══════════════════════════════════════════════════════════════ */ /** * 一条任务 → 给 App 的形状。 * * 🔴 v79 为什么要包这一层:`Task::find()` 只 SELECT `t.*`,**不含看板名/看板色** * (网页端是在模板里再查一次)。端上面板要显示"这条在哪个看板"、 * 还要拿它的颜色去画那颗小圆点 —— 让端上为了两个字段再打一次 * `/tasks?view=boards` 是多余的一跳,就在服务端补齐。 * `board_color_calc` 与首页任务用的是**同一个**派生口径, * 否则同一个看板在首页和详情里是两种颜色(用户能一眼看出来)。 */ private static function taskOut(int $fam, int $id, int $pid): array { $t = Task::find($fam, $id, $pid); if (!$t) { return []; } $bid = (int)($t['board_id'] ?? 0); $t['board_name'] = Task::boardName($fam, $bid > 0 ? $bid : null); $t['board_color_calc'] = Task::boardColorById($fam, $bid > 0 ? $bid : null); return $t; } private function tasks(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $t = Task::find($fam, $id, $pid); if (!$t) { Api::notFound('这项任务'); } // 子动作:状态 / 搬看板 / 评论 if ($sub === 'status' && ($method === 'POST' || $method === 'PATCH')) { Api::requireWrite(); Task::setStatus($fam, $id, Api::str('status', 'todo'), Api::int('position')); Api::ok(self::taskOut($fam, $id, $pid)); } if ($sub === 'move' && $method === 'POST') { Api::requireWrite(); Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid); Api::ok(self::taskOut($fam, $id, $pid)); } if ($sub === 'comments') { if ($method === 'GET') { Api::ok(Task::comments($id)); } if ($method === 'POST') { Api::requireWrite(); $txt = Api::str('content', '', 4000); if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); } Api::ok(['id' => Task::addComment($id, $pid, $txt)], 201); } } /* ═══════════════════════════════════════════════════════════ v79(用户 2026-10-22):「鸿蒙端的功能,也需要同样适配…… 任务模块的功能需要完善。」 ⇒ 端上要对齐网页 v78 的「就地面板」,而面板里的**加附件**一直没有 API (网页端走的是 `tasks/attach_add` 这条表单路由)。 ──────────────────────────────────────────────────────────── POST /api/v1/tasks//attach multipart,字段名 `file` DELETE /api/v1/tasks//attach 参数 `attachment_id` ⚠️ 路由只解析「资源/id/子动作」**三段**(`Api::dispatch`),没有第四段 ⇒ 删附件**必须**把 id 放参数里。写成 `/attach/` 的话, `Api::dispatch` 只会给到 `$sub='attach'`,aid 直接丢掉(静默 404)。 ⚠️ 两个分支都回**整条任务**(`Task::find`,含 attachs):端上拿到就能 直接重画面板,不用再补一次 GET。 ⚠️ 权限除了 `requireWrite()` 还要 `Task::canWrite()`:前者只说明 "这个账号不是只读访客",后者才说明"这条任务归我管"。 ═══════════════════════════════════════════════════════════ */ if ($sub === 'attach') { Api::requireWrite(); if (!Task::canWrite($t, $pid)) { Api::fail('这项任务不能改。', 'forbidden', 403); } if ($method === 'POST') { $file = $_FILES['file'] ?? null; if (!is_array($file)) { Api::fail('没有收到文件。', 'unprocessable', 422); } $r = \App\Attachment::save($fam, Task::ATTACH, $id, $file, $pid); if (empty($r['ok'])) { $msg = (string)($r['error'] ?? ''); Api::fail($msg !== '' ? $msg : '文件没存下来', 'unprocessable', 422); } Api::ok(self::taskOut($fam, $id, $pid), 201); } if ($method === 'DELETE') { $aid = Api::int('attachment_id'); if ($aid <= 0) { Api::fail('要指定删哪个附件。', 'bad_request', 400); } if (!\App\Attachment::removeFrom(Task::ATTACH, $id, $aid)) { Api::notFound('这个附件'); } Api::ok(self::taskOut($fam, $id, $pid)); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'GET') { Api::ok(self::taskOut($fam, $id, $pid)); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); Task::update($fam, $id, [ 'title' => Api::str('title', (string)$t['title'], 200), 'note' => Api::str('note', (string)($t['note'] ?? '')), 'due_at' => Api::dt('due_at', $t['due_at'] ?? null), 'repeat_rule' => Api::str('repeat_rule', (string)($t['repeat_rule'] ?? '')), 'parent_id' => Api::int('parent_id', (int)($t['parent_id'] ?? 0)), 'assignee_id' => Api::int('assignee_id', (int)($t['assignee_id'] ?? 0)), 'category_id' => Api::int('category_id', (int)($t['category_id'] ?? 0)), 'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)), 'status' => (string)$t['status'], // 状态只走 /status 子动作 ]); Api::ok(self::taskOut($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Task::destroy($fam, $id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $title = Api::str('title', '', 200); if ($title === '') { Api::fail('任务得写清楚要做什么。', 'unprocessable', 422); } $newId = Task::create($fam, $pid, [ 'title' => $title, 'note' => Api::str('note', ''), 'due_at' => Api::dt('due_at', ''), 'repeat_rule' => Api::str('repeat_rule', ''), 'parent_id' => Api::int('parent_id'), // 🔴 v44 第 5 条「负责人默认是自己」——API 建任务时同样默认自己, // 与网页表单一致(不传 assignee_id 就是"我负责")。 'assignee_id' => Api::int('assignee_id', $pid), 'category_id' => Api::int('category_id'), 'board_id' => Api::int('board_id'), 'status' => Api::str('status', 'todo'), ]); foreach (Api::people(Api::ints('shares'), $fam) as $sp) { Task::setShare($newId, $sp, 'write'); } Api::ok(self::taskOut($fam, $newId, $pid), 201); } // ---- 列表:三种视图 ---- // ?view=board → 按看板分组(默认) // ?view=status&status=doing → 跨看板按状态 // ?view=done → 已完成(含分类/关键词过滤) $view = Api::str('view', 'board'); if ($view === 'status') { Api::ok(Task::byStatus($fam, $pid, Api::str('status', 'todo'))); } if ($view === 'done') { Api::ok(Task::doneList($fam, $pid, Api::int('category_id'), Api::str('keyword'))); } /* v79:**只给元数据**(看板 + 分类),不带任务。 🔴 为什么需要它:端上「挪看板 / 选分类」要的是"**全部**看板", 而看板视图(下面那段)每列都塞满了任务,为了拿 5 个名字去解析几百条任务 很浪费;更关键的是——**空看板在别的视图里根本不存在** (端上原来只能从已加载任务里反推看板 ⇒ 空看板永远选不到)。 网页端同一份数据来自 `Task::boards()` / `Task::categories()`。 */ if ($view === 'boards') { $bs = []; foreach (Task::boards($fam) as $b) { $bs[] = [ 'id' => (int)$b['id'], 'name' => (string)$b['name'], 'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']), 'open' => (int)($b['open_count'] ?? 0), ]; } $cs = []; foreach (Task::categories($fam) as $c) { $cs[] = ['id' => (int)$c['id'], 'name' => (string)$c['name']]; } Api::ok([ 'boards' => $bs, 'categories' => $cs, 'status' => Task::STATUS, 'repeat' => Task::REPEAT, ]); } // 看板视图:把"列"与"任务"一起给出来(App 一次请求就够画整屏) $boards = Task::boards($fam); /* ⚠️ `Task::boardColumns()` 返回的是**包了一层的数组** (`['cols'=>[boardId => rows], 'boards'=>…, 'defId'=>…]`), 不是"boardId => rows"本身 —— 直接按 boardId 取会永远拿到空列 (实测:接口返回的 boards 里 tasks 全是 [],而断言只看"键在不在"就漏过去了)。 ⇒ 必须取 `['cols']` 那一层。 */ $bc = Task::boardColumns($fam, $pid); $cols = $bc['cols'] ?? []; $out = []; foreach ($boards as $b) { $out[] = [ 'id' => (int)$b['id'], 'name' => (string)$b['name'], 'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']), 'tasks' => array_values($cols[(int)$b['id']] ?? []), ]; } if (!empty($cols[0])) { // 还没归到任何看板的兜底列 $out[] = ['id' => 0, 'name' => '其他', 'color' => '', 'tasks' => array_values($cols[0])]; } Api::ok([ 'boards' => $out, 'categories' => Task::categories($fam), 'status' => Task::STATUS, 'repeat' => Task::REPEAT, ]); } /* ═══════════════════════════════════════════════════════════════ 观影 ═══════════════════════════════════════════════════════════════ */ private function movies(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); /* 🔴 v62(用户 2026-10-19):「设置里面的影院管理,这些功能都还没有, 应该补充,就是增加影院呢、修改影院呢、**高级搜索、统计、年度报告**, 这些全部都应该补充进去。」 ⇒ 网页端早就有 `movies/search` / `movies/stats` / `movies/report` (见 `MovieStats`),但 **API 一个都没暴露** —— 端上只能干看着。 这里按网页同一套 `MovieStats` 暴露成子路由,口径与网页**完全一致** (统计数字两边必须一样,否则用户会发现"网页 124 部、App 121 部")。 ⚠️ 这三个都是 **家族级**(不针对某一条观影记录)⇒ 必须在 `$id > 0` 分支**之前**处理,否则 `/movies/stats` 会被当成"id=0 的详情"。 */ if ($sub === 'stats' && $id === 0) { if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } Api::ok([ 'overview' => MovieStats::overview($fam), 'by_cinema'=> MovieStats::byCinema($fam), 'by_person'=> MovieStats::byPerson($fam, $pid), 'by_month' => MovieStats::byMonth($fam, 12), 'by_year' => MovieStats::byYear($fam), ]); } if ($sub === 'report' && $id === 0) { if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } $years = MovieStats::years($fam); $year = (int)Api::int('year'); // 没给年份 / 给的年份没有数据 ⇒ 退回最新有数据的那一年(与网页同一规则) if ($year <= 0 || !in_array($year, array_map('intval', $years), true)) { $year = count($years) > 0 ? (int)$years[0] : (int)date('Y'); } Api::ok([ 'year' => $year, 'years' => count($years) > 0 ? array_map('intval', $years) : [$year], 'stats' => MovieStats::annual($fam, $pid, $year), ]); } if ($sub === 'search' && $id === 0) { if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } $p = [ 'title' => Api::str('title'), 'cinema_id' => Api::int('cinema_id'), 'person_id' => Api::int('person_id'), 'date_from' => (string)(Api::param('date_from') ?? ''), 'date_to' => (string)(Api::param('date_to') ?? ''), ]; $has = false; foreach ($p as $v) { if ($v !== '' && $v !== 0) { $has = true; break; } } // ⚠️ 条件全空时**不查全库**(那等于"列出所有观影",不是搜索)—— // 与网页 `hasCond` 同一行为,端上据此提示"先设条件"。 Api::ok([ 'has_cond' => $has ? 1 : 0, 'rows' => $has ? MovieStats::search($fam, $p, $pid) : [], ]); } /* ──────────────────────────────────────────────────────────────── 🔴 v64(用户 2026-10-19)**豆瓣自动补充**(片名联想 / 抓取详情+海报 / 缩略图代理) 用户原话:「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。 这个在网页端是直接实现了的,我们的手机端也必须同步实现。」 ⇒ 网页端那条路由(`index.php` 的 `movies/douban`)**一行没改、逻辑照搬**, 只是换一个 App 打得到的入口。三件事与网页**逐条对齐**: · `action=suggest` → 片名候选(下拉联想); · `action=thumb` → 代理豆瓣缩略图(**回图片字节,不是 JSON 信封**); · 不带 action → 抓详情 + 海报落盘(poster 槽位管线),回 JSON。 ⚠️ 为什么缩略图必须由服务端代取:豆瓣有防盗链,客户端直连一律 403。 `Douban::thumb()` 里已经带 Referer 了。 ⚠️ 为什么用 query 传 action 而不是 `/movies/douban/thumb`: `Api::dispatch()` 只解析「资源 / id / 子动作」两层, 三段路径里第三段会被丢掉。query 是最省事、也不跟路由打架的形态。 ⚠️ **路径必须是 `movies/douban`**:`Api::dispatch()` 取 `parts[1]` 当子动作, 非数字时 `$id === 0` ⇒ 正好落在 $id>0 分支**之前**,不会被当成"id=0 的详情"。 ──────────────────────────────────────────────────────────────── */ if ($sub === 'douban' && $id === 0) { $act = Api::str('action'); if ($act === 'suggest') { if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } $res = Douban::suggest(Api::str('q')); if (!$res['ok']) { Api::fail((string)($res['error'] ?? '搜索失败'), 'upstream', 502); } $items = (array)($res['items'] ?? []); /* ⚠️ 逐个把候选缩略图换成 **API 形式**的代理地址。 端上拿不到 `index.php?r=` 那个网页入口(见 `Douban::apiThumbUrl`)。 ⚠️ 原始地址只在过了白名单时才下发(见 `buildSuggestItems` 的 `img`)。 */ foreach ($items as &$it) { $rawImg = (string)($it['img'] ?? ''); $it['thumb'] = $rawImg !== '' ? Douban::apiThumbUrl($rawImg) : ''; unset($it['img']); } unset($it); Api::ok(['items' => array_values($items)]); } if ($act === 'thumb') { if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } $img = Douban::thumb(Api::str('u')); if ($img === null) { Api::fail('取不到这张图。', 'not_found', 404); } // ⚠️ 这里**故意不走 Api 信封**:调用方是 `Image()`,它要的是图片字节。 // 与 `media.php` 同一口径(那个也不走信封)。 if (!headers_sent()) { header('Content-Type: ' . $img['mime']); header('Content-Length: ' . strlen($img['body'])); header('Cache-Control: public, max-age=86400'); } echo $img['body']; exit; } if ($act !== '') { Api::fail('不支持的 action。', 'bad_request', 400); } // 默认:按 id 或片名抓详情 + 海报落盘 if ($method !== 'POST' && $method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } Api::requireWrite(); $idDb = Api::str('id'); // ⚠️ 豆瓣条目 id(**字符串**,不是本函数参数里那个数字 id) $qDb = Api::str('q'); if ($idDb !== '') { $resDb = Douban::fetchById($idDb); } elseif ($qDb !== '') { $resDb = Douban::fetchByTitle($qDb); } else { Api::fail('请输入电影名称。', 'unprocessable', 422); } if (!$resDb['ok']) { Api::fail((string)($resDb['error'] ?? '抓取失败'), 'upstream', 502); } // 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口) // ⚠️ 与网页那条路由**同一套**(`Upload::image`),端上不做任何裁剪 —— // 服务端保证落库的一定是 2:3。 $posterKey = ''; $posterShow = ''; if ((string)($resDb['poster_url'] ?? '') !== '') { $bytes = Douban::downloadPoster((string)$resDb['poster_url']); if ($bytes !== '') { $tmp = tempnam(sys_get_temp_dir(), 'fbdb'); if ($tmp !== false) { file_put_contents($tmp, $bytes); $up = Upload::image($fam, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster'); @unlink($tmp); if ($up['ok']) { $posterKey = (string)$up['file']; $posterShow = Storage\Storage::url($fam, $posterKey); } } } } Api::ok([ 'source' => 'douban', 'title' => (string)($resDb['title'] ?? ''), 'meta' => $resDb['meta'], /* 拼好的简介(含导演/主演/年份/类型/片长/国家/评分 + 剧情原文)。 在服务端拼、由端上填空 —— 与网页端**同一份输出**: 这段文本会**落库**(movie_details.info),两端拼法必须一致。 */ 'summary' => Douban::summaryText( (array)$resDb['meta'], (string)($resDb['title'] ?? ''), true ), 'poster_key' => $posterKey, 'poster' => $posterShow, ]); } /* ──────────────────────────────────────────────────────────────── 🔴 v64:**海报 / 观影照片上传**(端上「记一场」多图) 用户原话:「**允许上传多张图片,包括海报。**」 ⇒ 网页端是靠 `movies/create` 的 multipart 表单一次性带上 (`poster` 单张 + `photo[]` 多张,见 index.php 那段)。 但 App 这边"先 JSON 建记录、再逐个传图"更稳: · JSON 建记录失败时不会留下半张孤儿图; · 图片传失败也**不挡记录**(与网页同一取舍:一条记录因为一张 5MB 原图存不进去,看着像功能坏了); · 一次传一张,进度好做、重试粒度小。 ⚠️ 图片走**旧值保留**语义:传一张海报就是"换掉旧海报并删旧对象" (与网页 `movies/update` 里那段一致);照片是**追加**,不替换 (替换会把用户前几次传的全冲掉)。 ⚠️ 槽位固定:海报 `poster`(2:3 裁)、照片 `photo`(free 等比缩)。 槽位决定比例与输出尺寸,由 `Upload::image` 在服务端保证。 ⚠️ 必须是 `$id > 0`(记录已存在)—— 所以这个分支放在下面那一段里。 ──────────────────────────────────────────────────────────────── */ if (($sub === 'poster' || $sub === 'photos') && $id === 0) { Api::fail('要指定是哪一条观影记录。', 'bad_request', 400); } if ($id > 0) { $m = Movie::find($fam, $id, $pid); if (!$m) { Api::notFound('这条观影记录'); } if ($sub === 'poster' || $sub === 'photos') { if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } Api::requireWrite(); if ($sub === 'poster') { // ⚠️ 表单字段名固定 `poster`(与网页端同名)——端上拼 multipart 时用 $up = Upload::image($fam, $_FILES['poster'] ?? [], 'poster'); if (!$up['ok']) { Api::fail((string)($up['error'] ?? '海报没存下来'), 'unprocessable', 422); } $newKey = (string)$up['file']; $oldKey = (string)($m['poster_key'] ?? ''); Movie::setPoster($id, $newKey); // 换图顺手删旧对象(与网页 movies/update 同一口径,否则堆孤儿) if ($oldKey !== '' && $oldKey !== $newKey) { Storage\Storage::delete($fam, $oldKey); } Api::ok([ 'poster_key' => $newKey, 'poster' => Storage\Storage::url($fam, $newKey), ]); } // ---- 观影照片(可多张;字段 `photo[]`,一次传几张都行)---- $files = self::normalizeFiles('photo'); if ($files === []) { Api::fail('没有收到图片。', 'unprocessable', 422); } $added = []; $warns = []; foreach ($files as $f) { $up = Upload::image($fam, $f, 'photo'); if ($up['ok']) { $added[] = (string)$up['file']; } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = (string)$up['error']; } } if ($added === []) { Api::fail($warns ? implode(';', $warns) : '这几张都没存下来', 'unprocessable', 422); } /* ⚠️ **追加**而不是替换:`Movie::addPhotos()` 保留库里已有的那些。 (替换的实现会把用户上一次传的照片全删掉 —— 与"多图"的直觉相反。) ⚠️ 只把这次**新增成功**的那些传进去,不碰旧的。 */ Movie::addPhotos($id, $added); Api::ok([ 'added' => $added, 'photos' => array_map( static fn(array $p): array => [ 'id' => (int)$p['id'], 'key' => (string)$p['key'], 'url' => Storage\Storage::url($fam, (string)$p['key']), ], Movie::photos($id) ), 'warns' => $warns, ]); } if ($sub === 'comments') { if ($method === 'GET') { $rv = MovieReview::mine($id, $pid); Api::ok($rv ? MovieReview::forEvent($id) : []); } if ($method === 'POST') { Api::requireWrite(); $rv = MovieReview::mine($id, $pid); if (!$rv) { Api::fail('先写一条评价,才能评论。', 'unprocessable', 422); } $txt = Api::str('content', '', 4000); if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); } $cid = MovieReview::addComment((int)$rv['id'], $pid, $txt, Api::int('parent_id'), Api::str('target_person', '')); if ($cid <= 0) { Api::fail('评论没保存下来(可能内容太长)。', 'unprocessable', 422); } Api::ok(['id' => $cid], 201); } } if ($sub === 'review' && ($method === 'POST' || $method === 'PUT' || $method === 'PATCH')) { Api::requireWrite(); $my = MovieReview::mine($id, $pid); $err = MovieReview::save( $id, $fam, $pid, Api::int('rating'), Api::str('content', '', 8000), array_values((array)Api::param('feelings', [])), $my !== null ); if ($err !== '') { Api::fail($err, 'unprocessable', 422); } Api::ok(MovieReview::mine($id, $pid)); } if ($method === 'GET') { Api::ok($m); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); Movie::update($fam, $id, $this->movieData($m)); Api::ok(Movie::find($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Movie::destroy($fam, $id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $d = $this->movieData(null); if ($d['title'] === '') { Api::fail('片名不能为空。', 'unprocessable', 422); } if ($d['starts_at'] === '') { Api::fail('观影时间不能为空。', 'unprocessable', 422); } $newId = Movie::create($fam, $pid, $d); Api::ok(Movie::find($fam, $newId, $pid), 201); } $list = Movie::all($fam, Api::str('keyword'), $pid); [$per, $off] = Api::page(50); Api::list(array_slice(array_values($list), $off, $per), count($list)); } /** * 把 `$_FILES['name']` 归一成「一个文件一个数组」的列表(支持 `name[]` 多文件)。 * * 🔴 v64:端上「记一场」允许多张观影照片(用户:「允许上传多张图片」)。 * PHP 对 `` 给的 * `$_FILES['photo']` 是**按字段竖切**的(`name`/`type`/`tmp_name`/… 各一个数组), * 每个 `Upload::image()` 要的是**横切**的一行。 * ⇒ 这里做一次转置,别在业务分支里手写索引。 * * ⚠️ 单文件与多文件两种形态都要认(单文件时 `name` 是字符串,不是数组)—— * 端上第一次传 1 张、第二次传 3 张,走的是同一段代码。 * ⚠️ `UPLOAD_ERR_NO_FILE` 的那种"空槽"要**丢掉**: * 文件多选控件在用户取消选择时会留一个空槽,交给 `Upload::image` * 会被判成"没传图"(error 空串)从而静默少一张 —— 不如这里就过滤掉。 * * @return array */ private static function normalizeFiles(string $field): array { if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) { return []; } $f = $_FILES[$field]; // 单文件形态:直接包一层 if (!is_array($f['name'])) { return (int)($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE ? [] : [$f]; } $out = []; foreach (array_keys($f['name']) as $i) { $one = [ 'name' => (string)($f['name'][$i] ?? ''), 'type' => (string)($f['type'][$i] ?? ''), 'tmp_name' => (string)($f['tmp_name'][$i] ?? ''), 'error' => (int)($f['error'][$i] ?? UPLOAD_ERR_NO_FILE), 'size' => (int)($f['size'][$i] ?? 0), ]; if ($one['error'] === UPLOAD_ERR_NO_FILE) { continue; } $out[] = $one; } return $out; } /** 观影写入字段(新建与更新共用;`$cur` 非空时做"只改给了的字段"的回退) */ private function movieData(?array $cur): array { $b = Api::body(); $pick = static function (string $k, $def) use ($b) { return array_key_exists($k, $b) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET) ? null : $def; // null = "有人给了",下面按 Api::* 取真实值 }; $has = static fn(string $k): bool => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); return [ 'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''), 'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''), 'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''), 'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''), 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), 'info' => $has('info') ? Api::str('info', '') : (string)($cur['info'] ?? ''), 'cinema_id' => $has('cinema_id') ? Api::int('cinema_id') : (int)($cur['cinema_id'] ?? 0), 'attendees' => $has('attendees') ? Api::ints('attendees') : array_map( static fn($a) => (int)$a['id'], (array)($cur['attendees'] ?? [])), /* 🔴 v64:**豆瓣抓来的海报键**(端上不传文件,只把这个键带上)。 ⚠️ 与网页端 `movies/create` 里那段同一个契约(隐藏字段 `poster_key`), 但校验放在**这里**(模型入口)而不是各调用点 —— 键必须形如 `posters/xxx.jpg`,否则手搓请求能让一行记录指向 别的家庭的存储对象。 ⚠️ 只在"有人给了这个字段"时改(`$has` 语义):改别的字段时 顺带把海报清空是最不能接受的那种数据丢失。 */ 'poster_key' => $has('poster_key') ? (preg_match('#^posters/[A-Za-z0-9._-]+$#', Api::str('poster_key')) ? Api::str('poster_key') : '') : (string)($cur['poster_key'] ?? ''), ]; } private function cinemas(int $id, string $method): void { [$fam] = Api::me(); if ($method === 'GET') { Api::ok(Cinema::all($fam)); } if ($method === 'POST' || $method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); $cur = $id > 0 ? Cinema::find($fam, $id) : null; if ($id > 0 && !$cur) { Api::notFound('这家影院'); } $name = Api::str('name', (string)($cur['name'] ?? ''), 100); if ($name === '') { Api::fail('影院名称不能为空', 'unprocessable', 422); } $err = Cinema::save( $fam, $id, Api::str('place', (string)($cur['place'] ?? ''), 100), $name, Api::str('address', (string)($cur['address'] ?? ''), 200) ?: null, Cinema::normalizeColor(Api::str('color', (string)($cur['color'] ?? '#888888'), 16)) ); if ($err !== '') { Api::fail($err, 'conflict', 409); } // save() 不返回 id ⇒ 新建时按"同地方同名"回查(表上有唯一性校验,够精确) if ($id === 0) { $id = 0; foreach (Cinema::all($fam) as $c) { if ((string)$c['name'] === $name && (string)($c['place'] ?? '') === Api::str('place')) { $id = (int)$c['id']; break; } } } Api::ok(Cinema::find($fam, $id), $cur ? 200 : 201); } if ($method === 'DELETE' && $id > 0) { Api::requireWrite(); Cinema::destroy($fam, $id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } /* ═══════════════════════════════════════════════════════════════ 旅程 / 行程 ═══════════════════════════════════════════════════════════════ */ private function trips(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $t = Trip::find($fam, $id, $pid); if (!$t) { Api::notFound('这趟旅程'); } if ($sub === 'nodes') { if ($method === 'GET') { Api::ok(Trip::nodes($id, $pid)); } if ($method === 'POST') { Api::requireWrite(); $nid = Trip::saveNode($fam, $id, $this->nodeData(null)); Api::ok(Trip::node($nid), 201); } } if ($sub === 'summary' && $method === 'GET') { Api::ok(Trip::summary($id, $pid)); } /* 🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗? 为什么这里没有增加题图的功能呢?」 ⇒ 端上补题图上传。槽位**必须**是 `cover`(`app/ImageSlot.php` 里 1920×1080 / 16:9),与网页版 `trips/form.php` 的 `cover` 文件域 同一条管线 —— 端上不裁剪,裁/缩/转 JPEG 全在服务端做。 ⚠️ 表单字段名固定 `cover`(端上拼 multipart 时用); 与观影的 `poster`(2:3)不是同一个槽位,别串。 ⚠️ 换图顺手删旧对象:否则每换一次题图就在存储里堆一张孤儿图 (与 `movies//poster` 那条同一口径)。 */ if ($sub === 'cover') { if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } Api::requireWrite(); $up = Upload::image($fam, $_FILES['cover'] ?? [], 'cover'); if (!$up['ok']) { Api::fail((string)($up['error'] ?? '题图没存下来'), 'unprocessable', 422); } $newKey = (string)$up['file']; $oldKey = (string)($t['cover_key'] ?? ''); Trip::setCover($id, $newKey); if ($oldKey !== '' && $oldKey !== $newKey) { Storage\Storage::delete($fam, $oldKey); } Api::ok([ 'cover_key' => $newKey, 'cover' => Storage\Storage::url($fam, $newKey), ]); } if ($method === 'GET') { Api::ok($t); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); $d = [ 'title' => Api::str('title', (string)$t['title'], 200), 'note' => Api::str('note', (string)($t['note'] ?? '')), 'summary' => Api::str('summary', (string)($t['summary'] ?? '')), ]; if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; } Trip::update($fam, $id, $d); Api::ok(Trip::find($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Trip::destroy($fam, $id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $title = Api::str('title', '', 200); if ($title === '') { Api::fail('旅程得有个名字。', 'unprocessable', 422); } $d = ['title' => $title, 'note' => Api::str('note', ''), 'summary' => Api::str('summary', '')]; if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; } $newId = Trip::create($fam, $pid, $d); Api::ok(Trip::find($fam, $newId, $pid), 201); } Api::ok(Trip::all($fam, Api::str('keyword'))); } /** 行程节点字段(与网页 node_add 同一份契约) */ private function nodeData(?array $cur): array { $has = static fn(string $k): bool => array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET); return [ 'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''), 'detail' => $has('detail') ? Api::str('detail', '') : (string)($cur['detail'] ?? ''), 'place' => $has('place') ? Api::str('place', '', 200) : (string)($cur['place'] ?? ''), 'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''), 'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''), 'node_type' => $has('node_type') ? Api::str('node_type', 'other') : (string)($cur['node_type'] ?? 'other'), 'status' => $has('status') ? Api::str('status', 'planned') : (string)($cur['status'] ?? 'planned'), 'category' => $has('category') ? Api::str('category', 'plan') : (string)($cur['category'] ?? 'plan'), // stage 不在这里改:只由新建/显式动作决定(与网页 node_add 同一条纪律) 'stage' => $cur === null ? Api::str('stage', 'plan') : (string)($cur['stage'] ?? 'plan'), 'members' => $has('members') ? Api::ints('members') : [], 'travel_mode' => Api::str('travel_mode', (string)($cur['travel_mode'] ?? '')), 'vehicle_id' => Api::int('vehicle_id', (int)($cur['vehicle_id'] ?? 0)), 'line_name' => Api::str('line_name', ''), 'carrier' => Api::str('carrier', ''), 'trip_no' => Api::str('trip_no', ''), ]; } /* ═══════════════════════════════════════════════════════════════ 记账 ═══════════════════════════════════════════════════════════════ */ private function ledger(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $lg = Ledger::find($fam, $id, $pid); if (!$lg) { Api::notFound('这本账本'); } if ($sub === 'entries') { if ($method === 'GET') { $es = Ledger::entries($id); Api::ok(['items' => $es, 'summary' => Ledger::summary($es)]); } if ($method === 'POST') { Api::requireWrite(); if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); } // ⚠️ `amount` 交给模型自己转分(Ledger::toCents)—— App 传元, // 与网页表单同一口径;这里**不要**先转一次(会双重换算)。 $amount = Api::param('amount', ''); if (trim((string)$amount) === '') { Api::fail('金额不能为空。', 'unprocessable', 422); } $eid = Ledger::addEntry($id, [ 'kind' => Api::str('kind', 'expense'), 'amount' => $amount, 'title' => Api::str('title', '', 120), 'note' => Api::str('note', ''), 'person_id' => Api::int('person_id'), ], $pid); Api::ok(['id' => $eid], 201); } /* 🔴 v68:改一笔 / 删一笔。 ⚠️ 为什么 id 放在 **body/query** 而不是第 4 段路径: `Api::dispatch()` 只解析 `///` **三段**, `PATCH /ledgers/3/entries/17` 的第 4 段会被直接丢掉, 请求会落到"整本账本"上(v66 在 `trips/vehicles` 上踩过一次)。 */ if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); } $eid = Api::int('id'); if ($eid <= 0) { Api::fail('要改哪一笔?缺 `id`。', 'unprocessable', 422); } $amount = Api::param('amount', ''); if (trim((string)$amount) === '') { Api::fail('金额不能为空。', 'unprocessable', 422); } Ledger::updateEntry($id, $eid, [ 'kind' => Api::str('kind', 'expense'), 'amount' => $amount, 'title' => Api::str('title', '', 120), 'note' => Api::str('note', ''), 'person_id' => Api::int('person_id'), ]); Api::ok(['updated' => $eid]); } if ($method === 'DELETE') { Api::requireWrite(); if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); } $eid = Api::int('id'); if ($eid <= 0) { Api::fail('要删哪一笔?缺 `id`。', 'unprocessable', 422); } Ledger::removeEntry($id, $eid); Api::ok(['deleted' => $eid]); } } /* 🔴 v68:共享给谁(端上"财务 → 设置 → 共享给谁"原来是个灰条)。 `Ledger::setShare/unshare/shares` 早就在模型里,只是没有路由。 ⚠️ 只有**账本主人**能改共享关系:拿到 write 权限的被共享者 不该能再把账本转手给别人(否则等于改了别人的账本)。 */ if ($sub === 'shares') { $isOwner = (int)($lg['owner_id'] ?? 0) === $pid; if ($method === 'GET') { Api::ok(['items' => Ledger::shares($id)]); } if ($method === 'POST') { Api::requireWrite(); if (!$isOwner) { Api::fail('只有账本主人能改共享。', 'forbidden', 403); } $who = Api::int('person_id'); if ($who <= 0) { Api::fail('要共享给谁?缺 `person_id`。', 'unprocessable', 422); } Ledger::setShare($id, $who, Api::str('perm', 'read')); Api::ok(['items' => Ledger::shares($id)]); } if ($method === 'DELETE') { Api::requireWrite(); if (!$isOwner) { Api::fail('只有账本主人能改共享。', 'forbidden', 403); } $who = Api::int('person_id'); if ($who <= 0) { Api::fail('要取消谁的共享?缺 `person_id`。', 'unprocessable', 422); } Ledger::unshare($id, $who); Api::ok(['items' => Ledger::shares($id)]); } } if ($method === 'GET') { $es = Ledger::entries($id); Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]); } /* v68:账本改名(端上"账本名字"那条原来也是灰条) */ if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); } $nm = Api::str('name', '', 60); if ($nm === '') { Api::fail('账本名字不能为空。', 'unprocessable', 422); } Ledger::rename($fam, $id, $nm); Api::ok(Ledger::find($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Ledger::remove($fam, $id, $pid); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $name = Api::str('name', '', 80); /* ⚠️ `Ledger::defaultName()` **需要一个 ownerId 参数**(实测: 不给参数会 ArgumentCountError)—— 用当前人补上。 */ $newId = Ledger::create($fam, $pid, $name !== '' ? $name : Ledger::defaultName($pid)); Api::ok(Ledger::find($fam, $newId, $pid), 201); } Api::ok(Ledger::mine($fam, $pid)); } /* ═══════════════════════════════════════════════════════════════ 资源 / 书 / 笔记 ═══════════════════════════════════════════════════════════════ */ private function resources(int $id, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $r = Resource::find($fam, $id, $pid); if (!$r) { Api::notFound('这条资源'); } if ($method === 'GET') { Api::ok($r); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); Resource::update($fam, $id, [ 'title' => Api::str('title', (string)$r['title'], 200), 'content' => Api::str('content', (string)($r['content'] ?? '')), 'category'=> Api::str('category', (string)($r['category'] ?? '')), ]); Api::ok(Resource::find($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Resource::destroy($fam, $id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $title = Api::str('title', '', 200); if ($title === '') { Api::fail('资源得有个标题。', 'unprocessable', 422); } $newId = Resource::create($fam, $pid, $pid, [ 'title' => $title, 'content' => Api::str('content', ''), 'category' => Api::str('category', ''), ]); Api::ok(Resource::find($fam, $newId, $pid), 201); } Api::ok(Resource::all($fam, $pid, Api::int('owner_id'), Api::str('keyword'), Api::str('sort', 'used'))); } /* ═══════════════════════════════════════════════════════════════ 旅程三项设置(v66,用户 2026-10-20) 用户原话:「旅程的……车辆管理、出行方式、节点设置,这些东西都还没完成。」 网页端早就有这两块(`trips/vehicles.php` / `trips/types.php`), 表与模型(`Vehicle` / `NodeType` 的 `save/destroy`)也齐 —— **缺的只是 API 路由**,所以端上那三个菜单项一直点不开。 ⚠️ 「出行方式」**没有**这一对接口:它是枚举常量(`Trip::TRAVEL_MODES`), 加一种要改代码,不存在"用户自己管理"这回事。 ⚠️ 这里**不复制**模型的校验逻辑:名称必填、重名、内置不可删、 还有 N 段行程在用……全在 `Vehicle` / `NodeType` 里(网页端同一份)。 接口只负责把 `ok=false` 的 `error` 原样回给端上 —— 那句话已经是人话。 ═══════════════════════════════════════════════════════════════ */ /** * `/api/v1/vehicles` —— 出行用的车(自家 / 租的 / 借的)。 * * ⚠️ 与财务里 `kind='car'` 的**账目**是两回事(服务端 `Vehicle` 文件头写明): * 那条账目记的是"这台车值多少钱",没有车牌车型;用户 2026-10-05 明确 * 要求选车下拉里**不要**混进财务的车。 */ private function vehicles(int $id, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { if ($method === 'GET') { $v = Vehicle::find($fam, $id); if (!$v) { Api::notFound('这辆车'); } $v['used_count'] = $this->vehicleUsedCount($id); Api::ok($v); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); $cur = Vehicle::find($fam, $id); if (!$cur) { Api::notFound('这辆车'); } $r = Vehicle::save($fam, $this->vehicleInput($cur), $id); if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); } $v = Vehicle::find($fam, $id) ?: []; $v['used_count'] = $this->vehicleUsedCount($id); Api::ok($v); } if ($method === 'DELETE') { Api::requireWrite(); $r = Vehicle::destroy($fam, $id); if (empty($r['ok'])) { /* 422 而不是 400:这是"现在不能删"(还有行程在用), 不是"请求写错了" —— 端上据此原样显示服务端那句话。 */ Api::fail((string)($r['error'] ?? '删除失败。'), 'unprocessable', 422); } Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $r = Vehicle::save($fam, $this->vehicleInput([])); if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); } $newId = (int)($r['id'] ?? 0); $v = Vehicle::find($fam, $newId) ?: []; $v['used_count'] = $this->vehicleUsedCount($newId); Api::ok($v, 201); } $out = []; foreach (Vehicle::all($fam) as $v) { $v['used_count'] = $this->vehicleUsedCount((int)$v['id']); $out[] = $v; } Api::ok($out); } /** 一辆车被多少段行程引用(端上据此决定"删除"能不能点) */ private function vehicleUsedCount(int $vehicleId): int { if ($vehicleId <= 0) { return 0; } try { $st = Database::get()->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?'); $st->execute([$vehicleId]); return (int)$st->fetchColumn(); } catch (\Throwable $ex) { return 0; } } /** * 车字段的取值:**请求里有就用请求的,没有就沿用库里那份**。 * * ⚠️ 为什么不能像 `resources` 那样直接 `Api::str('plate', $cur['plate'])` 一路写死: * 那样每多一个字段就要多抄一行,而且 PATCH 只改一个字段时会把别的冲成空。 * 这里逐键取,语义与页面表单提交一致(`Vehicle::save` 自己会 trim/截断)。 */ private function vehicleInput(array $cur): array { return [ 'plate' => Api::param('plate', (string)($cur['plate'] ?? '')), 'name' => Api::param('name', (string)($cur['name'] ?? '')), 'vtype' => Api::param('vtype', (string)($cur['vtype'] ?? 'own')), 'vendor' => Api::param('vendor', (string)($cur['vendor'] ?? '')), 'order_no' => Api::param('order_no', (string)($cur['order_no'] ?? '')), 'is_default' => Api::param('is_default', (int)($cur['is_default'] ?? 0)), 'note' => Api::param('note', (string)($cur['note'] ?? '')), ]; } /** * `/api/v1/nodetypes` —— 行程节点类型(游玩 / 用餐 / 赶路…,可自定义)。 * * 🔴 改名会**级联**到已有行程(`trip_nodes.node_type` 存的是名字,不是 id)—— * 这条在 `NodeType::save` 里做,接口不要重复实现。 */ private function nodeTypes(int $id, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { if ($method === 'GET') { $t = NodeType::find($fam, $id); if (!$t) { Api::notFound('这个类型'); } $t['used_count'] = NodeType::usedCount($fam, (string)$t['name']); Api::ok($t); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); $cur = NodeType::find($fam, $id); if (!$cur) { Api::notFound('这个类型'); } $r = NodeType::save($fam, $this->nodeTypeInput($cur), $id); if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); } $t = NodeType::find($fam, $id) ?: []; $t['used_count'] = NodeType::usedCount($fam, (string)($t['name'] ?? '')); Api::ok($t); } if ($method === 'DELETE') { Api::requireWrite(); $r = NodeType::destroy($fam, $id); if (empty($r['ok'])) { // 内置类型 / 还有行程在用 —— 两种都是"现在不能删",不是请求写错 Api::fail((string)($r['error'] ?? '删除失败。'), 'unprocessable', 422); } Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $r = NodeType::save($fam, $this->nodeTypeInput([])); if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); } $newId = (int)($r['id'] ?? 0); $t = NodeType::find($fam, $newId) ?: []; $t['used_count'] = 0; Api::ok($t, 201); } /* ⚠️ `NodeType::all()` 内部会**自愈地**补内置类型(`seedDefaults` / `seedFromTripLog`),所以这里调它就是"保证列表一定不为空"。 */ $out = []; foreach (NodeType::all($fam) as $t) { $t['used_count'] = NodeType::usedCount($fam, (string)$t['name']); $out[] = $t; } Api::ok($out); } /** 节点类型字段的取值(同 `vehicleInput`:请求优先、缺了沿用库里) */ private function nodeTypeInput(array $cur): array { return [ 'name' => Api::param('name', (string)($cur['name'] ?? '')), 'icon' => Api::param('icon', (string)($cur['icon'] ?? '')), ]; } private function books(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); if ($id > 0) { $bk = Book::find($fam, $id); if (!$bk) { Api::notFound('这本书'); } if ($sub === 'notes' && $method === 'GET') { Api::ok(Book::notes($id, $pid)); } if ($sub === 'notes' && $method === 'POST') { Api::requireWrite(); $txt = Api::str('content', '', 8000); if ($txt === '') { Api::fail('笔记内容不能为空。', 'unprocessable', 422); } $nid = Book::addNote($fam, $id, $pid, [ 'content' => $txt, 'kind' => Api::str('kind', 'note'), 'chapter' => Api::str('chapter', '', 120), 'loc' => Api::str('loc', '', 120), ]); if ($nid <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); } Api::ok(['id' => $nid], 201); } if ($method === 'GET') { Api::ok($bk); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); $d = $bk; if (array_key_exists('status', Api::body())) { $d['status'] = Api::str('status'); } if (array_key_exists('current_page', Api::body())) { $d['current_page'] = Api::str('current_page'); } if (array_key_exists('rating', Api::body())) { $d['rating'] = Api::int('rating'); } Book::update($fam, $id, $d); Api::ok(Book::find($fam, $id)); } if ($method === 'DELETE') { Api::requireWrite(); Book::destroy($fam, $id); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $title = Api::str('title', '', 200); if ($title === '') { Api::fail('书名不能为空。', 'unprocessable', 422); } $newId = Book::create($fam, $pid, [ 'title' => $title, 'author' => Api::str('author', '', 120), 'category' => Api::str('category', ''), 'status' => Api::str('status', 'want'), ]); Api::ok(Book::find($fam, $newId), 201); } Api::ok(Book::all($fam, [ 'status' => Api::str('status'), 'category' => Api::str('category'), 'owner' => Api::int('owner_id'), 'keyword' => Api::str('keyword'), ])); } private function notes(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); /* ═══════════════════════════════════════════════════════════ 工单 #23(用户 2026-10-28「鸿蒙端对齐网页端」):笔记图片块。 端上要"图文混排"(往笔记里插图),缺两步: · 上传图片拿附件 key → 这里补 `POST /api/v1/notes/upload` · 拿到的 key 作为 `image` 块塞进 `blocks` 存 —— `noteBlocksIn()` 本来就认 `blocks` 数组(见 `cleanBlocks` 的 image 分支), `Note::claimUploads` 也会按块里的 key 把"草稿附件"认领到笔记上。 🔴 上传是**草稿态**、**不依赖笔记 id**(新建笔记时还没有 id): 图片先挂在 target_id=0,等 `blocks` 带 key 提交时再认领—— 与 Web 端 `notes/upload` 路由同一套语义。 ⚐ 注意:`Notes/upload` 的 `$sub='upload'`、`$id=0`,所以这里必须 放在 `if ($id > 0)` **之前**(否则新建笔记插不了图)。 ═══════════════════════════════════════════════════════════ */ if ($sub === 'upload') { Api::requireWrite(); if ($method === 'POST') { $file = $_FILES['file'] ?? null; if (!is_array($file)) { Api::fail('没有收到文件。', 'unprocessable', 422); } $r = \App\Attachment::save($fam, Note::ATTACH, 0, $file, $pid); if (empty($r['ok'])) { $msg = (string)($r['error'] ?? ''); Api::fail($msg !== '' ? $msg : '图片没存下来', 'unprocessable', 422); } // 🔴 `Attachment::save` 只回 id 不回 key ⇒ 反查一行拿 key // (与 Web 端 `notes/upload` 路由同一招,见 index.php 那段)。 $key = ''; foreach (\App\Attachment::list(Note::ATTACH, 0) as $a) { if ((int)$a['id'] === (int)($r['id'] ?? 0)) { $key = (string)$a['key']; break; } } Api::ok([ 'key' => $key, 'id' => (int)($r['id'] ?? 0), ], 201); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($id > 0) { $n = Note::find($fam, $id, $pid); if (!$n) { Api::notFound('这条笔记'); } if ($method === 'GET') { Api::ok($n); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); /* 🔴🔴 v65(2026-10-20 修的数据丢失):`Note::update()` 会把 `cleanBlocks($d['blocks'] ?? [])` **整组替换**写回(先 DELETE 再 INSERT), 而原来这里传的是 `'body' => …` —— `Note` 根本不读 `body`,只读 `blocks` ⇒ 请求里的正文被忽略、`blocks` 恒为 `[]` ⇒ **只改个标题就会把整条笔记的正文全部删光**。 这里改成: · 请求给了 `blocks` / `body` → 按请求替换; · 都没给(只想改标题/标签)→ **沿用库里的块**。 */ $blocks = $this->noteBlocksIn(); if ($blocks === null) { $blocks = $n['blocks']; } Note::update($fam, $id, $pid, [ 'title' => Api::str('title', (string)$n['title'], 200), 'blocks' => $blocks, 'tags' => Api::param('tags', $n['tags'] ?? ''), ]); Api::ok(Note::find($fam, $id, $pid)); } if ($method === 'DELETE') { Api::requireWrite(); Note::destroy($fam, $id, $pid); Api::ok(['deleted' => $id]); } Api::fail('不支持的方法。', 'method_not_allowed', 405); } if ($method === 'POST') { Api::requireWrite(); $title = Api::str('title', '', 200); if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); } $newId = Note::create($fam, $pid, [ 'title' => $title, /* 🔴 v65:同上,`Note::create()` 读的是 `blocks`。 原来传 `'body'` ⇒ 新建出来的笔记**只有标题、一个字都没有**。 */ 'blocks' => $this->noteBlocksIn() ?? [], 'tags' => Api::param('tags', []), ]); if ($newId <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); } Api::ok(Note::find($fam, $newId, $pid), 201); } [$per, $off] = Api::page(50); Api::ok([ 'tags' => Note::tagCloud($fam, $pid), 'items' => Note::all($fam, $pid, Api::str('keyword'), Api::str('tag'), $per, $off), ]); } /** * 请求里的「正文」→ `note_blocks` 数组。 * * 🔴🔴 为什么要有这个函数(2026-10-20 修的一处**数据丢失**): * `Note::create()` / `Note::update()` 读的键是 **`blocks`** * (`note_blocks` 表,一条笔记由若干块组成:text / image / …), * 而本文件的 `notes()` 原来传的是 **`body`** —— `Note` 压根不看这个键。 * 后果: * · `POST /notes`:正文被丢掉,新笔记只有标题; * · `PATCH /notes/`:`blocks` 恒为 `[]`,而 `update()` 是 * **整组替换**(先 `DELETE FROM note_blocks`)⇒ **整条笔记的正文被删光**。 * 网页端没暴露,是因为网页表单提交的是 `blocks[]`;只有走 API 的客户端会中招。 * * 兼容两种写法: * · `blocks`:数组,原样交给 `Note::cleanBlocks()` 清洗(网页表单那种); * · `body`:**纯文本**,整段包成一个 `text` 块(App 端最自然的写法)。 * * @return array|null 请求里**根本没提**正文时返回 null * —— 调用方据此区分"要清空"和"没改", * 对 null 一律沿用库里的块(见 `notes()` 的 PATCH 分支)。 */ private function noteBlocksIn(): ?array { $body = Api::body(); if (array_key_exists('blocks', $body) && is_array($body['blocks'])) { return $body['blocks']; } if (array_key_exists('body', $body) || array_key_exists('body', $_POST)) { $txt = (string)Api::str('body', ''); return $txt === '' ? [] : [['kind' => 'text', 'content' => $txt]]; } return null; } /* ═══════════════════════════════════════════════════════════════ v58 · 增量同步:各模块的「变更指纹」 ═══════════════════════════════════════════════════════════════ */ /** * 每个模块的指纹由哪些表合成。 * * 🔴 为什么要这个接口(用户 2026-10-17:「首次全量,之后增量」): * 端上每次打开都全量拉 12 个模块,其中 11 个绝大多数时候没有任何变化 —— * 白耗流量、白等时间,用户还看不到"同步到底在干什么"。 * 有了指纹,端上先问一句「哪些模块变了」,**只拉变了的**。 * * 🔴 指纹为什么是「行数 + 最大 id + 最大 updated_at」三件而不是只用 updated_at: * 本项目**有一半的表根本没有 `updated_at` 列**(`movie_details`、 * `person_roster`、`anniversaries`、`ledger_entries` … 实测 61 张表里只有 16 张有)。 * 只用 updated_at 会把"新增一部电影"判成"没变" ⇒ 端上永远看不到新数据。 * 加入 `COUNT(*)` + `MAX(id)` 后,**新增与删除一定被抓到**。 * * ⚠️ **已知局限(必须写在这里,别让后人以为它是万能的)**: * "**修改**一张没有 updated_at 的表里**已有的一行**"(比如改某部电影的海报 key) * 合成指纹前后一模一样 ⇒ 判成"没变"。 * 端上对此的兜底是**陈旧上限**:每过一段时间(或每 N 轮)强制全量一次 * (见端上 `FbSyncAll` 的 `FORCE_FULL_AFTER_MS`)。这是缓存一致性的标准折中, * 不是漏洞 —— 但不能当成"已解决"。 * * ⚠️ 表名是**写死的**(这里才知道"哪个模块由哪几张表组成")。 * 写错了不会报错,只会让那个模块的指纹偏旧。所以取指纹时一旦**一张表都没查到** * 就返回 `null`,端上把 `null` 视为「无法判断 ⇒ 每次都拉」。 * ⇒ 失败方向是"多拉一次",**永远不会是"漏同步"**。 * * ⚠️ 指纹**不按家庭过滤**(`calendar_events`/`movie_details` 这类表连 family_id 都没有): * 别家写入只会让我们多拉一次,不会串数据(内容接口本身按家庭隔离)。 * 单家庭部署下等于没有影响。 */ private const CHANGE_TABLES = [ 'movies' => ['movie_details', 'movie_reviews', 'cinemas', 'movie_review_comments'], 'roster' => ['persons', 'person_roster', 'person_tags', 'person_groups', 'person_contacts', 'person_jobs', 'person_notes', 'nicknames', 'relations'], 'calendar' => ['calendar_events', 'calendar_event_overrides', 'events', 'event_attendees', 'event_photos', 'calendars'], 'tasks' => ['tasks', 'task_comments', 'task_boards', 'task_shares'], 'days' => ['anniversaries', 'anniversary_people'], 'trips' => ['trip_details', 'trip_nodes', 'trip_node_members', 'trip_node_revisions', 'trip_expenses'], 'ledger' => ['ledgers', 'ledger_entries', 'ledger_entry_snapshots', 'ledger_shares'], 'resources' => ['resources', 'resource_shares', 'attachments'], 'books' => ['books', 'book_chapters', 'book_notes'], 'notes' => ['notes', 'note_blocks', 'note_tags', 'note_shares'], 'notifications' => ['notifications'], // 🔴 `kin`(家谱)与 `roster` **同源**:它整页都是从 persons / relations / // nicknames 现算出来的,没有自己的表。所以指纹用的就是名册那三张 —— // 名册一变,家谱必然要重拉;反过来也一样。分开列不会更准,只会多查几遍。 'kin' => ['persons', 'relations', 'nicknames'], // 'home' 故意**不在**这里:它是派生页(天气、农历、统计都要现算), // "变没变"由上面这些模块决定,而端上单独判它没有意义 // ⇒ 端上对 home 的处理是"只要别的模块变了就跟着重拉",见 FbSyncAll。 ]; /** * `GET /api/v1/changes` —— 返回各模块的变更指纹。 * * 响应: * ``` * { ok: true, data: { * server_time: '2026-10-18T09:00:00+08:00', * modules: { movies: '120:120:2026-10-17 21:03:11', roster: null, ... }, * stale_after_ms: 86400000 * } } * ``` * 指纹是**不透明字符串**,端上只做「相等 / 不相等」比较,不解析。 * `null` = 服务端自己都判断不了(表缺失/查询失败)⇒ 端上必须拉。 */ private function changes(string $method): void { Api::me(); // 必须带令牌:指纹本身不算机密,但没理由让匿名者试探"家里有没有新数据" if ($method !== 'GET') { Api::fail('只支持 GET。', 'method_not_allowed', 405); } $db = Database::get(); $out = []; $unknown = []; foreach (self::CHANGE_TABLES as $mod => $tables) { $fp = self::moduleFingerprint($db, $tables); $out[$mod] = $fp; if ($fp === null) { $unknown[] = $mod; } } // 全库水位:所有模块指纹拼起来的哈希。 // ⚠️ 端上主要用它做**快速路径**(水位没变 ⇒ 连逐个比都不用比,整轮跳过)。 Api::ok([ 'server_time' => date('c'), 'modules' => $out, 'all' => hash('sha256', json_encode($out) . ''), 'unknown' => $unknown, // 端上"陈旧上限"的建议值(1 天);端上也可以自己定更短 'stale_after_ms' => 24 * 3600 * 1000, ]); } /** * 算一个模块的指纹:`行数:最大id:最大updated_at`,各表累加/取最大。 * * @param string[] $tables 候选表名(不存在或没权限的会被跳过) * @return string|null 一张表都没查到 ⇒ null(无法判断) */ private static function moduleFingerprint(\PDO $db, array $tables): ?string { $rows = 0; $maxId = 0; $maxUpd = ''; $touched = 0; foreach ($tables as $t) { // ⚠️ 表名要拼进 SQL(标识符不能走占位符)⇒ 先严格白名单,防注入 if (preg_match('/^[a-z][a-z0-9_]{0,40}$/', $t) !== 1) { continue; } try { $st = $db->query("SELECT COUNT(*) AS c, COALESCE(MAX(`id`), 0) AS m FROM `$t`"); $row = $st !== false ? $st->fetch() : false; if ($row === false) { continue; } $touched++; $rows += (int)$row['c']; $maxId = max($maxId, (int)$row['m']); } catch (\Throwable $e) { continue; // 表不存在(老库)/ 没这张表 —— 跳过,不影响别的表 } // updated_at 单独 try:**很多表没有这一列**(见 CHANGE_TABLES 的说明) try { $u = $db->query("SELECT COALESCE(MAX(`updated_at`), '') FROM `$t`"); $s = $u !== false ? (string)$u->fetchColumn() : ''; if ($s > $maxUpd) { $maxUpd = $s; } } catch (\Throwable $e) { // 这一列不存在:只靠行数 + 最大 id 兜(新增/删除仍能被抓到) } } if ($touched === 0) { return null; } return $rows . ':' . $maxId . ':' . $maxUpd; } }