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