//<子劚䜜>` 后调这里。 * * ⚠ 䞺什么䞍做成"每资源䞀䞪文件"本项目的郚眲䞎静态断蚀郜是**按文件癜名单**走的 * 见 `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'), ]); } switch ($res) { case 'me': $this->me($method); return; case 'tokens': $this->tokens($id, $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; 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, $method); return; // 🔎 v45登圕手机号 + 密码 → API 什牌。 // 䞺什么必须走 API 而䞍是眑页登圕后倍制 Cookie // App 是 Bearer 什牌那套没有䌚话而原先前端只胜让人 // **手劚去眑页筟发什牌再粘莎**到 App 里 —— 这䞪䜓验没法接受。 case 'login': $this->login($method); return; } Api::fail('未知的接口资源' . $res, 'not_found', 404); } /* ═══════════════════════════════════════════════════════════════ 登圕手机号 + 密码 → API 什牌 ═══════════════════════════════════════════════════════════════ */ /** * `POST /api/v1/login` —— 甚手机号 + 密码换䞀枚 API 什牌。 * * 🔎 䞺什么需芁这䞪接口v45甚户 2026-10-12 "把服务噚接进来、跑正匏流皋" * 原先 App 只胜让人**先去眑页「䞪人讟眮 → API 什牌」筟发、再手劚粘莎** * 到 App 的讟眮页里。这是䞪"胜甚䜆没法给别人甚"的流皋。 * 有了它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); } $phone = Api::str('phone', '', 40); $password = (string)Api::param('password', ''); $label = Api::str('label', 'App 登圕', 60); // 什牌有效期倩。0 = 氞䞍过期䞎眑页筟发什牌的默讀行䞺䞀臎。 $days = Api::int('days', 0); if ($phone === '' || $password === '') { Api::fail('手机号和密码郜埗填。', 'unprocessable', 422); } // ---- 限流倱莥次数有成本防爆砎---- $gate = self::loginThrottle($phone); if ($gate !== null) { Api::fail($gate, 'too_many_requests', 429); } $hash = Crypto::hash($phone); $st = Database::get()->prepare( '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 WHERE p.phone_hash = ? LIMIT 1' ); $st->execute([$hash]); $row = $st->fetch(); // ⚠ 莊号䞍存圚 / 被停甚 / 密码错 —— **统䞀**同䞀句提瀺 + 同䞀䞪延迟。 // 分匀回话就等于告诉对方"这䞪手机号存圚/䞍存圚"。 $ok = $row && (string)$row['status'] === 'active' && password_verify($password, (string)$row['password_hash']); if (!$ok) { self::loginThrottleFail($phone); // 固定延迟让每次倱莥郜近䌌同价对脚本爆砎是实打实的成本 usleep(300000); Api::fail('手机号或密码䞍对。', 'unauthorized', 401); } self::loginThrottleClear($phone); $personId = (int)$row['person_id']; $familyId = (int)$row['family_id']; $expiresAt = null; if ($days > 0) { // ⚠ 甚服务端时闎算䞍信客户端䌠的日期 $expiresAt = date('Y-m-d H:i:s', time() + $days * 86400); } $token = ApiToken::issue($personId, $label, $expiresAt); 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()); } Api::ok([ 'token' => $token, 'expires_at' => $expiresAt, 'person' => [ 'id' => $personId, 'name' => (string)$row['name'], 'avatar' => (string)($row['avatar'] ?? ''), 'role' => (string)$row['role'], 'family_id' => $familyId, ], ]); } /* ---- 登圕限流**文件实现**䞍碰数据库结构---- ⚠ 䞺什么䞍甚 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); // 生日阎历/阳历各䞀条**只到月日**幎仜䞍倖发 $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'] ?? '') ); // 🔎 脱敏名册行同样是 `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) )); } 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::ok([ 'id' => $pid, 'name' => (string)($p['name'] ?? ''), 'avatar' => avatar_url($p['avatar'] ?? null), 'role' => Auth::roleWord(), 'family' => (function () use ($fam): array { $f = Family::find($fam) ?: []; return ['id' => $fam, 'name' => (string)($f['name'] ?? '')]; })(), // 各暡块䞻题色App 端顶郚/分类色块盎接甚它䞍必自己绎技䞀匠衚 'colors' => $this->moduleColors($fam), ]); } 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 里"退出登圕/吊销讟倇" */ 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); } 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'))); $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)); $tasks = Task::byStatus($fam, $pid, 'doing'); foreach (Task::byStatus($fam, $pid, 'todo') as $t) { if (Task::isOverdue($t['due_at'] ?? null)) { $tasks[] = $t; } } Api::ok([ 'greeting' => Home::greeting( Nickname::display($pid, null) ?: '', $today, count($soon), count(array_filter($tasks, static fn($t) => Task::isOverdue($t['due_at'] ?? null)))), 'date' => $today, 'weekday' => Home::weekday($today), 'today_events' => $todayEvents, 'tasks' => $tasks, 'unread' => Notification::unreadCount($pid), ]); } /* ═══════════════════════════════════════════════════════════════ 名册 / 人 ═══════════════════════════════════════════════════════════════ */ 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('这䞪人'); } if ($method === 'GET') { Api::ok(self::stripSensitivePerson($p)); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); Person::update($id, [ 'name' => Api::str('name', (string)$p['name'], 50), 'gender' => Api::str('gender', (string)($p['gender'] ?? '')), 'birthday'=> Api::dt('birthday', $p['birthday'] ?? null), 'note' => Api::str('note', (string)($p['note'] ?? '')), 'phone' => Api::str('phone', (string)($p['phone'] ?? ''), 40), ]); Api::ok(self::stripSensitivePerson((array)Person::find($id))); } 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, [ 'name' => $name, 'kind' => Api::str('kind', 'member'), 'gender' => Api::str('gender', ''), 'note' => Api::str('note', ''), 'phone' => Api::str('phone', '', 40), ]); Api::ok(self::stripSensitivePerson((array)Person::find($newId)), 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 的字段**去掉就地改返回同䞀䞪数组。 * * 🔎 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('这条日皋'); } if (!Calendar::canWriteEvent($ev, $pid)) { Api::fail('这本日历䜠没有写权限。', 'forbidden', 403); } if ($method === 'GET') { Api::ok($ev); } 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') { 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); } Api::ok(Anniversary::all($fam, $pid)); } /** 纪念日字段契纊䞎 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') : array_map(static fn($p) => (int)$p['id'], (array)($cur['people'] ?? [])), ]; } /* ═══════════════════════════════════════════════════════════════ 任务 ═══════════════════════════════════════════════════════════════ */ 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(Task::find($fam, $id, $pid)); } if ($sub === 'move' && $method === 'POST') { Api::requireWrite(); Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid); Api::ok(Task::find($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); } } if ($method === 'GET') { Api::ok($t); } 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(Task::find($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(Task::find($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'))); } // 看板视囟把"列"侎"任务"䞀起给出来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(); if ($id > 0) { $m = Movie::find($fam, $id, $pid); if (!$m) { Api::notFound('这条观圱记圕'); } 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)); } /** 观圱写入字段新建䞎曎新共甚`$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'] ?? [])), ]; } 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)); } 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(); // ⚠ `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); } } if ($method === 'GET') { $es = Ledger::entries($id); Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]); } 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'))); } 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 $method): void { [$fam, $pid] = Api::me(); 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(); Note::update($fam, $id, $pid, [ 'title' => Api::str('title', (string)$n['title'], 200), 'body' => Api::str('body', (string)($n['body'] ?? '')), '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, 'body' => Api::str('body', ''), 'tags' => Api::param('tags', []), ]); 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), ]); } }