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
+9
View File
@@ -109,6 +109,15 @@ final class Api
'person_id' => $personId, 'person_id' => $personId,
'family_id' => (int)$p['family_id'], 'family_id' => (int)$p['family_id'],
'token_id' => (int)$hit[1], '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, 'role' => $role,
'can_write' => $role !== 'guest', 'can_write' => $role !== 'guest',
]; ];
+655 -49
View File
@@ -24,17 +24,33 @@ final class ApiHandler
*/ */
public function route(string $res, int $id, string $sub, string $method): void public function route(string $res, int $id, string $sub, string $method): void
{ {
// 健康检查 / 版本探测:**不需要令牌**(App 启动时先探这个) // 健康检查 / 版本探测:**不需要令牌**(App 首次填「服务器地址」时就打它)
if ($res === 'ping' || $res === '') { if ($res === 'ping' || $res === '') {
Api::ok([ Api::ok([
'service' => 'familybook', 'service' => 'familybook',
'api' => 'v1', 'api' => 'v1',
'time' => date('c'), '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) { switch ($res) {
case 'me': $this->me($method); return; 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 'tokens': $this->tokens($id, $method); return;
case 'home': $this->home(); return; case 'home': $this->home(); return;
case 'people': $this->people($id, $sub, $method); 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 "把服务器接进来、跑正式流程"): * 🔴 为什么需要这个接口(v45,用户 2026-10-12 "把服务器接进来、跑正式流程"):
* 原先 App 只能让人**先去网页「个人设置 → API 令牌」签发、再手动粘贴** * 原先 App 只能让人**先去网页「个人设置 → API 令牌」签发、再手动粘贴**
* 到 App 的设置页里。这是个"能用但没法给别人用"的流程。 * 到 App 的设置页里。这是个"能用但没法给别人用"的流程。
* 有了它,App 首页就能直接登录。 * 有了它,App 首页就能直接登录。
* *
* 🔴 v55(用户 2026-10-17「完整的登录功能……输入用户名密码」)——
* **同一个入口既认手机号也认用户名**:
* · 输入像手机号(纯数字/带 + - 空格)→ 走 `phone_hash` 精确查找;
* · 否则当作**用户名**(= 名册里的姓名)→ 只在"有登录账号的人"里找。
* ⚠️ 为什么要这样(而不是只留手机号):一家人里老人记不住手机号,
* 但一定记得住自己叫什么;而 `accounts` 表里**没有用户名列**
* (见 Schema),为它加列 + 迁移的代价远大于收益。
* 姓名在库里本来就有(`persons.name`),且带账号的人极少(一家几个人),
* 重名风险低到可以接受 —— 真重名时**明确报错让他改用手机号**,
* 而不是随便挑一个。
* ⚠️ 参数名从 `phone` 扩成 `account`,**仍然接受 `phone`**
* (老版本 App 还在用,不能因为服务端升级就把人挡在门外)。
*
* ⚠️ 与网页登录(`Auth::login`)的三处**有意不同**: * ⚠️ 与网页登录(`Auth::login`)的三处**有意不同**:
* ① 不建会话 —— API 是无状态 Bearer,登录只是"换令牌"; * ① 不建会话 —— API 是无状态 Bearer,登录只是"换令牌";
* ② 限流不能放 `$_SESSION`(API 没有 session)⇒ 落**文件**, * ② 限流不能放 `$_SESSION`(API 没有 session)⇒ 落**文件**,
* 按"手机号 + 来源 IP"计数(见下方 `loginThrottle`); * 按"登录名 + 来源 IP"计数(见下方 `loginThrottle`);
* ③ 失败**不区分**"没这个人"与"密码错" —— 同一句提示, * ③ 失败**不区分**"没这个人"与"密码错" —— 同一句提示,
* 否则接口成了"手机号是否已注册"的探测器。 * 否则接口成了"手机号是否已注册"的探测器。
* *
@@ -97,32 +126,44 @@ final class ApiHandler
Api::fail('登录要用 POST。', 'method_not_allowed', 405); 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', ''); $password = (string)Api::param('password', '');
$label = Api::str('label', 'App 登录', 60); $label = Api::str('label', 'App 登录', 60);
// 令牌有效期(天)。0 = 永不过期(与网页签发令牌的默认行为一致)。 /* ---- v57:设备会话信息(用户 2026-10-17)----
$days = Api::int('days', 0); 用户原话:「我们在请求服务端的时候,就需要把我们的客户端的这个设备信息,
就是我的设备信息,提交到我们的服务器端。」
⚠️ `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 === '') { if ($account === '' || $password === '') {
Api::fail('手机号和密码都得填。', 'unprocessable', 422); Api::fail('账号(手机号或用户名)和密码都得填。', 'unprocessable', 422);
} }
// ---- 限流:失败次数有成本(防爆破)---- // ---- 限流:失败次数有成本(防爆破)----
$gate = self::loginThrottle($phone); $gate = self::loginThrottle($account);
if ($gate !== null) { if ($gate !== null) {
Api::fail($gate, 'too_many_requests', 429); Api::fail($gate, 'too_many_requests', 429);
} }
$hash = Crypto::hash($phone); $row = self::findLoginAccount($account);
$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();
// ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。 // ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。
// 分开回话就等于告诉对方"这个手机号存在/不存在"。 // 分开回话就等于告诉对方"这个手机号存在/不存在"。
@@ -130,23 +171,32 @@ final class ApiHandler
&& (string)$row['status'] === 'active' && (string)$row['status'] === 'active'
&& password_verify($password, (string)$row['password_hash']); && password_verify($password, (string)$row['password_hash']);
if (!$ok) { if (!$ok) {
self::loginThrottleFail($phone); self::loginThrottleFail($account);
// 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本) // 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本)
usleep(300000); usleep(300000);
Api::fail('手机号或密码不对。', 'unauthorized', 401); Api::fail('账号或密码不对。', 'unauthorized', 401);
} }
self::loginThrottleClear($phone); self::loginThrottleClear($account);
$personId = (int)$row['person_id']; $personId = (int)$row['person_id'];
$familyId = (int)$row['family_id']; $familyId = (int)$row['family_id'];
$expiresAt = null; /* ⚠️ 设备会话**一律 24 小时滑动续期**(用户 2026-10-17)。
if ($days > 0) { 原来这里有个 `days` 参数(0 = 永不过期)。v57 起不再读它:
// ⚠️ 用服务端时间算,不信客户端传的日期 同一个接口存在两种有效期语义,端上就没法判断"什么时候该锁"——
$expiresAt = date('Y-m-d H:i:s', time() + $days * 86400); 而"丢了的手机必须进不来"正是靠 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 = ?') Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?')
->execute([(int)$row['account_id']]); ->execute([(int)$row['account_id']]);
@@ -158,9 +208,23 @@ final class ApiHandler
error_log('[familybook] 登录补建默认日历本失败(已跳过):' . $ex->getMessage()); 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([ Api::ok([
'token' => $token, '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' => [ 'person' => [
'id' => $personId, 'id' => $personId,
'name' => (string)$row['name'], 'name' => (string)$row['name'],
@@ -168,9 +232,65 @@ final class ApiHandler
'role' => (string)$row['role'], 'role' => (string)$row['role'],
'family_id' => $familyId, '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。 ⚠️ 为什么不用 session:API 是无状态的,压根没有 session。
⚠️ 为什么不用数据库表:这只是一个"短期计数器", ⚠️ 为什么不用数据库表:这只是一个"短期计数器",
@@ -265,6 +385,10 @@ final class ApiHandler
$row['shown'] = Nickname::display($pid, $row) ?: (string)$row['name']; $row['shown'] = Nickname::display($pid, $row) ?: (string)$row['name'];
// 手机号**明文**(库里的 phone_encrypted 解密;空则空串) // 手机号**明文**(库里的 phone_encrypted 解密;空则空串)
$row['phone_plain'] = PersonQuery::phonePlain($row); $row['phone_plain'] = PersonQuery::phonePlain($row);
// 🔴 **全部电话**(主号在前 + person_contacts 里的补充电话)。
// App 点人名直接拨号要用:有主号打主号,没主号打补充号
// (用户 2026-10-15 定的规则)。见 PersonQuery::phonesOf 的说明。
$row['phones'] = PersonQuery::phonesOf($row);
// 生日:阴历/阳历各一条,**只到月日**(年份不外发) // 生日:阴历/阳历各一条,**只到月日**(年份不外发)
$row['birthdays'] = PersonQuery::birthdaysOf($row); $row['birthdays'] = PersonQuery::birthdaysOf($row);
// 标签 // 标签
@@ -309,17 +433,40 @@ final class ApiHandler
{ {
[$fam, $pid] = Api::me(); [$fam, $pid] = Api::me();
$p = Person::find($pid) ?: []; $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([ Api::ok([
'id' => $pid, 'id' => $pid,
'name' => (string)($p['name'] ?? ''), 'name' => (string)($p['name'] ?? ''),
'avatar' => avatar_url($p['avatar'] ?? null), '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 { 'family' => (function () use ($fam): array {
$f = Family::find($fam) ?: []; $f = Family::find($fam) ?: [];
return ['id' => $fam, 'name' => (string)($f['name'] ?? '')]; return ['id' => $fam, 'name' => (string)($f['name'] ?? '')];
})(), })(),
// 各模块主题色:App 端顶部/分类色块直接用它,不必自己维护一张表 // 各模块主题色:App 端顶部/分类色块直接用它,不必自己维护一张表
'colors' => $this->moduleColors($fam), '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 里"退出登录/吊销设备") */ /** 令牌自助管理(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 private function tokens(int $id, string $method): void
{ {
[, $pid] = Api::me(); [, $pid] = Api::me();
@@ -349,27 +564,136 @@ final class ApiHandler
Api::fail('不支持的方法。', 'method_not_allowed', 405); 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 private function home(): void
{ {
[$fam, $pid] = Api::me(); [$fam, $pid] = Api::me();
$today = date('Y-m-d'); $today = date('Y-m-d');
$cals = Calendar::mine($fam, $pid); $cals = Calendar::mine($fam, $pid);
$soon = Calendar::month($fam, $cals, $pid, $today, date('Y-m-d', strtotime('+7 days'))); $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 $soon = array_values(array_filter($soon, static fn(array $e): bool
=> (string)($e['kind'] ?? '') !== 'lunar')); => (string)($e['kind'] ?? '') !== 'lunar'));
$todayEvents = array_values(array_filter($soon, static fn(array $e): bool $todayEvents = array_values(array_filter($soon, static fn(array $e): bool
=> (string)($e['date'] ?? '') === $today)); => (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'); $tasks = Task::byStatus($fam, $pid, 'doing');
foreach (Task::byStatus($fam, $pid, 'todo') as $t) { 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([ Api::ok([
'greeting' => Home::greeting( 'greeting' => Home::greeting(
Nickname::display($pid, null) ?: '', $today, count($soon), Nickname::display($pid, null) ?: '', $today, count($soon), $overdueN),
count(array_filter($tasks, static fn($t) => Task::isOverdue($t['due_at'] ?? null)))),
'date' => $today, 'date' => $today,
'weekday' => Home::weekday($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, 'today_events' => $todayEvents,
'tasks' => $tasks, 'tasks' => $tasks,
'unread' => Notification::unreadCount($pid), 'unread' => Notification::unreadCount($pid),
@@ -388,19 +712,32 @@ final class ApiHandler
$p = Person::find($id); $p = Person::find($id);
// 只认本家的(跨家 id 直接当不存在,不泄露"别人家有没有这个人") // 只认本家的(跨家 id 直接当不存在,不泄露"别人家有没有这个人")
if (!$p || (int)$p['family_id'] !== $fam) { Api::notFound('这个人'); } 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') { if ($method === 'GET') {
Api::ok(self::stripSensitivePerson($p)); Api::ok(self::personDetail($p, $pid));
} }
if ($method === 'PATCH' || $method === 'PUT') { if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite(); Api::requireWrite();
Person::update($id, [ self::applyPersonWrite($id, $p, $fam, $pid);
'name' => Api::str('name', (string)$p['name'], 50), Api::ok(self::personDetail((array)Person::find($id), $pid));
'gender' => Api::str('gender', (string)($p['gender'] ?? '')),
'birthday'=> Api::dt('birthday', $p['birthday'] ?? null),
'note' => Api::str('note', (string)($p['note'] ?? '')),
'phone' => Api::str('phone', (string)($p['phone'] ?? ''), 40),
]);
Api::ok(self::stripSensitivePerson((array)Person::find($id)));
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
Api::requireWrite(); Api::requireWrite();
@@ -414,14 +751,18 @@ final class ApiHandler
Api::requireWrite(); Api::requireWrite();
$name = Api::str('name', '', 50); $name = Api::str('name', '', 50);
if ($name === '') { Api::fail('得写个名字。', 'unprocessable', 422); } if ($name === '') { Api::fail('得写个名字。', 'unprocessable', 422); }
$newId = Person::create($fam, [ $newId = Person::create($fam, self::personWriteInput(null, true));
'name' => $name, // 头像(可选,multipart 时才有)
'kind' => Api::str('kind', 'member'), $up = Upload::avatar($fam, $_FILES['avatar'] ?? [],
'gender' => Api::str('gender', ''), (string)($_POST['avatar_crop'] ?? ''));
'note' => Api::str('note', ''), if ($up['ok']) {
'phone' => Api::str('phone', '', 40), Person::setAvatar($newId, (string)$up['file']);
]); Person::setAvatarColor($newId, (string)($up['color'] ?? ''));
Api::ok(self::stripSensitivePerson((array)Person::find($newId)), 201); }
// 加进**我**的名册(与网页端同一口径:我加的人自动进我的名册)
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 // 列表:支持 ?q= 搜索、?kind=member|contact
@@ -434,6 +775,271 @@ final class ApiHandler
Api::list($safe); 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 的字段**去掉(就地改,返回同一个数组)。 * 把人员行里**不该给到 App 的字段**去掉(就地改,返回同一个数组)。
* *
+321 -22
View File
@@ -15,6 +15,29 @@
* ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。 * ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。
* 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。 * 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。
* ⇒ 网页端把明文塞进一次性 session 槽位、显示一次;App 端它自己存 Keychain。 * ⇒ 网页端把明文塞进一次性 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); declare(strict_types=1);
@@ -25,29 +48,145 @@ final class ApiToken
/** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */ /** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */
public const PREFIX = 'fb_'; public const PREFIX = 'fb_';
/** 设备会话的有效期(小时)。用户 2026-10-17:24 小时,滑动续期 */
public const TTL_HOURS = 24;
/** 剩余寿命低于这个小时数才续期(避免每个请求都写库,见文件头说明) */
private const RENEW_BELOW_HOURS = 4;
/** /**
* 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。 * 每个账号最多几台设备。
* *
* ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集: * 用户 2026-10-17 同意:「每账号最多 10 台,超了让用户先踢旧的。」
* 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。 * ⚠️ 不设上限的话,一个脚本反复登录就能把 `api_tokens` 灌满,
* 32 字节 → base64url 43 字符 → 约 256 bit 熵。 * 而且设备列表会变得没法看。
*/
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 public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string
{ {
$plain = self::PREFIX . self::b64url(random_bytes(32)); $plain = self::PREFIX . self::b64url(random_bytes(32));
Database::get()->prepare( self::insert(
'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)'
)->execute([
$personId, $personId,
hash('sha256', $plain), hash('sha256', $plain),
$label !== '' ? mb_substr($label, 0, 60) : null, $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; 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 会进日志/浏览器历史, * ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史,
* 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选; * 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选;
@@ -55,6 +194,10 @@ final class ApiToken
* ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。 * ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。
* ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误, * ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误,
* 避免给爆破者提供信息;具体原因网页端能看到)。 * 避免给爆破者提供信息;具体原因网页端能看到)。
*
* 返回值**同时保留数字键**(`[0]`=人员 id、`[1]`=令牌 id):
* `Api::actor()` / `api.php` / `media.php` 三处历史上都用 `$hit[0]`、`$hit[1]`,
* 改成纯关联数组会静默把它们打坏。
*/ */
public static function verify(string $plain): ?array public static function verify(string $plain): ?array
{ {
@@ -62,8 +205,10 @@ final class ApiToken
if ($plain === '' || strpos($plain, self::PREFIX) !== 0) { if ($plain === '' || strpos($plain, self::PREFIX) !== 0) {
return null; return null;
} }
$st = Database::get()->prepare( $db = Database::get();
'SELECT t.id, t.person_id, a.status $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 FROM api_tokens t
JOIN accounts a ON a.person_id = t.person_id JOIN accounts a ON a.person_id = t.person_id
WHERE t.token_hash = ? WHERE t.token_hash = ?
@@ -75,28 +220,158 @@ final class ApiToken
if (!$row || (string)$row['status'] !== 'active') { if (!$row || (string)$row['status'] !== 'active') {
return null; 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 { try {
Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?') $ip = self::clientIp();
->execute([(int)$row['id']]); if ($expiresAt !== null && !self::renewDue($expiresAt)) {
} catch (\Throwable $e) { $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]);
} }
return [(int)$row['person_id'], (int)$row['id']]; } catch (\Throwable $e) {
// 见上:记不上就记不上
} }
/** 某个人的令牌清单(**不含明文**,只给管理界面看) */ 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 public static function listFor(int $personId): array
{ {
$st = Database::get()->prepare( $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' FROM api_tokens WHERE person_id = ? ORDER BY id DESC'
); );
$st->execute([$personId]); $st->execute([$personId]);
return $st->fetchAll(); return $st->fetchAll();
} }
/** 吊销一枚(限自己的) */ /** 吊销一枚(限自己的;v44 的老接口) */
public static function revoke(int $personId, int $id): bool public static function revoke(int $personId, int $id): bool
{ {
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?'); $st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?');
@@ -104,7 +379,7 @@ final class ApiToken
return $st->rowCount() > 0; return $st->rowCount() > 0;
} }
/** 吊销自己的全部("手机丢了"时的应急动作) */ /** 吊销自己的全部("手机丢了"时的应急动作;v44 的老接口) */
public static function revokeAll(int $personId): int public static function revokeAll(int $personId): int
{ {
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?'); $st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?');
@@ -112,6 +387,30 @@ final class ApiToken
return $st->rowCount(); 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 private static function b64url(string $raw): string
{ {
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
+81
View File
@@ -405,6 +405,87 @@ final class Home
return (string)$r; 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']。 * 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。
* *
+51
View File
@@ -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;
}
/** /**
* 给列表用的行补上派生字段。幂等(已有值就不覆盖)。 * 给列表用的行补上派生字段。幂等(已有值就不覆盖)。
* *
+73 -2
View File
@@ -166,6 +166,21 @@ final class Schema
* ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工: * ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工:
* 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides * 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides
* (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。 * (后者自带新时间,语义上是"替换那一次",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() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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, `person_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库', `token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库',
`label` VARCHAR(60) DEFAULT NULL COMMENT '设备名(用户自己填,如「我的手机」)', `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, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME DEFAULT NULL, `last_used_at` DATETIME DEFAULT NULL,
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期', `expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期(设备会话一律 24h 滑动)',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_api_token` (`token_hash`), UNIQUE KEY `uk_api_token` (`token_hash`),
KEY `idx_api_person` (`person_id`), 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 CONSTRAINT `fk_api_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='移动端 / 第三方调用的 API 令牌(与 DAV 口令、登录口令分开)' 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-<id>` 后
它就是一个正常的、可踢的设备条目。
⚠️ 幂等:条件只挑 `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 条):把**已入库**的带分隔符手机号归一成纯数字。 /* 🔴 v51(用户 2026-10-15 第 5 条):把**已入库**的带分隔符手机号归一成纯数字。
用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字, 用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字,
中间有两个空格……看起来还有不少这样的数据呢,都提示我数据不对。」 中间有两个空格……看起来还有不少这样的数据呢,都提示我数据不对。」
+47 -61
View File
@@ -319,6 +319,15 @@ function render_me_settings(array $person, array $errors = []): void
'apiPlain' => (string)($_SESSION['api_plain'] ?? ''), 'apiPlain' => (string)($_SESSION['api_plain'] ?? ''),
'apiLabel' => (string)($_SESSION['api_plain_label'] ?? ''), 'apiLabel' => (string)($_SESSION['api_plain_label'] ?? ''),
'apiBase' => rtrim((string)fb_config('base_url', ''), '/'), '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'); ], 'settings');
// ⚠️ 取完即清:明文令牌只该被看到一次(刷新页面就没了)。 // ⚠️ 取完即清:明文令牌只该被看到一次(刷新页面就没了)。
// 放在 View::render **之后** —— 早了就拿不到值了。 // 放在 View::render **之后** —— 早了就拿不到值了。
@@ -646,72 +655,23 @@ function read_due_input(): ?string
/** /**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
* *
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**, * 🔴 v54(用户 2026-10-16):**实现已搬到 `App\Home::memories()`**,
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**: * 这里只做一次转发。
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事, * 为什么搬:App 端首页也要显示「往年的今天」(用户原话
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。 * 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样」),
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。 * 而 App 走的是 `/api/v1/home`。**两端必须同一份实现** ——
* 否则会出现"网页说 2 年前、手机说 3 年前"这种对不上的事
* (本项目在农历/节气上已经吃过一次这个亏)。
*
* ⚠️ 保留这个函数名而不是把调用点全改掉:模板里 `$memories` 的取用位置、
* 以及可能的旧书签/脚本都还在按老路径走,删了就静默少一块。
* 顺便这个包装也让"模板注释里提到的 home_memories()"仍然指得到东西。
* *
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容) * @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/ */
function home_memories(int $familyId, int $limit = 6): array function home_memories(int $familyId, int $limit = 6): array
{ {
$today = date('Y-m-d'); return \App\Home::memories($familyId, $limit);
$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];
} }
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */ /** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
@@ -5016,6 +4976,32 @@ $want = array_keys($want);
redirect('settings/me', [], 'api'); redirect('settings/me', [], 'api');
break; 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 条)。 * 日程「显示设置」独立页(v51 用户 2026-10-15 第 6 条)。
* *
+89 -3
View File
@@ -160,10 +160,96 @@ $rosterCount = $rosterCount ?? 0;
(一台设备一枚)、可逐枚吊销、可设有效期、带设备名。 (一台设备一枚)、可逐枚吊销、可设有效期、带设备名。
两件事的生命周期完全不同(吊销 DAV 口令会连带断掉日历同步; 两件事的生命周期完全不同(吊销 DAV 口令会连带断掉日历同步;
吊销一枚 App 令牌不该影响别人的手机)。 */ ?> 吊销一枚 App 令牌不该影响别人的手机)。 */ ?>
<!-- ==================== 设备管理(v57,用户 2026-10-17)==================== -->
<?php /* 用户原话:「在我们服务器端的个人设置那里面,就有一个这个设备管理,
就是说我都有哪些设备已经在登录这个东西。」
「允许我们的用户把其中的某一个客户端远程给它退出,相当于它这个 Token 给它删掉。
这样的话,这个应用一旦打开,就发现这个 Token 已经失效了,返回登录页面。」
⚠️ 这里列的是**设备**(按 device_id 聚合),不是令牌行:
同一台手机重登过 5 次,这一页只该出现 1 行。
⚠️ 文案里必须说清"下次联网才生效" —— 服务端没有推送通道,
写"立刻失效"是骗人;而 App 端每次启动都会打 `/me`,所以体感上很快。 */ ?>
<div class="card card-pad" id="devices" style="margin-top:20px">
<h2 class="section-title">
设备管理
<span class="hint">哪些手机 / 平板正在登录这个账号</span>
</h2>
<p class="small muted" style="margin:0 0 14px">
在 App 里每登录一次,这里就多一台设备(最多 <?= (int)($deviceMax ?? 10) ?> 台)。
令牌有效期 <?= (int)($deviceTtl ?? 24) ?> 小时,
<b>只要设备在联网就会自动续期</b>,所以常用的手机不需要反复登录。
<br>手机丢了就在这里「移除」—— 那台设备下次联网时会立刻退回登录页,
本机已下载的数据将无法查看。
</p>
<?php $deviceRows = $devices ?? []; ?>
<?php if (!empty($deviceRows)): ?>
<table class="res-table" style="width:100%;margin:6px 0 10px">
<thead>
<tr><th>设备</th><th>系统 / 客户端</th><th>最近活跃</th><th>登录时间</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($deviceRows as $d): ?>
<?php
$dvName = (string)($d['device_name'] ?? '');
$dvPlat = trim((string)($d['platform'] ?? ''));
$dvVer = (string)($d['app_version'] ?? '');
if ($dvVer !== '') {
$dvPlat = ($dvPlat !== '' ? $dvPlat . ' · ' : '') . '客户端 ' . $dvVer;
}
$dvId = (string)($d['device_id'] ?? '');
?>
<tr>
<td>
<b><?= e($dvName !== '' ? $dvName : '未知设备') ?></b>
<?php if (strpos($dvId, 'legacy-') === 0): ?>
<span class="small muted">(升级前的旧会话)</span>
<?php elseif ($dvId === 'auto-unknown'): ?>
<span class="small muted">(旧版 App)</span>
<?php endif; ?>
</td>
<td class="small muted"><?= e($dvPlat !== '' ? $dvPlat : '—') ?></td>
<td class="small muted">
<?= e(((string)($d['last_seen_at'] ?? '')) !== '' ? (string)$d['last_seen_at'] : '还没用过') ?>
</td>
<td class="small muted"><?= e((string)($d['created_at'] ?? '')) ?></td>
<td>
<?php if (\App\Auth::canWrite()): ?>
<form method="post" action="<?= e(url('settings/device_remove')) ?>" class="res-inline"
onsubmit="return confirm('移除这台设备?它下次联网时会退回登录页,本机已下载的数据将无法查看。')">
<?= csrf_field() ?>
<input type="hidden" name="device_id" value="<?= e($dvId) ?>">
<button class="btn btn-ghost btn-sm" type="submit">移除</button>
</form>
<?php else: ?>
<span class="small muted">只读账号</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (count($deviceRows) > 1 && \App\Auth::canWrite()): ?>
<div class="btn-row">
<form method="post" action="<?= e(url('settings/api_revoke')) ?>" class="res-inline"
onsubmit="return confirm('退出全部设备?所有 App(包括你现在手里这台)都要重新登录。')">
<?= csrf_field() ?>
<button class="btn btn-ghost btn-sm" type="submit">退出全部设备</button>
</form>
</div>
<?php endif; ?>
<?php else: ?>
<p class="small muted">还没有设备登录过。在 App 里用这个账号登录后,这里就会出现它。</p>
<?php endif; ?>
</div>
<div class="card card-pad" id="api" style="margin-top:20px"> <div class="card card-pad" id="api" style="margin-top:20px">
<h2 class="section-title"> <h2 class="section-title">
App 接口令牌 手动签发接口令牌
<span class="hint">移动端 / 第三方程序调用用</span> <span class="hint">第三方脚本 / 调试用;App 正常登录**不需要**这里</span>
</h2> </h2>
<p class="small muted" style="margin:0 0 14px"> <p class="small muted" style="margin:0 0 14px">
接口地址前缀 <span class="mono"><b><?= e((string)$apiBase) ?>/api/v1/</b></span>, 接口地址前缀 <span class="mono"><b><?= e((string)$apiBase) ?>/api/v1/</b></span>,
@@ -218,7 +304,7 @@ $rosterCount = $rosterCount ?? 0;
</tbody> </tbody>
</table> </table>
<?php else: ?> <?php else: ?>
<p class="small muted">还没有令牌。给每台设备发一枚吧。</p> <p class="small muted">还没有手动签发的令牌。App 登录走上面的「设备管理」,不用在这里生成。</p>
<?php endif; ?> <?php endif; ?>
<?php if (\App\Auth::canWrite()): ?> <?php if (\App\Auth::canWrite()): ?>