v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)

用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
This commit is contained in:
2026-10-18 03:34:47 +08:00
parent 190dc56301
commit ebaed21920
8 changed files with 1329 additions and 140 deletions
+658 -52
View File
@@ -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/<id>/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 的字段**去掉(就地改,返回同一个数组)。
*