diff --git a/app/Api.php b/app/Api.php index 29ed334..0618d3b 100644 --- a/app/Api.php +++ b/app/Api.php @@ -109,6 +109,15 @@ final class Api 'person_id' => $personId, 'family_id' => (int)$p['family_id'], 'token_id' => (int)$hit[1], + /* ---- v57:当前这次请求是「哪台设备」发来的 ---- + `devices` 接口靠它标出「这台设备」(不能误踢自己), + `/me` 靠它把"会话还剩多久"回给端上。 + ⚠️ 老令牌(v35 之前签的)没有 device_id,这里会是空串 —— + 下游判断必须容忍空串,不能拿它当"设备不存在"。 */ + 'device_id' => (string)($hit['device_id'] ?? ''), + 'device_name' => (string)($hit['device_name'] ?? ''), + 'expires_at' => isset($hit['expires_at']) && $hit['expires_at'] !== null + ? (string)$hit['expires_at'] : null, 'role' => $role, 'can_write' => $role !== 'guest', ]; diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 18568a2..fb95e06 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -24,17 +24,33 @@ final class ApiHandler */ public function route(string $res, int $id, string $sub, string $method): void { - // 健康检查 / 版本探测:**不需要令牌**(App 启动时先探这个) + // 健康检查 / 版本探测:**不需要令牌**(App 首次填「服务器地址」时就打它) if ($res === 'ping' || $res === '') { Api::ok([ - 'service' => 'familybook', - 'api' => 'v1', - 'time' => date('c'), + '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, + 'home_memories' => 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; case 'home': $this->home(); return; case 'people': $this->people($id, $sub, $method); return; @@ -73,17 +89,30 @@ final class ApiHandler ═══════════════════════════════════════════════════════════════ */ /** - * `POST /api/v1/login` —— 用手机号 + 密码换一枚 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`); + * 按"登录名 + 来源 IP"计数(见下方 `loginThrottle`); * ③ 失败**不区分**"没这个人"与"密码错" —— 同一句提示, * 否则接口成了"手机号是否已注册"的探测器。 * @@ -97,32 +126,44 @@ final class ApiHandler Api::fail('登录要用 POST。', 'method_not_allowed', 405); } - $phone = Api::str('phone', '', 40); + // `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); - // 令牌有效期(天)。0 = 永不过期(与网页签发令牌的默认行为一致)。 - $days = Api::int('days', 0); + /* ---- 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 ($phone === '' || $password === '') { - Api::fail('手机号和密码都得填。', 'unprocessable', 422); + if ($account === '' || $password === '') { + Api::fail('账号(手机号或用户名)和密码都得填。', 'unprocessable', 422); } // ---- 限流:失败次数有成本(防爆破)---- - $gate = self::loginThrottle($phone); + $gate = self::loginThrottle($account); 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(); + $row = self::findLoginAccount($account); // ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。 // 分开回话就等于告诉对方"这个手机号存在/不存在"。 @@ -130,23 +171,32 @@ final class ApiHandler && (string)$row['status'] === 'active' && password_verify($password, (string)$row['password_hash']); if (!$ok) { - self::loginThrottleFail($phone); + self::loginThrottleFail($account); // 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本) usleep(300000); - Api::fail('手机号或密码不对。', 'unauthorized', 401); + Api::fail('账号或密码不对。', 'unauthorized', 401); } - self::loginThrottleClear($phone); + self::loginThrottleClear($account); $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); + /* ⚠️ 设备会话**一律 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 + ); } - $token = ApiToken::issue($personId, $label, $expiresAt); Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?') ->execute([(int)$row['account_id']]); @@ -158,9 +208,23 @@ final class ApiHandler 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()); + } + Api::ok([ 'token' => $token, - 'expires_at' => $expiresAt, + // 到期时间由**服务端**算(端上只拿来显示,绝不据此判断"还能不能用") + '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'], @@ -168,9 +232,65 @@ final class ApiHandler 'role' => (string)$row['role'], 'family_id' => $familyId, ], + 'family' => ['id' => $familyId, 'name' => $familyName], + // 回显设备,端上可以核对"服务器记住的是不是这台" + 'device' => [ + 'id' => $deviceId, + 'name' => $deviceName, + 'max_devices'=> ApiToken::MAX_DEVICES, + ], ]); } + /** + * 按「手机号 或 用户名」找人(登录用)。 + * + * 🔴 两步查找,顺序**不能反**: + * ① **手机号**:`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。 ⚠️ 为什么不用数据库表:这只是一个"短期计数器", @@ -265,6 +385,10 @@ final class ApiHandler $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); // 标签 @@ -309,17 +433,40 @@ final class ApiHandler { [$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' => Auth::roleWord(), + 'role' => (string)($act['role'] ?? 'member'), + 'can_write'=> (bool)($act['can_write'] ?? false), 'family' => (function () use ($fam): array { $f = Family::find($fam) ?: []; return ['id' => $fam, 'name' => (string)($f['name'] ?? '')]; })(), // 各模块主题色: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'), ]); } @@ -333,6 +480,74 @@ final class ApiHandler } /** 令牌自助管理(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(); @@ -349,27 +564,136 @@ final class ApiHandler 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) { - if (Task::isOverdue($t['due_at'] ?? null)) { $tasks[] = $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); + + // ---- 天气(三级回落:个人经纬度 → 内置城市表 → 家庭级兜底 → 不显示)---- + $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), - count(array_filter($tasks, static fn($t) => Task::isOverdue($t['due_at'] ?? null)))), + 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'], 'today_events' => $todayEvents, 'tasks' => $tasks, 'unread' => Notification::unreadCount($pid), @@ -388,19 +712,32 @@ final class ApiHandler $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::stripSensitivePerson($p)); + Api::ok(self::personDetail($p, $pid)); } 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))); + self::applyPersonWrite($id, $p, $fam, $pid); + Api::ok(self::personDetail((array)Person::find($id), $pid)); } if ($method === 'DELETE') { Api::requireWrite(); @@ -414,14 +751,18 @@ final class ApiHandler 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); + $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 @@ -434,6 +775,271 @@ final class ApiHandler 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 的字段**去掉(就地改,返回同一个数组)。 * diff --git a/app/ApiToken.php b/app/ApiToken.php index bec6d47..a35e5ef 100644 --- a/app/ApiToken.php +++ b/app/ApiToken.php @@ -15,6 +15,29 @@ * ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。 * 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。 * ⇒ 网页端把明文塞进一次性 session 槽位、显示一次;App 端它自己存 Keychain。 + * + * ===================== v57:一把令牌 = 一次「设备会话」(2026-10-17)===================== + * + * 用户原话:「在服务器端的个人设置那里面,就有一个这个设备管理,就是说我都有哪些 + * 设备已经在登录这个东西。」「同一个设备退出登录,它这个令牌马上就失效; + * 一旦重新登录之后,再给它分配一个新的令牌。」 + * + * ⇒ 令牌从此**必须挂在 `device_id` 上**(App 首次启动自生成的 UUID,不是硬件号 —— + * HarmonyOS 三方应用拿不到 UDID)。同 `device_id` 重新登录 = 旧令牌立即删、发新的。 + * + * **24 小时是「滑动窗口」而不是「硬过期」**(用户 2026-10-17 明确): + * 每次带着有效令牌的请求都会把 `expires_at` 往后推到「现在 + 24h」。 + * ⇒ 天天开 App 的人**永远不会被要求重新登录**; + * 而丢了的手机只要被网页端踢掉,它的下一次请求就是 401。 + * ⚠️ 为什么续期要有阈值(`RENEW_BELOW_HOURS`):否则**每个请求都要写一次库** + * (同步时几秒内几十个请求),纯属浪费;剩余寿命还够时只更新活跃时间。 + * ⚠️ 为什么"离线超过 24h 就锁"这条判定不在服务端做: + * 离线时根本没有请求,服务端无从判断。它由**端上**按"上次校验成功的时间"判, + * 而那个时间用的是**服务端返回的时间**,不是本机时钟(本机时钟可以改)。 + * + * **踢设备 = 硬删**(不是打个吊销标记): + * 令牌哈希一并抹掉,库里不留可用的凭据;设备列表里也立刻消失。 + * 审计需求不存在(用户要的是"这台机器再也进不来",不是"记录它曾经来过")。 */ declare(strict_types=1); @@ -25,29 +48,145 @@ final class ApiToken /** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */ public const PREFIX = 'fb_'; + /** 设备会话的有效期(小时)。用户 2026-10-17:24 小时,滑动续期 */ + public const TTL_HOURS = 24; + + /** 剩余寿命低于这个小时数才续期(避免每个请求都写库,见文件头说明) */ + private const RENEW_BELOW_HOURS = 4; + /** - * 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。 + * 每个账号最多几台设备。 * - * ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集: - * 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。 - * 32 字节 → base64url 43 字符 → 约 256 bit 熵。 + * 用户 2026-10-17 同意:「每账号最多 10 台,超了让用户先踢旧的。」 + * ⚠️ 不设上限的话,一个脚本反复登录就能把 `api_tokens` 灌满, + * 而且设备列表会变得没法看。 + */ + public const MAX_DEVICES = 10; + + /** + * 签发一枚**手动令牌**(网页端「签发 API 令牌」入口,v44 的老路径)。 + * + * ⚠️ 这条路径**不挂设备**语义上也要有个归属:网页上生成了令牌,用户可能 + * 粘贴到任何地方(第三方脚本、curl)。它仍然进设备列表(叫 label, + * 没有就显示"手动签发的令牌"),也照样能被踢掉 —— 否则它就是一个 + * **用户看不见、也踢不掉的幽灵会话**(v35 迁移回填的就是这类东西)。 + * + * @param string $label 设备名(用户自己填) + * @param string|null $expiresAt 到期时间;NULL = 不过期(与老行为一致) */ public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string { $plain = self::PREFIX . self::b64url(random_bytes(32)); - Database::get()->prepare( - 'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)' - )->execute([ + self::insert( $personId, hash('sha256', $plain), $label !== '' ? mb_substr($label, 0, 60) : null, - $expiresAt !== '' ? $expiresAt : null, - ]); + 'manual-' . self::b64url(random_bytes(6)), + $label !== '' ? mb_substr($label, 0, 60) : '手动签发的令牌', + 'manual', + '', + ($expiresAt !== null && $expiresAt !== '') ? $expiresAt : null + ); return $plain; } /** - * 校验一枚令牌,成功返回 [personId, tokenId],失败 null。 + * 签发一枚**设备会话令牌**(App 登录用)。 + * + * 用户 2026-10-17:「同一个设备退出登录,它这个令牌马上就失效;它一旦登录、 + * 重新登录之后,再给它分配一个新的令牌。」 + * ⇒ 先按 `$deviceId` 删掉同一台设备的旧令牌,再签新的。**同一台设备永远只有一把**。 + * + * @param string $deviceId App 自生成的 UUID(空串会被兜底成一个随机设备号) + * @param string $deviceName 设备名(机型,用户可改) + * @param string $platform `harmonyos 5.0.1` 这种 + * @param string $appVersion App 版本号 + * @param int $ttlHours 有效期(小时);0 = 不过期(仅测试/特殊场景用) + * + * @return string|null 明文令牌;**null = 设备数量已达上限**(调用方回 409) + */ + public static function issueDevice( + int $personId, + string $deviceId, + string $deviceName = '', + string $platform = '', + string $appVersion = '', + int $ttlHours = self::TTL_HOURS + ): ?string { + $db = Database::get(); + $deviceId = trim($deviceId); + if ($deviceId === '') { + /* 老客户端(v57 之前)不带设备号。 + ⚠️ **不能生成随机设备号**:那样它每登录一次就等于"一台新设备", + 十几次就把 `MAX_DEVICES` 撞满,账号被自己锁死。 + ⇒ 固定成一个共享槽位 "auto-unknown":老客户端共用一条会话, + 互相顶掉(它们本来也不区分设备),但永远不会撑爆上限。 + 设备列表里它显示成「未知设备」,照样能踢。 */ + $deviceId = 'auto-unknown'; + } + $deviceId = mb_substr($deviceId, 0, 64); + + // ① 先算"这台设备在不在这台账号的设备表里",以及一共几台。 + // ⚠️ 顺序很重要:**必须先判上限、再删旧令牌**。 + // 反过来的话,第 11 台设备登录 → 旧令牌已被删 → 才发现超限 → + // 用户手里这台机器直接变未登录,而服务端什么都没给他。 + $st = $db->prepare( + "SELECT COUNT(DISTINCT COALESCE(NULLIF(device_id, ''), CONCAT('legacy-', id))) + FROM api_tokens WHERE person_id = ?" + ); + $st->execute([$personId]); + $deviceCount = (int)$st->fetchColumn(); + + $st = $db->prepare('SELECT COUNT(*) FROM api_tokens WHERE person_id = ? AND device_id = ?'); + $st->execute([$personId, $deviceId]); + $isKnownDevice = (int)$st->fetchColumn() > 0; + + // ② 新设备且已达上限 → 拒绝,**不动任何一行** + if (!$isKnownDevice && $deviceCount >= self::MAX_DEVICES) { + return null; + } + + // ③ 同一台设备重登:旧令牌立即作废(用户明确要求) + $db->prepare('DELETE FROM api_tokens WHERE person_id = ? AND device_id = ?') + ->execute([$personId, $deviceId]); + + $plain = self::PREFIX . self::b64url(random_bytes(32)); + self::insert( + $personId, + hash('sha256', $plain), + $deviceName !== '' ? mb_substr($deviceName, 0, 60) : null, + $deviceId, + $deviceName !== '' ? mb_substr($deviceName, 0, 60) : '未命名设备', + $platform !== '' ? mb_substr($platform, 0, 30) : '', + $appVersion !== '' ? mb_substr($appVersion, 0, 30) : '', + $ttlHours > 0 ? date('Y-m-d H:i:s', time() + $ttlHours * 3600) : null + ); + return $plain; + } + + /** 落一行令牌(`issue` / `issueDevice` 共用) */ + private static function insert( + int $personId, + string $tokenHash, + ?string $label, + string $deviceId, + string $deviceName, + string $platform, + string $appVersion, + ?string $expiresAt + ): void { + Database::get()->prepare( + 'INSERT INTO api_tokens + (person_id, token_hash, label, device_id, device_name, platform, app_version, + last_used_at, expires_at) + VALUES (?,?,?,?,?,?,?,NOW(),?)' + )->execute([ + $personId, $tokenHash, $label, $deviceId, $deviceName, $platform, $appVersion, $expiresAt, + ]); + } + + /** + * 校验一枚令牌,成功返回人员/设备信息,失败 null。 * * ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史, * 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选; @@ -55,6 +194,10 @@ final class ApiToken * ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。 * ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误, * 避免给爆破者提供信息;具体原因网页端能看到)。 + * + * 返回值**同时保留数字键**(`[0]`=人员 id、`[1]`=令牌 id): + * `Api::actor()` / `api.php` / `media.php` 三处历史上都用 `$hit[0]`、`$hit[1]`, + * 改成纯关联数组会静默把它们打坏。 */ public static function verify(string $plain): ?array { @@ -62,8 +205,10 @@ final class ApiToken if ($plain === '' || strpos($plain, self::PREFIX) !== 0) { return null; } - $st = Database::get()->prepare( - 'SELECT t.id, t.person_id, a.status + $db = Database::get(); + $st = $db->prepare( + 'SELECT t.id, t.person_id, t.device_id, t.device_name, t.platform, t.app_version, + t.expires_at, a.status FROM api_tokens t JOIN accounts a ON a.person_id = t.person_id WHERE t.token_hash = ? @@ -75,28 +220,158 @@ final class ApiToken if (!$row || (string)$row['status'] !== 'active') { return null; } - // 记一次使用时间(不给它加索引/不加事务:这是"顺带的统计",不值得拖慢鉴权) + + $tokenId = (int)$row['id']; + $personId = (int)$row['person_id']; + $expiresAt = $row['expires_at'] !== null ? (string)$row['expires_at'] : null; + $newExpiresAt = $expiresAt; + + /* 记活跃 + 滑动续期。 + ⚠️ 整段包在 try 里:**记录失败不该拦住这次请求**(这条是"顺带的统计", + 与鉴权本身无关;写不进去最多是设备列表里"最近活跃"停住)。 */ try { - Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?') - ->execute([(int)$row['id']]); + $ip = self::clientIp(); + if ($expiresAt !== null && !self::renewDue($expiresAt)) { + $db->prepare( + 'UPDATE api_tokens SET last_used_at = NOW(), last_ip = ? WHERE id = ?' + )->execute([$ip, $tokenId]); + } else { + // 剩余不到 4 小时 → 续到"现在 + 24h"(滑动窗口) + $newExpiresAt = date('Y-m-d H:i:s', time() + self::TTL_HOURS * 3600); + $db->prepare( + 'UPDATE api_tokens + SET last_used_at = NOW(), last_ip = ?, expires_at = ? + WHERE id = ?' + )->execute([$ip, $newExpiresAt, $tokenId]); + } } catch (\Throwable $e) { - // 记不上不该拦住这次请求 + // 见上:记不上就记不上 } - return [(int)$row['person_id'], (int)$row['id']]; + + return [ + 0 => $personId, + 1 => $tokenId, + 'person_id' => $personId, + 'token_id' => $tokenId, + 'device_id' => (string)($row['device_id'] ?? ''), + 'device_name' => (string)($row['device_name'] ?? ''), + 'platform' => (string)($row['platform'] ?? ''), + 'app_version' => (string)($row['app_version'] ?? ''), + 'expires_at' => $newExpiresAt, + ]; } - /** 某个人的令牌清单(**不含明文**,只给管理界面看) */ + /** + * 该账号的**设备列表**(按设备聚合,每台只出最新的一行)。 + * + * 用户 2026-10-17:「我都有哪些设备已经在登录这个东西」—— + * 所以要的是"设备",不是"令牌":同一台设备重登过 5 次,列表里也只该有 1 行。 + * + * @param int $currentTokenId 当前请求所用令牌的 id(用来标「这台设备」) + * @param string $currentDeviceId 当前请求的设备号 + */ + public static function listDevices(int $personId, int $currentTokenId = 0, string $currentDeviceId = ''): array + { + $st = Database::get()->prepare( + "SELECT t.id, t.device_id, t.device_name, t.label, t.platform, t.app_version, + t.created_at, t.last_used_at, t.last_ip, t.expires_at + FROM api_tokens t + JOIN ( + SELECT COALESCE(NULLIF(device_id, ''), CONCAT('legacy-', id)) AS dk, + MAX(id) AS mid + FROM api_tokens + WHERE person_id = ? + GROUP BY dk + ) x ON x.mid = t.id + WHERE t.person_id = ? + ORDER BY COALESCE(t.last_used_at, t.created_at) DESC, t.id DESC" + ); + $st->execute([$personId, $personId]); + $rows = $st->fetchAll(); + $out = []; + foreach ($rows as $r) { + $deviceId = (string)($r['device_id'] ?? ''); + $name = (string)($r['device_name'] ?? ''); + if ($name === '') { + $name = (string)($r['label'] ?? ''); + } + $out[] = [ + 'id' => (int)$r['id'], + 'device_id' => $deviceId, + 'device_name' => $name !== '' ? $name : '未知设备', + 'platform' => (string)($r['platform'] ?? ''), + 'app_version' => (string)($r['app_version'] ?? ''), + 'created_at' => (string)($r['created_at'] ?? ''), + 'last_seen_at'=> (string)($r['last_used_at'] ?? ''), + 'last_ip' => (string)($r['last_ip'] ?? ''), + 'expires_at' => $r['expires_at'] !== null ? (string)$r['expires_at'] : null, + // 当前设备:优先比 device_id,拿不到就退回比令牌 id + 'is_current' => ($currentDeviceId !== '' && $deviceId === $currentDeviceId) + || ($currentTokenId > 0 && (int)$r['id'] === $currentTokenId), + ]; + } + return $out; + } + + /** + * 踢掉一台设备(硬删,见文件头)。 + * + * @return int 实际删掉的行数(一台设备可能连着多行,正常是 1) + */ + public static function revokeDevice(int $personId, string $deviceId): int + { + $deviceId = trim($deviceId); + if ($deviceId === '') { + return 0; + } + $st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ? AND device_id = ?'); + $st->execute([$personId, $deviceId]); + return $st->rowCount(); + } + + /** + * 踢掉**除当前设备以外**的全部设备("我的其它设备都退掉")。 + * + * @param string $keepDeviceId 保留哪台(当前设备的 device_id) + */ + public static function revokeOthers(int $personId, string $keepDeviceId, int $keepTokenId = 0): int + { + $db = Database::get(); + $keepDeviceId = trim($keepDeviceId); + if ($keepDeviceId !== '') { + $st = $db->prepare('DELETE FROM api_tokens WHERE person_id = ? AND device_id <> ?'); + $st->execute([$personId, $keepDeviceId]); + return $st->rowCount(); + } + // 拿不到设备号(老客户端)→ 退回按令牌 id 保留 + if ($keepTokenId > 0) { + $st = $db->prepare('DELETE FROM api_tokens WHERE person_id = ? AND id <> ?'); + $st->execute([$personId, $keepTokenId]); + return $st->rowCount(); + } + return 0; + } + + /** 吊销当前这一枚(App「退出登录」用;服务端尽力而为,见 ApiHandler::logout 的说明) */ + public static function revokeCurrent(int $personId, int $tokenId): bool + { + $st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?'); + $st->execute([$tokenId, $personId]); + return $st->rowCount() > 0; + } + + /** 某个人的令牌清单(**不含明文**,只给管理界面看;v44 的老接口) */ public static function listFor(int $personId): array { $st = Database::get()->prepare( - 'SELECT id, label, created_at, last_used_at, expires_at + 'SELECT id, label, device_id, device_name, created_at, last_used_at, expires_at FROM api_tokens WHERE person_id = ? ORDER BY id DESC' ); $st->execute([$personId]); return $st->fetchAll(); } - /** 吊销一枚(限自己的) */ + /** 吊销一枚(限自己的;v44 的老接口) */ public static function revoke(int $personId, int $id): bool { $st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?'); @@ -104,7 +379,7 @@ final class ApiToken return $st->rowCount() > 0; } - /** 吊销自己的全部("手机丢了"时的应急动作) */ + /** 吊销自己的全部("手机丢了"时的应急动作;v44 的老接口) */ public static function revokeAll(int $personId): int { $st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?'); @@ -112,8 +387,32 @@ final class ApiToken return $st->rowCount(); } + /** 剩余寿命是否已经短到该续期了(见文件头 RENEW_BELOW_HOURS 的说明) */ + private static function renewDue(string $expiresAt): bool + { + $ts = strtotime($expiresAt); + if ($ts === false) { + return false; + } + return ($ts - time()) < self::RENEW_BELOW_HOURS * 3600; + } + + /** + * 客户端 IP。 + * + * ⚠️ **只认 `REMOTE_ADDR`**,不解析 `X-Forwarded-For`: + * 本站是 Apache 直接对外(没有反代/CDN),XFF 是客户端可以随便写的 —— + * 拿它当真实 IP 等于让踢人的界面显示攻击者编的地址。 + * 将来真加了 CDN,再在这里显式白名单代理 IP 之后才可信。 + */ + private static function clientIp(): string + { + $ip = (string)($_SERVER['REMOTE_ADDR'] ?? ''); + return mb_substr($ip, 0, 45); + } + private static function b64url(string $raw): string { return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } -} \ No newline at end of file +} diff --git a/app/Home.php b/app/Home.php index ed2dc6d..f222651 100644 --- a/app/Home.php +++ b/app/Home.php @@ -405,6 +405,87 @@ final class Home return (string)$r; } + /** + * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 + * + * 🔴 v54(用户 2026-10-16)—— 从 `index.php` 的 `home_memories()` **搬进来**。 + * 用户原话:「近期任务的下面,应该加上**往年的今天**,跟电脑端一样, + * 你看看那个是什么逻辑。」 + * 搬的理由:App 端首页也要这一块,而 App 走 `/api/v1/home`; + * **两端必须是同一份实现**,否则会出现"网页说 2 年前、手机说 3 年前" + * (本项目在农历/节气上已经吃过一次这个亏,见 `ApiHandler::home()` 的注释)。 + * `index.php` 里那个同名函数现在只是一层转发。 + * + * ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**, + * 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**: + * "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事, + * 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。 + * 现在只认同一天;没命中就明说"往年的这一天还没有记录"。 + * + * ⚠️ 旅程只看 `trip_nodes.stage='actual'`(**实际行程**),不是整趟旅程: + * 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」, + * 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。 + * 用 `n.starts_at` 而不是 `events.starts_at` —— 实际行程的时间才是"那天真的走了"。 + * + * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容) + */ + public static function memories(int $familyId, int $limit = 6): array + { + $today = date('Y-m-d'); + $year = (int)date('Y'); + $md = substr($today, 5, 5); + $rows = []; + + // ① 观影(events kind=movie) + $sql = "SELECT e.id, e.title, e.starts_at, e.kind, + (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n + FROM events e + WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL + AND YEAR(e.starts_at) < ? + AND DATE_FORMAT(e.starts_at, '%m-%d') = ? + ORDER BY e.starts_at DESC LIMIT " . (int)$limit; + $st = Database::get()->prepare($sql); + $st->execute([$familyId, $year, $md]); + foreach ($st->fetchAll() as $r) { + $rows[] = $r; + } + + // ② 旅程的**实际行程**节点 + $sql2 = "SELECT n.id, n.title, n.starts_at, 'trip_node' AS kind, + (SELECT COUNT(*) FROM trip_node_members m WHERE m.node_id = n.id) AS n, + e.id AS trip_id, e.title AS trip_title, n.node_type + FROM trip_nodes n + JOIN events e ON e.id = n.event_id AND e.kind = 'trip' + LEFT JOIN trip_details d ON d.event_id = e.id + WHERE n.family_id = ? AND n.stage = 'actual' AND n.starts_at IS NOT NULL + AND COALESCE(d.is_public, 1) = 1 + AND YEAR(n.starts_at) < ? + AND DATE_FORMAT(n.starts_at, '%m-%d') = ? + ORDER BY n.starts_at DESC LIMIT " . (int)$limit; + $st2 = Database::get()->prepare($sql2); + $st2->execute([$familyId, $year, $md]); + foreach ($st2->fetchAll() as $r) { + // 标题带上"是哪趟旅程的哪一段",否则只看到一个"栈桥看海"不知道是何时的 + $r['title'] = (string)$r['trip_title'] . ' · ' . (string)$r['title']; + $rows[] = $r; + } + + // 合并后按时间倒序,取前 N + usort($rows, static fn(array $a, array $b): int => strcmp((string)$b['starts_at'], (string)$a['starts_at'])); + $rows = array_slice($rows, 0, $limit); + + foreach ($rows as &$r) { + $r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4); + if ($r['kind'] === 'trip_node') { + $r['href'] = \url('trips/show', ['id' => (int)$r['trip_id']]) . '#node-' . (int)$r['id']; + } else { + $r['href'] = \url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]); + } + } + unset($r); + return ['rows' => $rows, 'exact' => true]; + } + /** * 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。 * diff --git a/app/PersonQuery.php b/app/PersonQuery.php index f1a126e..50d6f06 100644 --- a/app/PersonQuery.php +++ b/app/PersonQuery.php @@ -94,6 +94,57 @@ final class PersonQuery } } + /** + * 一个人的**全部电话号码**(主手机号在前,补充电话在后)。 + * + * 🔴 为什么需要它(2026-10-15,App 端"点人名直接拨号"): + * 服务端其实**支持多号码**(`person_contacts` 表存"工作号 / 老人机 / 家里座机"), + * 但 `/roster` 只下发主手机号 ⇒ App 没法知道还有别的号可打。 + * ⇒ 这里把补充电话一起解出来,**主号排第一**(App 的规则是"有主号打主号")。 + * + * ⚠️ 返回的是**明文**(`person_contacts.value` 里 kind=phone 是密文,这里解密)。 + * 调用方要注意:这是敏感信息,只在**名册**这类"本人可见自己名册"的场景下发, + * 不要塞进公开列表(与 `stripSensitivePerson` 的取舍一致)。 + * ⚠️ 空值一律不返回;两个都空 ⇒ 返回空数组(App 端据此走"打开详情"而不是拨号)。 + * ⚠️ **不打码**:打码(`maskPhone`)是给"共享给别人的页面"看的; + * App 名册是本人看自己的名册,打码会导致拨不出去。 + * + * @param array $row persons 行(需含 id 与 phone_encrypted) + * @return string[] 明文号码,主号在前;可能为空 + */ + public static function phonesOf(array $row): array + { + $out = []; + $main = self::phonePlain($row); + if ($main !== '') { + $out[] = $main; + } + $id = (int)($row['id'] ?? 0); + if ($id <= 0) { + return $out; + } + try { + $contacts = Person::contactsOf($id); + foreach (($contacts['phone'] ?? []) as $c) { + $enc = (string)($c['value'] ?? ''); + if ($enc === '') { + continue; + } + try { + $plain = (string)Crypto::decrypt($enc); + } catch (\Throwable $ex) { + continue; + } + if ($plain !== '' && !in_array($plain, $out, true)) { + $out[] = $plain; + } + } + } catch (\Throwable $ex) { + // 取补充电话失败**不影响**主号:至少还能打主号。 + } + return $out; + } + /** * 给列表用的行补上派生字段。幂等(已有值就不覆盖)。 * diff --git a/app/Schema.php b/app/Schema.php index 777d6b7..702d5d0 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -166,6 +166,21 @@ final class Schema * ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工: * 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides * (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。 + * 35 → **设备会话(v57,用户 2026-10-17)**:`api_tokens` 从"一把令牌"升级成 + * "一台设备一个会话"。加 `device_id`(App 自生成 UUID,**不是**硬件号 —— + * HarmonyOS 三方应用拿不到 UDID)/ `device_name` / `platform` / + * `app_version` / `last_ip`,并加 `idx_api_device (person_id, device_id)`。 + * 动机(用户原话):「在服务器端的个人设置那里面,就有一个这个设备管理, + * 就是说我都有哪些设备已经在登录这个东西」。 + * ⚠️ 为什么必须挂 `device_id` 而不是只挂 `label`:`label` 是**人填的文本**, + * 两台设备可以填同一个名字;"踢掉丢了的那台"要求**精确到机器**。 + * ⚠️ 历史令牌没有设备号 → 回填 `legacy-<令牌 id>`。不回填的话,升级后 + * 旧令牌在设备列表里根本看不见 —— 等于一个**踢不掉的幽灵会话**。 + * ⚠️ **故意不加** `revoked_at`:踢设备是**硬删整行**(`DELETE FROM api_tokens`), + * 不留"已吊销但还在"的死行 —— 那类行要每个查询都多判一次"还算不算数", + * 漏一处就等于"被踢的设备还能用"。审计需求在本项目不存在(家人内部使用)。 + * ⚠️ **复用**既有的 `last_used_at` 当"最近活跃",不另立 `last_seen_at` —— + * 两个语义相同的列并存,迟早会出现"一个更新了另一个没更新"的假活跃。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -173,7 +188,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '34'; + public const VERSION = '35'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -295,12 +310,18 @@ CREATE TABLE IF NOT EXISTS `api_tokens` ( `person_id` INT UNSIGNED NOT NULL, `token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库', `label` VARCHAR(60) DEFAULT NULL COMMENT '设备名(用户自己填,如「我的手机」)', + `device_id` VARCHAR(64) DEFAULT NULL COMMENT '设备标识(App 首次启动自生成的 UUID;不是硬件号)', + `device_name` VARCHAR(60) DEFAULT NULL COMMENT '设备名(机型 + 用户可改,如「HUAWEI Mate 40」)', + `platform` VARCHAR(30) DEFAULT NULL COMMENT '平台与系统版本(如 harmonyos 5.0.1)', + `app_version` VARCHAR(30) DEFAULT NULL COMMENT 'App 版本(如 1.0.0)', + `last_ip` VARCHAR(45) DEFAULT NULL COMMENT '最近一次请求的 IP(IPv6 最长 45)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_used_at` DATETIME DEFAULT NULL, - `expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期', + `expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期(设备会话一律 24h 滑动)', PRIMARY KEY (`id`), UNIQUE KEY `uk_api_token` (`token_hash`), KEY `idx_api_person` (`person_id`), + KEY `idx_api_device` (`person_id`, `device_id`), CONSTRAINT `fk_api_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='移动端 / 第三方调用的 API 令牌(与 DAV 口令、登录口令分开)' @@ -2267,6 +2288,56 @@ SQL); } }); + /* ---- v35(v57,用户 2026-10-17):API 令牌挂到「设备」上 ---- + + 用户原话:「在服务器端的个人设置那里面,就有一个设备管理, + 就是说我都有哪些设备已经在登录这个东西。」 + ⇒ 一把令牌就是**一次设备会话**,得能按机器踢、能显示机器是谁。 + + ⚠️ 必须排在 statements() 之后(那里才是 `api_tokens` 的 CREATE TABLE)—— + 记忆里的红线:ALTER TABLE 排在同表 CREATE TABLE 之前,全新安装会 1146。 + ⚠️ `label` 保留不动:网页端「手动签发令牌」那条老路径还在用它, + 而且是用户自己填的文本(设备管理里显示 `device_name`,两者不是一回事)。 */ + self::addColumn($db, 'api_tokens', 'device_id', + "ADD COLUMN `device_id` VARCHAR(64) DEFAULT NULL COMMENT '设备标识(App 首次启动自生成 UUID,不是硬件号)' AFTER `label`"); + self::addColumn($db, 'api_tokens', 'device_name', + "ADD COLUMN `device_name` VARCHAR(60) DEFAULT NULL COMMENT '设备名(机型 + 用户可改)' AFTER `device_id`"); + self::addColumn($db, 'api_tokens', 'platform', + "ADD COLUMN `platform` VARCHAR(30) DEFAULT NULL COMMENT '平台与系统版本(如 harmonyos 5.0.1)' AFTER `device_name`"); + self::addColumn($db, 'api_tokens', 'app_version', + "ADD COLUMN `app_version` VARCHAR(30) DEFAULT NULL COMMENT 'App 版本' AFTER `platform`"); + self::addColumn($db, 'api_tokens', 'last_ip', + "ADD COLUMN `last_ip` VARCHAR(45) DEFAULT NULL COMMENT '最近一次请求的 IP(IPv6 最长 45)' AFTER `app_version`"); + + // 设备索引:设备列表与"按设备踢"都是 `WHERE person_id = ? AND device_id = ?` + try { + $hasDeviceIdx = (int)$db->query( + "SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'api_tokens' + AND INDEX_NAME = 'idx_api_device'" + )->fetchColumn(); + if ($hasDeviceIdx === 0) { + $db->exec('ALTER TABLE `api_tokens` ADD INDEX `idx_api_device` (`person_id`, `device_id`)'); + } + } catch (\Throwable $e) { + // 索引是优化项,加不上不该挡住"能按设备踢"这件事本身 + } + + /* 历史令牌回填设备号。 + ⚠️ 为什么必须回填:v35 之前的令牌只有 `label`,没有 `device_id`。 + 不回填的话它们在设备列表里**根本查不出来** —— 用户在网页端看不到、 + 也就踢不掉,等于一个"踢不掉的幽灵会话"。回填成 `legacy-` 后 + 它就是一个正常的、可踢的设备条目。 + ⚠️ 幂等:条件只挑 `device_id` 为空的行,跑完自然不再命中。 */ + self::tryStep('历史令牌回填设备号', static function () use ($db) { + $db->exec( + "UPDATE `api_tokens` + SET `device_id` = CONCAT('legacy-', `id`), + `device_name` = COALESCE(NULLIF(`device_name`, ''), NULLIF(`label`, ''), '旧设备') + WHERE `device_id` IS NULL OR `device_id` = ''" + ); + }); + /* 🔴 v51(用户 2026-10-15 第 5 条):把**已入库**的带分隔符手机号归一成纯数字。 用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字, 中间有两个空格……看起来还有不少这样的数据呢,都提示我数据不对。」 diff --git a/index.php b/index.php index 19de82c..5b8a6d2 100644 --- a/index.php +++ b/index.php @@ -319,6 +319,15 @@ function render_me_settings(array $person, array $errors = []): void 'apiPlain' => (string)($_SESSION['api_plain'] ?? ''), 'apiLabel' => (string)($_SESSION['api_plain_label'] ?? ''), 'apiBase' => rtrim((string)fb_config('base_url', ''), '/'), + /* 🔴 v57(用户 2026-10-17):「在个人设置里面有这个设备管理」。 + ⚠️ 这里传的是**按设备聚合**的列表(`listDevices`),不是令牌清单 —— + 同一台手机重登过 5 次,网页上只该出现 1 行。 + 与上面 `apiTokens`(原始令牌行、只给"手动签发/吊销"用)不是一回事。 */ + 'devices' => \App\ApiToken::listDevices($me), + 'deviceMax' => \App\ApiToken::MAX_DEVICES, + 'deviceTtl' => \App\ApiToken::TTL_HOURS, + // 网页这边不知道"哪台是当前设备"(发请求的是浏览器,不是 App) + // ⇒ 页面不标"当前设备",只标"App 设备"。 ], 'settings'); // ⚠️ 取完即清:明文令牌只该被看到一次(刷新页面就没了)。 // 放在 View::render **之后** —— 早了就拿不到值了。 @@ -646,72 +655,23 @@ function read_due_input(): ?string /** * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 * - * ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**, - * 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**: - * "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事, - * 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。 - * 现在只认同一天;没命中就明说"往年的这一天还没有记录"。 + * 🔴 v54(用户 2026-10-16):**实现已搬到 `App\Home::memories()`**, + * 这里只做一次转发。 + * 为什么搬:App 端首页也要显示「往年的今天」(用户原话 + * 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样」), + * 而 App 走的是 `/api/v1/home`。**两端必须同一份实现** —— + * 否则会出现"网页说 2 年前、手机说 3 年前"这种对不上的事 + * (本项目在农历/节气上已经吃过一次这个亏)。 + * + * ⚠️ 保留这个函数名而不是把调用点全改掉:模板里 `$memories` 的取用位置、 + * 以及可能的旧书签/脚本都还在按老路径走,删了就静默少一块。 + * 顺便这个包装也让"模板注释里提到的 home_memories()"仍然指得到东西。 * * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容) */ function home_memories(int $familyId, int $limit = 6): array { - $today = date('Y-m-d'); - $year = (int)date('Y'); - $md = substr($today, 5, 5); - $rows = []; - - // ① 观影:照旧(events kind=movie) - $sql = "SELECT e.id, e.title, e.starts_at, e.kind, - (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n - FROM events e - WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL - AND YEAR(e.starts_at) < ? - AND DATE_FORMAT(e.starts_at, '%m-%d') = ? - ORDER BY e.starts_at DESC LIMIT " . (int)$limit; - $st = Database::get()->prepare($sql); - $st->execute([$familyId, $year, $md]); - foreach ($st->fetchAll() as $r) { - $rows[] = $r; - } - - // ② 旅程:改成看**实际行程**(trip_nodes stage='actual'),不是整趟旅程。 - // 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」, - // 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。 - // ⚠️ 用 starts_at 而不是 events.starts_at —— 实际行程的时间才是"那天真的走了"。 - $sql2 = "SELECT n.id, n.title, n.starts_at, 'trip_node' AS kind, - (SELECT COUNT(*) FROM trip_node_members m WHERE m.node_id = n.id) AS n, - e.id AS trip_id, e.title AS trip_title, n.node_type - FROM trip_nodes n - JOIN events e ON e.id = n.event_id AND e.kind = 'trip' - LEFT JOIN trip_details d ON d.event_id = e.id - WHERE n.family_id = ? AND n.stage = 'actual' AND n.starts_at IS NOT NULL - AND COALESCE(d.is_public, 1) = 1 - AND YEAR(n.starts_at) < ? - AND DATE_FORMAT(n.starts_at, '%m-%d') = ? - ORDER BY n.starts_at DESC LIMIT " . (int)$limit; - $st2 = Database::get()->prepare($sql2); - $st2->execute([$familyId, $year, $md]); - foreach ($st2->fetchAll() as $r) { - // 标题带上"是哪趟旅程的哪一段",否则只看到一个"栈桥看海"不知道是何时的 - $r['title'] = (string)$r['trip_title'] . ' · ' . (string)$r['title']; - $rows[] = $r; - } - - // 合并后按时间倒序,取前 N - usort($rows, static fn(array $a, array $b): int => strcmp((string)$b['starts_at'], (string)$a['starts_at'])); - $rows = array_slice($rows, 0, $limit); - - foreach ($rows as &$r) { - $r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4); - if ($r['kind'] === 'trip_node') { - $r['href'] = url('trips/show', ['id' => (int)$r['trip_id']]) . '#node-' . (int)$r['id']; - } else { - $r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]); - } - } - unset($r); - return ['rows' => $rows, 'exact' => true]; + return \App\Home::memories($familyId, $limit); } /** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */ @@ -5016,6 +4976,32 @@ $want = array_keys($want); redirect('settings/me', [], 'api'); break; + /* 🔴 v57(用户 2026-10-17):「设备管理」——把某一台设备远程退掉。 + + 用户原话:「我们就允许我们的用户在个人设置页面,把其中的某一个客户端 + 远程给它退出,相当于它这个 Token 给它删掉。」 + 「这样的话,这个应用一旦打开,就发现这个 Token 已经失效了, + 那么它就返回登录页面了。」 + + ⚠️ 移的是**设备**不是"令牌":同一台手机重登过 5 次,列表里只有 1 行, + 点一次就该把它连着的那几枚全删掉(按 device_id 整组删,见 ApiToken 的 + revokeDevice)。 + ⚠️ 移除后那台设备**下一次请求就是 401**(服务端没有推送通道, + "立刻失效"是下一次联网时生效 —— 网页上这句提示必须说实话)。 */ + case 'settings/device_remove': + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $deviceId = trim((string)($_POST['device_id'] ?? '')); + $n = \App\ApiToken::revokeDevice($me, $deviceId); + if ($n > 0) { + flash('ok', '已移除这台设备。它下次联网时会收到"令牌无效",并退回登录页。'); + } else { + flash('err', '没找到这台设备(可能已经被移除了)。'); + } + redirect('settings/me', [], 'devices'); + break; + /** * 日程「显示设置」独立页(v51 用户 2026-10-15 第 6 条)。 * diff --git a/templates/settings/me.php b/templates/settings/me.php index fe30de2..55edcfb 100644 --- a/templates/settings/me.php +++ b/templates/settings/me.php @@ -160,10 +160,96 @@ $rosterCount = $rosterCount ?? 0; (一台设备一枚)、可逐枚吊销、可设有效期、带设备名。 两件事的生命周期完全不同(吊销 DAV 口令会连带断掉日历同步; 吊销一枚 App 令牌不该影响别人的手机)。 */ ?> + + + +
+

+ 设备管理 + 哪些手机 / 平板正在登录这个账号 +

+

+ 在 App 里每登录一次,这里就多一台设备(最多 台)。 + 令牌有效期 小时, + 只要设备在联网就会自动续期,所以常用的手机不需要反复登录。 +
手机丢了就在这里「移除」—— 那台设备下次联网时会立刻退回登录页, + 本机已下载的数据将无法查看。 +

+ + + + + + + + + + + + + + + + + + + +
设备系统 / 客户端最近活跃登录时间
+ + + (升级前的旧会话) + + (旧版 App) + + + + + +
+ + + +
+ + 只读账号 + +
+ + 1 && \App\Auth::canWrite()): ?> +
+
+ + +
+
+ + +

还没有设备登录过。在 App 里用这个账号登录后,这里就会出现它。

+ +
+

- App 接口令牌 - 移动端 / 第三方程序调用用 + 手动签发接口令牌 + 第三方脚本 / 调试用;App 正常登录**不需要**这里

接口地址前缀 /api/v1/, @@ -218,7 +304,7 @@ $rosterCount = $rosterCount ?? 0; -

还没有令牌。给每台设备发一枚吧。

+

还没有手动签发的令牌。App 登录走上面的「设备管理」,不用在这里生成。