From 13a5d08696f384f93c6526fd91204c46d20d9802 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 14 Oct 2026 09:41:34 +0800 Subject: [PATCH] =?UTF-8?q?v45=20API=20=E8=A1=A5=E5=BD=95=EF=BC=9A?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB=E5=BD=95=E6=8D=A2=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=20+=20=E6=88=91=E7=9A=84=E5=90=8D=E5=86=8C=20/=20?= =?UTF-8?q?=E5=AE=B6=E8=B0=B1=E6=8E=A5=E5=8F=A3=20+=20=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E5=B1=82=E6=95=8F=E6=84=9F=E5=AD=97=E6=AE=B5=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 为什么现在才提交(这是「补录」,代码早已上线并验证): v45 第三/第四批当时,同一工作区里有**并行的鸿蒙端工作流**正在改这个文件 (手机号登录换令牌、media.php 的 ?token=)。按「绝不把别人正在写的裹进 自己的提交」的纪律,那次只提交了自己的 5 个文件,本文件被**故意留下**。 此后文件静止约 25 小时、端上工作流也已收尾 ⇒ 现在补录,让仓库与线上一致 (线上 md5 = 本地 md5,deploy 是从工作树打包的,所以线上一直是这份)。 ① `POST /api/v1/login` —— 手机号 + 密码 → API 令牌 原先 App 只能「先去网页签发令牌、再手动粘贴」,能用但没法给别人用。 与网页登录有意不同:不建会话(API 无状态);限流落**文件** (`sys_get_temp_dir()/fb_login_.json`,60s 窗口 / 5 次) 而不进 $_SESSION;失败**统一**提示(不区分「没这个人」与「密码错」, 否则接口成了手机号是否已注册的探测器);有效期用**服务端时间**算。 成功时顺带 `Calendar::seedDefaults()`(全新安装的兜底)。 ② `GET /api/v1/roster` —— 「**我**名册里的人」(我加的 + 别人分享的) 与 `/people`(全家 801 人 = 通讯录底账)区分开:App 名册页要用这个, 否则一屏 800 人且是「别人的通讯录」。组装**照抄 Web 端 index.php 的 people 分支**(同一套类/判据):Person::roster → Kinship::label / Nickname::display / PersonQuery::phonePlain / PersonQuery::birthdaysOf / PersonTag::mapFor / Pinyin::initials,外加标签云与标签词表。 ⚠️ 复用这些类而非另写,否则会「网页搜得到、App 搜不到」。 ③ `GET /api/v1/kin` —— 家谱(以我为中心的关系图) 用 `Kinship::familyMap($fam,$pid,$people,2,2)`,与 Web 端 kin/tree.php 同一套 ⇒ 端上与网页的亲属关系必然一致。按代数排序,拍平直接关系字段。 ④ 🔴 信息泄露修复:`stripSensitivePerson()` `Person::all()` / `Person::roster()` 都是 `SELECT p.*`,把 `phone_hash` / `phone_encrypted` / `id_card_encrypted` / `birthday_id_card` / `birthday_lunar*` 一路吐给 App。前两个是 AES 密文(离线爆破材料), `phone_hash` 是「手机号是否属于这个家」的判定键。 ⇒ 在 **API 出口层**脱敏(列表 / 详情 / 新建 / PATCH 四个出口都套)。 ⚠️ **不能改 `Person::all()` 本身**:网页端确实在用(ContactImporter 靠 phone_hash 去重、PersonQuery 靠 phone_encrypted 按手机号搜) ⇒ 网页要、App 不要 ⇒ 差异落在出口层。 线上验证(全绿):GET login→405 / 缺参→422 / 错密码→401「手机号或密码不对」 / 不存在的手机号→**同样的** 401 与提示 / 连续错→第 6 次起 429 / 名册 800 人 / 标签云 15 个 / 称谓正确(爸爸→父亲…)/ 手机号解密正确 / 生日阴阳历 / 首字母 ypzbb / 6 个敏感字段全去除(列表与详情都验)。 --- app/ApiHandler.php | 361 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 357 insertions(+), 4 deletions(-) diff --git a/app/ApiHandler.php b/app/ApiHandler.php index f65aa8e..18568a2 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -38,6 +38,14 @@ final class ApiHandler 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; @@ -50,11 +58,249 @@ final class ApiHandler 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, + ]); + } + /* ═══════════════════════════════════════════════════════════════ 我 / 令牌 / 首页 ═══════════════════════════════════════════════════════════════ */ @@ -143,7 +389,7 @@ final class ApiHandler // 只认本家的(跨家 id 直接当不存在,不泄露"别人家有没有这个人") if (!$p || (int)$p['family_id'] !== $fam) { Api::notFound('这个人'); } if ($method === 'GET') { - Api::ok($p); + Api::ok(self::stripSensitivePerson($p)); } if ($method === 'PATCH' || $method === 'PUT') { Api::requireWrite(); @@ -154,7 +400,7 @@ final class ApiHandler 'note' => Api::str('note', (string)($p['note'] ?? '')), 'phone' => Api::str('phone', (string)($p['phone'] ?? ''), 40), ]); - Api::ok(Person::find($id)); + Api::ok(self::stripSensitivePerson((array)Person::find($id))); } if ($method === 'DELETE') { Api::requireWrite(); @@ -175,18 +421,125 @@ final class ApiHandler 'note' => Api::str('note', ''), 'phone' => Api::str('phone', '', 40), ]); - Api::ok(Person::find($newId), 201); + 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); - Api::list(array_values($list)); + // ⚠️ 逐行脱敏(见 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();