Files
FamilyBook/app/ApiHandler.php
T
laoyang ad7f43af68 v61:新增账号自助接口(GET /account + POST /account/password)
端上新增「个人设置」页要用:用户 2026-10-19 要求把抽屉里的
「通知消息 / 个人设置 / 修改密码 / 系统设置」都收进"点右上角头像"那一页,
所以端上必须能自己改密码,不能再指着用户去网页改。

  GET  /api/v1/account            → 我的资料 + 账号信息 + 当前设备
  POST /api/v1/account/password   → 改密码(old_password/password/password2)

规则与网页那条改密码路由逐条对齐(不能端上比网页松):
  · 必须验旧密码;
  · 两次新密码一致;
  · 过 Account::validatePassword()(≥8 位 + 字母数字都要有)。
额外收紧一条:新密码不能与原密码相同(网页没拦,端上拦了没坏处)。

两处刻意为之:
  · 改密码**不查写权限**(网页那条路由只要求登录,guest 也能改自己的密码)
    —— 加了写权限检查就会出现"网页能改、App 不能改"的权限裂缝;
  · 改完**不动现有令牌** —— 用户在自己手机上改密码不该被踹下线。

features 声明补 account / account_password,端上可探测。

验证:_fb_v61_account_api.py 20/0(独立副本 + 自检 + 随机端口 + 真 MariaDB,
覆盖 5 种错误路径 + 改完旧密码登不进/新密码能登/原令牌仍有效);
php -l 全量通过;线上部署已完成。
2026-10-19 12:19:06 +08:00

2302 lines
118 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 🔴 v44(用户 2026-10-12 第 6 条)—— **API 资源路由表**。
*
* 一个 `switch` 管所有资源:`App\Api::dispatch()` 解析出 `<资源>/<id>/<子动作>` 后调这里。
*
* ⚠️ 为什么不做成"每资源一个文件":本项目的部署与静态断言都是**按文件白名单**走的
* (见 `familybook_deploy.sh`),几十个小文件会让"哪些该进包"变成一件要维护的事。
* 一个文件 + 清晰分段,是这里更稳的选择。
* ⚠️ 每个写操作**都**先调 `Api::requireWrite()` —— 只读账号在网页上不能写,
* 在 API 里也必须不能写(权限裂缝是最不该出现的那种 bug)。
*/
declare(strict_types=1);
namespace App;
final class ApiHandler
{
/**
* @param string $res 资源名(people / movies / …)
* @param int $id 路径里的数字 id(没有则 0)
* @param string $sub 子动作(如 tasks/12/status 里的 status)
* @param string $method GET / POST / PATCH / DELETE
*/
public function route(string $res, int $id, string $sub, string $method): void
{
// 健康检查 / 版本探测:**不需要令牌**(App 首次填「服务器地址」时就打它)
if ($res === 'ping' || $res === '') {
Api::ok([
'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,
/* 🔴 v61:账号自助 —— 端上「个人设置」页要能改密码、
看自己这台设备。`/account` 返回 profile + 当前设备,
`POST /account/password` 改密码。 */
'account' => 1,
'account_password' => 1,
'home_memories' => 1,
// v58:同步可以"先问一句哪些模块变了",只拉变了的
// (见 `changes()` 的说明;老端上没有这个标志也不会坏)
'changes' => 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;
// 🔴 v58:增量同步的"变更指纹"。端上先问一遍,只拉真的变了的模块
// (首次全量、之后增量)。详见 `changes()`。
case 'changes': $this->changes($method); return;
case 'home': $this->home(); return;
case 'people': $this->people($id, $sub, $method); return;
// 🔴 v45:名册(**我名册里的人**,不是全家 800 人)。
// 与 `people` 的区别见 `roster()` 方法的说明。
case 'roster': $this->roster($method); return;
// 🔴 v45:家谱(以我为中心的关系图)。
// 端上「家谱」页要用:父母 / 配偶 / 子女 / 兄弟姐妹 + 各代层次。
// ⚠️ 数据来自 `relations` 表(只存相邻两代的边),
// 亲属关系靠 `Kinship` 推导 —— 与 Web 端 `kin/tree.php` 同一套。
case 'kin': $this->kin(); return;
case 'notifications': $this->notifications($id, $sub, $method); return;
case 'calendar': $this->calendar($sub, $method); return;
case 'events': $this->events($id, $sub, $method); return;
case 'days': $this->days($id, $method); return; // 纪念日
case 'tasks': $this->tasks($id, $sub, $method); return;
case 'movies': $this->movies($id, $sub, $method); return;
case 'cinemas': $this->cinemas($id, $method); return;
case 'trips': $this->trips($id, $sub, $method); return;
case 'ledger': $this->ledger($id, $sub, $method); return;
case 'resources': $this->resources($id, $method); return;
case 'books': $this->books($id, $sub, $method); return;
case 'notes': $this->notes($id, $method); return;
// 🔴 v45:登录(手机号 + 密码 → API 令牌)。
// 为什么必须走 API 而不是网页登录后复制 Cookie:
// App 是 Bearer 令牌那套,没有会话;而原先前端只能让人
// **手动去网页签发令牌再粘贴**到 App 里 —— 这个体验没法接受。
case 'login': $this->login($method); return;
/* 🔴 v61:账号自助(端上「个人设置」页)。
⚠️ 与网页那条改密码路由**同一套规则**:
必须验旧密码、两次新密码一致、且过 `Account::validatePassword()`
(≥8 位 + 字母与数字都有)—— 端上不该比网页松。 */
case 'account': $this->account($sub, $method); return;
}
Api::fail('未知的接口资源:' . $res, 'not_found', 404);
}
/* ═══════════════════════════════════════════════════════════════
登录(手机号 + 密码 → 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`);
* ③ 失败**不区分**"没这个人"与"密码错" —— 同一句提示,
* 否则接口成了"手机号是否已注册"的探测器。
*
* ⚠️ 成功时**顺带补建默认日历本**(与 `Auth::login` 同样的兜底):
* 全新安装时 `Schema::ensure()` 跑在"建人"之前,
* 那次遍历一个账号都没有 ⇒ 一本日历都不建。放在登录处最稳。
*/
private function login(string $method): void
{
if ($method !== 'POST') {
Api::fail('登录要用 POST。', 'method_not_allowed', 405);
}
// `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);
/* ---- 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 ($account === '' || $password === '') {
Api::fail('账号(手机号或用户名)和密码都得填。', 'unprocessable', 422);
}
// ---- 限流:失败次数有成本(防爆破)----
$gate = self::loginThrottle($account);
if ($gate !== null) {
Api::fail($gate, 'too_many_requests', 429);
}
$row = self::findLoginAccount($account);
// ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。
// 分开回话就等于告诉对方"这个手机号存在/不存在"。
$ok = $row
&& (string)$row['status'] === 'active'
&& password_verify($password, (string)$row['password_hash']);
if (!$ok) {
self::loginThrottleFail($account);
// 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本)
usleep(300000);
Api::fail('账号或密码不对。', 'unauthorized', 401);
}
self::loginThrottleClear($account);
$personId = (int)$row['person_id'];
$familyId = (int)$row['family_id'];
/* ⚠️ 设备会话**一律 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
);
}
Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?')
->execute([(int)$row['account_id']]);
// 进门补默认日历本(幂等;失败不挡登录)
try {
Calendar::seedDefaults($familyId, $personId);
} catch (\Throwable $ex) {
error_log('[familybook] 登录补建默认日历本失败(已跳过):' . $ex->getMessage());
}
// 家庭名(端上登录成功后立刻能显示"已连上 X 家",
// 不必再多打一次 `/me`;拿不到就给空串,不影响登录)
$familyName = '';
try {
$f = Family::find($familyId) ?: [];
$familyName = (string)($f['name'] ?? '');
} catch (\Throwable $ex) {
error_log('[familybook] 登录取家庭名失败(已跳过):' . $ex->getMessage());
}
/* 🔴 2026-10-18:登录响应直接带上**我该显示的那句标语**。
用户要求把抽屉左上角那行(原来是「我的家」)换成标语:
有个人 slogan 用个人的、没有用家庭的、再没有用应用默认口号
(三层优先级见 `Family::slogan()`,与网页端首页同一个函数)。
⚠️ 放在登录里而不是只放 `/me`:登录后**立刻**就要渲染抽屉,
少一次请求就少一次"先显示旧值再跳变"的闪烁。 */
$slogan = Family::DEFAULT_SLOGAN;
try {
$slogan = Family::slogan($familyId, $personId);
} catch (\Throwable $ex) {
error_log('[familybook] 登录取标语失败(回退默认):' . $ex->getMessage());
}
$avatarRaw = (string)($row['avatar'] ?? '');
Api::ok([
'token' => $token,
// 到期时间由**服务端**算(端上只拿来显示,绝不据此判断"还能不能用")
'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'],
'avatar' => $avatarRaw,
/* 🔴 头像 URL 与 `/me` 同口径(`avatar_url()` 会按线上存储驱动
决定走 /uploads 还是 OSS 直链)。
⚠️ 端上顶栏原来只拿色块字、拿不到真图 —— 用户 2026-10-18:
「右上角的头像应该是真实的」。 */
'avatar_url'=> avatar_url($avatarRaw !== '' ? $avatarRaw : null),
'role' => (string)$row['role'],
'family_id' => $familyId,
],
'family' => ['id' => $familyId, 'name' => $familyName, 'slogan' => $slogan],
// 回显设备,端上可以核对"服务器记住的是不是这台"
'device' => [
'id' => $deviceId,
'name' => $deviceName,
'max_devices'=> ApiToken::MAX_DEVICES,
],
]);
}
/**
* 账号自助(端上「个人设置」页)。
*
* GET /api/v1/account → 我的资料 + 账号信息 + 当前设备
* POST /api/v1/account/password → 改密码(old_password / password / password2)
*
* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、修改密码、
* 系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」
* ⇒ 端上个人设置页要能**自己改密码**,不能再指着用户去网页改。
*
* ⚠️ 规则与网页那条路由**逐条对齐**(不能端上比网页松):
* · 必须验旧密码;
* · 两次新密码必须一致;
* · 过 `Account::validatePassword()`(≥8 位 + 字母数字都要有)。
* ⚠️ 新密码**不能与原密码相同**:网页那边没拦,但端上自己拦一道没坏处
* (用户改完发现"还是旧密码"会以为没保存成功)。
* ⚠️ 改完**不动现有令牌**:用户在自己的手机上改密码,不该把自己踹下线
* (其它设备上的会话仍有效 —— 要踢设备请用 `/devices`)。
*/
private function account(string $sub, string $method): void
{
$act = Api::actor();
$pid = (int)$act['person_id'];
if ($sub === 'password') {
if ($method !== 'POST') {
Api::fail('改密码要用 POST。', 'method_not_allowed', 405);
}
/* ⚠️ 这里**刻意不做写权限校验**:
只读访客(guest)在网页上也能改自己的密码
(那条路由只要求登录,不查写权限)。
端上若加了写权限检查,就会出现"网页能改、App 不能改"的权限裂缝。 */
$old = (string)Api::param('old_password', '');
$new = (string)Api::param('password', '');
$new2 = (string)Api::param('password2', '');
$acc = Account::findByPerson($pid);
if ($acc === null) {
Api::fail('这个账号不存在(可能只有成员资料、还没开通登录)。', 'not_found', 404);
}
if (!password_verify($old, (string)$acc['password_hash'])) {
// 只回"当前密码不对",不回其它线索
Api::fail('当前密码不对。', 'unauthorized', 401);
}
if ($new !== $new2) {
Api::fail('两次输入的新密码不一致。', 'unprocessable', 422);
}
if ($new === $old) {
Api::fail('新密码不能和当前密码一样。', 'unprocessable', 422);
}
$errs = Account::validatePassword($new);
if (count($errs) > 0) {
Api::fail(implode(';', $errs), 'unprocessable', 422);
}
Account::setPassword((int)$acc['id'], $new);
Api::ok(['changed' => 1]);
}
if ($method !== 'GET') {
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
$p = Person::find($pid) ?: [];
$acc = Account::findByPerson($pid);
$avatarRaw = (string)($p['avatar'] ?? '');
Api::ok([
'person' => [
'id' => $pid,
'name' => (string)($p['name'] ?? ''),
'avatar_url' => avatar_url($avatarRaw !== '' ? $avatarRaw : null),
'role' => (string)($act['role'] ?? 'member'),
'can_write' => (bool)($act['can_write'] ?? false),
],
'account' => [
'has_login' => $acc !== null ? 1 : 0,
'role' => $acc !== null ? (string)$acc['role'] : '',
'status' => $acc !== null ? (string)$acc['status'] : '',
'last_login' => $acc !== null ? (string)($acc['last_login_at'] ?? '') : '',
// ⚠️ 密码规则由**服务端**给,端上只用来写提示语
// (避免两边各写一份"至少 8 位"然后慢慢对不上)
'password_min' => 8,
],
'device' => [
'id' => (string)($act['device_id'] ?? ''),
'name' => (string)($act['device_name'] ?? ''),
],
'is_admin' => ($act['role'] ?? '') === 'admin' ? 1 : 0,
]);
}
/**
* 按「手机号 或 用户名」找人(登录用)。
*
* 🔴 两步查找,顺序**不能反**:
* ① **手机号**:`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。
⚠️ 为什么不用数据库表:这只是一个"短期计数器",
为它加表 + 写迁移的代价大于收益(本应用是单机部署)。
⇒ 落 `sys_get_temp_dir()` 下的小文件,60 秒窗口、5 次失败。
⚠️ 键里带 IP:防止"只针对某个手机号"刷爆别人的登录
(但也别只按 IP —— 同一家庭可能共用出口 IP)。 */
private static function loginThrottleFile(string $phone): string
{
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? '0');
// ⚠️ 文件名用 hash,**不要把手机号写进文件名**(临时目录可能被列举)
return sys_get_temp_dir() . '/fb_login_' . hash('sha256', $ip . '|' . strtolower(trim($phone))) . '.json';
}
/** 返回"拦下来的提示",null = 放行 */
private static function loginThrottle(string $phone): ?string
{
$f = self::loginThrottleFile($phone);
if (!is_file($f)) { return null; }
$raw = @file_get_contents($f);
$d = $raw !== false ? json_decode($raw, true) : null;
if (!is_array($d)) { return null; }
$n = (int)($d['n'] ?? 0);
$t = (int)($d['t'] ?? 0);
if ($n >= 5 && (time() - $t) < 60) {
return '尝试太频繁了,请等 1 分钟再试。';
}
return null;
}
private static function loginThrottleFail(string $phone): void
{
$f = self::loginThrottleFile($phone);
$raw = @file_get_contents($f);
$d = $raw !== false ? json_decode($raw, true) : null;
$n = is_array($d) ? (int)($d['n'] ?? 0) : 0;
// 超过窗口就重新计数(否则失败过一次的人永远被算在同一轮里)
$t = is_array($d) ? (int)($d['t'] ?? 0) : 0;
if ((time() - $t) >= 60) { $n = 0; }
@file_put_contents($f, json_encode(['n' => $n + 1, 't' => time()]), LOCK_EX);
}
private static function loginThrottleClear(string $phone): void
{
@unlink(self::loginThrottleFile($phone));
}
/**
* `GET /api/v1/roster` —— 「**我**的名册」(App 名册页的主数据)。
*
* 🔴 与 `GET /people` 的**关键区别**(这决定了 App 名册页该用哪个):
* · `/people` —— 全家的人(本家 801 人),是"通讯录底账";
* · `/roster` —— **我名册里的人**(我加的 + 别人分享给我的),
* 是一个人**自己挑出来、常用**的那一小撮。
* Web 端名册页用的就是后者(`Person::roster()`,见 index.php 的注释)。
* ⇒ App 的名册页必须用 `/roster`,否则一屏 800 人,且是"别人的通讯录"。
*
* 组装顺序**照抄 Web 端 `index.php` 的 people 分支**(同一套类、同一套判据):
* `Person::roster` → `Kinship::label`(我该怎么称呼他)
* → `Nickname::display`(我怎么叫他)/ `PersonQuery::phonePlain`(解密手机号)
* → `PersonQuery::birthdaysOf`(阴历阳历只取月日)
* → `PersonTag::mapFor`(每人的标签)
* ⚠️ 一定要复用这些类而不是在 API 里另写一遍:
* 否则会出现"网页里搜得到、App 里搜不到"这类**两端不一致**
* (`PersonQuery` 的类注释专门强调过这一点)。
*
* 查询参数:`?q=`(关键字,走 PersonQuery 同一套粗筛 + 精筛)、
* `?kind=member|contact`、`?tag=`(只看某个标签)。
*/
private function roster(string $method): void
{
if ($method !== 'GET') {
Api::fail('名册只支持 GET。', 'method_not_allowed', 405);
}
[$fam, $pid] = Api::me();
$kind = Api::str('kind', 'member');
$q = Api::str('q');
$rows = Person::roster($fam, $pid, $kind, $q);
// ---- 装饰(与 Web 端同一套)----
$graph = Kinship::graph($fam);
$people = Person::map($fam);
$tagsOf = PersonTag::mapFor($fam, $pid);
foreach ($rows as &$row) {
// 「我该怎么称呼他」
$row['label'] = Kinship::label($graph, $pid, (int)$row['id'], $people);
// 「我怎么叫他」(昵称优先,没有就用本名)
$row['shown'] = Nickname::display($pid, $row) ?: (string)$row['name'];
// 手机号**明文**(库里的 phone_encrypted 解密;空则空串)
$row['phone_plain'] = PersonQuery::phonePlain($row);
// 🔴 **全部电话**(主号在前 + person_contacts 里的补充电话)。
// App 点人名直接拨号要用:有主号打主号,没主号打补充号
// (用户 2026-10-15 定的规则)。见 PersonQuery::phonesOf 的说明。
$row['phones'] = PersonQuery::phonesOf($row);
// 生日:阴历/阳历各一条,**只到月日**(年份不外发)
$row['birthdays'] = PersonQuery::birthdaysOf($row);
// 标签
$row['tags'] = $tagsOf[(int)$row['id']] ?? [];
// 首字母(App 侧"输入即筛选"用;与网页 filter.js 的 data-initials 同一来源)
$row['initials'] = Pinyin::initials(
(string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? '')
);
// 🔴 脱敏:名册行同样是 `SELECT p.*`,敏感密文一律不下发
// (见 stripSensitivePerson 的说明)。
$row = self::stripSensitivePerson($row);
}
unset($row);
// ---- 标签筛选(`?tag=` 为空 = 全部)----
$tagFilter = Api::str('tag');
if ($tagFilter !== '') {
$rows = array_values(array_filter(
$rows,
static fn(array $r): bool => in_array($tagFilter, $r['tags'] ?? [], true)
));
}
Api::ok([
'people' => array_values($rows),
// 标签云(筛选条用)+ 标签词表(编辑时选已有标签)
'tagCloud' => PersonTag::cloud($fam, $pid),
'groups' => PersonGroup::orderFor($fam, $pid),
'tagFilter' => $tagFilter,
'kind' => $kind,
'meId' => $pid,
'q' => $q,
]);
}
/* ═══════════════════════════════════════════════════════════════
我 / 令牌 / 首页
═══════════════════════════════════════════════════════════════ */
private function me(string $method): void
{
[$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' => (string)($act['role'] ?? 'member'),
'can_write'=> (bool)($act['can_write'] ?? false),
'family' => (function () use ($fam, $pid): array {
$f = Family::find($fam) ?: [];
/* 🔴 2026-10-18:标语一并给出来(抽屉左上角那行要它)。
每次启动都会打 `/me`,所以网页端改了 slogan 之后
手机下次启动就会跟上 —— 不必专门为它加一个接口。 */
$sl = Family::DEFAULT_SLOGAN;
try {
$sl = Family::slogan($fam, $pid);
} catch (\Throwable $ex) {
// 读不到就用默认,不挡登录校验
}
return [
'id' => $fam,
'name' => (string)($f['name'] ?? ''),
'slogan' => $sl,
];
})(),
// 各模块主题色: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'),
]);
}
private function moduleColors(int $fam): array
{
$out = [];
foreach (['calendar', 'tasks', 'movies', 'trips', 'ledger', 'resources', 'books', 'notes', 'people'] as $k) {
$out[$k] = Module::color($k, $fam);
}
return $out;
}
/** 令牌自助管理(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();
if ($method === 'GET') {
Api::ok(ApiToken::listFor($pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
if ($id > 0) {
Api::ok(['revoked' => ApiToken::revoke($pid, $id) ? 1 : 0]);
}
Api::ok(['revoked' => ApiToken::revokeAll($pid)]);
}
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) {
$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);
/* ---- 🔴 v59(用户 2026-10-18):最近三个纪念日(含生日),倒计时用 ----
用户原话:「首页应该加一个『最近纪念日』模块,放在『往年的今天』前面、
『近期任务』后面。内容包括:1. 未来最近的三个纪念日,包括每个人的生日,
以及手动填写的各个纪念日。2. 以倒计时形式展示:距离哪个纪念日还有多少天,
距离谁谁谁的生日还有多少天。」
⇒ 位置是**近期任务之后、往年的今天之前**(端上按这个顺序摆)。
⚠️ 为什么要 370 天的窗口(而不是像网页端概述卡那样只取 30 天):
「未来最近的三个」如果窗口太小,一个刚过完生日的家庭会**一个都取不到**,
模块直接空着 —— 而用户要的是一个**永远的倒计时**。
取满一年,保证"接下来的三个"一定有(只要有纪念日或生日)。
⚠️ 数据来源 `Anniversary::upcoming()` → `occurrences()` → `all()`,
而 `all()` = **派生生日 + 自建纪念日** —— 正好一次满足用户说的两样,
不必再去 `Birthday::` 那套单独取一遍(两处取必然口径不一)。
⚠️ 已经过完今天的同样包含在内(`from` 从今天起算,含今天)。
今天那条 `days=0` 端上会显示成「就是今天」。 */
$recentDays = [];
try {
$recentDays = array_slice(Anniversary::upcoming($fam, $pid, 370), 0, 3);
} catch (\Throwable $ex) {
error_log('[familybook] 首页取最近纪念日失败(已跳过):' . $ex->getMessage());
}
// 当前登录者本人(顶栏右上角要显示**真实头像**,用户 2026-10-18:
// "右上角的头像应该是真实的")。取不到就留空串,端上回落成色块字。
$meRow = Person::find($pid) ?: [];
// ---- 天气(三级回落:个人经纬度 → 内置城市表 → 家庭级兜底 → 不显示)----
$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), $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'],
/**
* 🔴 v59:未来最近的三个纪念日(用户 2026-10-18 要的「最近纪念日」模块)。
*
* 每条:`{id, date, title, kind, person_id, person_name?, source, days, age, nth}`
* · `days` —— 距今天还有几天(0 = 就是今天;**永远是 0 或正数**)
* · `kind` —— 用户填的类别("生日" / "结婚纪念" / …),端上直接显示
* · `source`—— `'custom'` = 手动填的纪念日;其余(`birthday_solar`
* / `birthday_lunar`)= 派生生日,端上据此区分"纪念日 / 生日"
* · `title` —— 已含姓名(如「老杨 的生日」「结婚纪念日」),端上直接用
*
* ⚠️ 顺序就是"最近的在前"(`upcoming` 内部已按 date 升序),
* 端上**不要再排一遍** —— 排序口径分散在两处迟早不一致。
*/
'recent_days' => $recentDays,
'today_events' => $todayEvents,
'tasks' => $tasks,
'unread' => Notification::unreadCount($pid),
/** 当前登录者的真实头像 URL(顶栏右上角要显示真图,用户 2026-10-18) */
'me_avatar' => avatar_url($meRow['avatar'] ?? null),
]);
}
/* ═══════════════════════════════════════════════════════════════
名册 / 人
═══════════════════════════════════════════════════════════════ */
private function people(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$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::personDetail($p, $pid));
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
self::applyPersonWrite($id, $p, $fam, $pid);
Api::ok(self::personDetail((array)Person::find($id), $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Person::delete($id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$name = Api::str('name', '', 50);
if ($name === '') { Api::fail('得写个名字。', 'unprocessable', 422); }
$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
$list = Person::all($fam, Api::str('q'), Api::str('kind', 'member'), $pid);
// ⚠️ 逐行脱敏(见 stripSensitivePerson 的说明:网页要、App 不要)
$safe = [];
foreach ($list as $row) {
$safe[] = self::stripSensitivePerson((array)$row);
}
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 的字段**去掉(就地改,返回同一个数组)。
*
* 🔴 v45 新增(用户 2026-10-12 接服务器时发现):
* `Person::all()` 是 `SELECT p.*`,于是把 `phone_hash` /
* `phone_encrypted` / `id_card_encrypted` 一起吐给了 App。
* 这三个都是**敏感物**:
* · `phone_encrypted` / `id_card_encrypted` 是 AES 密文,
* 给到客户端就等于把**离线爆破的材料**递出去了;
* · `phone_hash` 是"手机号是否属于这个家"的判定键,同样不该外流。
* App 侧一个都用不到(显示手机号要的是**解密后的明文**,
* 走的是另一条路)。
*
* ⚠️ 为什么在**这一层**脱敏,而不去改 `Person::all()`:
* 网页端**确实在用**这些字段 —— `ContactImporter` 靠 `phone_hash`
* 做导入去重、`PersonQuery` 靠 `phone_encrypted` 支持"按手机号搜索"。
* 从 `all()` 里删会**打断网页功能**。
* ⇒ 网页要、App 不要 ⇒ 差异就该落在"出口"这一层。
*
* ⚠️ 顺手去掉的还有几个**纯服务端内部**字段(`is_placeholder` 之外的
* 统计/审计列),App 的 UI 里没有对应位置,传了只是徒增体积。
*/
private static function stripSensitivePerson(array $row): array
{
unset(
$row['phone_hash'],
$row['phone_encrypted'],
$row['id_card_encrypted'],
// 生日相关的三个原始列:App 显示生日只用 `birthday`,
// 而 `birthday_id_card` / `birthday_lunar` 是**推算生日**用的中间量
// (农历、证件号),属于隐私推算材料,没必要下发。
$row['birthday_id_card'],
$row['birthday_lunar'],
$row['birthday_lunar_leap']
);
return $row;
}
/* ═══════════════════════════════════════════════════════════════
通知
═══════════════════════════════════════════════════════════════ */
/**
* 家谱(`GET /api/v1/kin`)。
*
* 🔴 为什么单独一个接口、而不是塞进 `/roster`:
* `/roster` 是"**我名册里**的人"(可筛可搜,数据是**一堆人**);
* 家谱是"**以我为中心的关系图**"(谁是谁的父母、配偶、子女,
* 是**一张网**)。两者的形状与用途都不同,塞在一起会让
* 名册接口多背一份"大部分人不用"的图谱数据。
*
* ⚠️ 用的是 `Kinship::familyMap($fam, $me, $people, up, down)` ——
* 与 Web 端 `kin/tree.php` **同一个方法**,所以端上与网页看到的
* 亲属关系必然一致(不会出现"App 说他是堂兄、网页说是表兄")。
* `$up` / `$down` 用默认 2 —— 手机上再往外排就看不下了。
*/
private function kin(): void
{
[$fam, $pid] = Api::me();
$people = Person::map($fam);
$map = Kinship::familyMap($fam, $pid, $people, 2, 2);
$graph = Kinship::graph($fam);
// ---- 把人整理成"一人一条 + 关系字段"的形状,便于端上直接渲染 ----
$byGen = [];
$gen = $map['gen'] ?? [];
$ago = $map['unit_of'] ?? [];
foreach ($gen as $id => $g) {
if (!isset($people[$id])) { continue; }
$row = $people[$id];
$label = Kinship::label($graph, $pid, (int)$id, $people);
$byGen[] = [
'id' => (int)$id,
'name' => (string)$row['name'],
'shown' => \App\Nickname::display($pid, $row) ?: (string)$row['name'],
'label' => $label, // 「我该怎么称呼他」
'gen' => (int)$g, // 0 = 我;负 = 长辈;正 = 晚辈
'avatar' => $row['avatar'] ?? null,
'avatar_color' => $row['avatar_color'] ?? null,
'unit' => (int)($ago[$id] ?? 0), // 户(夫妻单元)号
// 直接关系(拍平,端上画连线/分组用)
'parents' => array_map('intval', Kinship::parentIds($graph, (int)$id)),
'children' => array_map('intval', Kinship::childIds($graph, (int)$id)),
'spouses' => array_map('intval', Kinship::spouseIds($graph, (int)$id)),
'siblings' => array_map('intval', Kinship::siblingIds($graph, (int)$id)),
];
}
// 按代数排序(长辈在前、晚辈在后),同代按名字拼音稳定
usort($byGen, static function (array $a, array $b): int {
return $a['gen'] <=> $b['gen'] ?: strcmp($a['name'], $b['name']);
});
Api::ok([
'meId' => $pid,
'people' => $byGen,
'units' => $map['units'] ?? [],
'up' => 2,
'down' => 2,
'total' => count($byGen),
]);
}
private function notifications(int $id, string $sub, string $method): void
{
[, $pid] = Api::me();
if ($sub === 'read' || ($method === 'POST' && $sub === 'read')) {
Api::ok(['marked' => Notification::markAllRead($pid)]);
}
if ($sub === 'clear' || $method === 'DELETE') {
if ($id > 0) {
Api::ok(['deleted' => Notification::delete($id, $pid)]);
}
Api::ok(['cleared' => Notification::clearMine($pid)]);
}
Api::ok([
'unread' => Notification::unreadCount($pid),
'items' => Notification::mine($pid, 50),
]);
}
/* ═══════════════════════════════════════════════════════════════
日程
═══════════════════════════════════════════════════════════════ */
private function calendar(string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($sub === 'books') { // GET /calendar/books
Api::ok(Calendar::mine($fam, $pid));
}
// GET /calendar?from=2026-10-01&to=2026-10-31 (默认本月)
$from = Api::str('from', date('Y-m-01'));
$to = Api::str('to', date('Y-m-t'));
$cals = Calendar::mine($fam, $pid);
$rows = Calendar::month($fam, $cals, $pid, $from, $to);
Api::ok($rows);
}
private function events(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$ev = $this->eventFor($fam, $id);
if (!$ev) { Api::notFound('这条日程'); }
if (!Calendar::canWriteEvent($ev, $pid)) {
Api::fail('这本日历你没有写权限。', 'forbidden', 403);
}
if ($method === 'GET') { Api::ok($ev); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$this->writeEvent($pid, $ev);
}
if ($method === 'DELETE') {
Api::requireWrite();
$this->deleteEvent($ev);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$calId = Api::int('calendar_id');
$cal = Calendar::mineOne($fam, $pid, $calId);
if (!$cal || !Calendar::canWrite($cal, $pid) || Calendar::isReadonlyKind((string)$cal['kind'])) {
Api::fail('这本日历不能写入。', 'forbidden', 403);
}
$d = $this->eventData(null);
if ($d['starts_at'] === '') { Api::fail('开始时间不能为空。', 'unprocessable', 422); }
if ($d['title'] === '') { Api::fail('日程得有个标题。', 'unprocessable', 422); }
$d['created_by'] = $pid;
$newId = Calendar::addEvent((int)$cal['id'], $d);
Api::ok($this->eventFor($fam, $newId), 201);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
/** 取一条日程(本家范围内;不存在返回 null) */
private function eventFor(int $fam, int $id): ?array
{
$ev = Calendar::event($id);
if (!$ev) { return null; }
$cal = Calendar::find((int)$ev['calendar_id']);
if (!$cal || (int)$cal['family_id'] !== $fam) { return null; }
$ev['skips'] = Calendar::skipsOf($id);
return $ev;
}
/**
* 日程写入字段(新建与更新共用;`$cur` 非空做"只改给了的字段"回退)。
*
* 🔴 重复日程的**三档范围**(改/删这一次、这一次及以后、全部)——
* 网页那两段逻辑(event_update / event_del)比较长,这里**不复制**它们,
* 而是让 App 用同一组 `scope` + `occ` 参数,并在 API 里做同样的三档处理。
* (复制两份业务规则是"必然长歪"的写法;这里把差异收在一个私有方法里。)
*/
private function eventData(?array $cur): array
{
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
$starts = $has('starts_at') ? Api::dt('starts_at') : (string)($cur['starts_at'] ?? '');
$ends = $has('ends_at') ? Api::dt('ends_at') : ($cur['ends_at'] ?? null);
$allDay = $has('all_day') ? Api::bool('all_day') : !empty($cur['all_day']);
if ($allDay) {
$starts = $starts !== null && $starts !== '' ? substr((string)$starts, 0, 10) . ' 00:00:00' : (string)$starts;
$ends = $ends !== null && $ends !== '' ? substr((string)$ends, 0, 10) . ' 00:00:00' : null;
}
return [
'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''),
'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''),
'starts_at' => (string)($starts ?? ''),
'ends_at' => $ends,
'all_day' => $allDay,
'reminder_minutes' => $has('reminder_minutes') ? Api::param('reminder_minutes', [])
: ($cur['reminder_minutes'] ?? ''),
'status' => $has('status') ? Api::str('status', 'confirmed')
: (string)($cur['status'] ?? 'confirmed'),
'transp' => $has('transp') ? Api::bool('transp') : !empty($cur['transp']),
'rrule' => $has('freq') || $has('rrule') ? $this->rruleFromBody()
: (string)($cur['rrule'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
];
}
/** 更新一条日程(含重复日程的三档范围) */
private function writeEvent(int $pid, array $ev): void
{
$d = $this->eventData($ev);
$scope = Api::str('scope', 'all');
$occ = substr(Api::str('occ'), 0, 10);
$rrule = (string)($ev['rrule'] ?? '');
$hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ);
if ($rrule !== '' && $hasOcc && $scope === 'once') {
// 只改这一次:原系列跳掉那天 + 落一条独立单次
Calendar::detachOnce(['id' => (int)$ev['id'], 'calendar_id' => (int)$ev['calendar_id']],
$occ, $d, $pid);
Api::ok(['updated' => (int)$ev['id'], 'scope' => 'once']);
}
if ($rrule !== '' && $hasOcc && $scope === 'future') {
// 这一次及以后:老系列截到前一天,新系列从这天起
$rp = Repeat::parse($rrule);
$cut = date('Y-m-d', strtotime($occ . ' -1 day'));
$rp['until'] = $cut;
$rp['count'] = 0;
Calendar::updateEvent((int)$ev['id'], [
'title' => (string)($ev['title'] ?? ''),
'location' => (string)($ev['location'] ?? ''),
'starts_at' => (string)$ev['starts_at'],
'ends_at' => $ev['ends_at'] ?: null,
'all_day' => (int)($ev['all_day'] ?? 0) === 1,
'reminder_minutes' => $ev['reminder_minutes'] ?? '',
'status' => (string)($ev['status'] ?? 'confirmed'),
'transp' => (int)($ev['transp'] ?? 0) === 1,
'rrule' => 'FREQ=' . strtoupper((string)$rp['freq'])
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z',
'note' => (string)($ev['note'] ?? ''),
]);
$d2 = $d;
$d2['starts_at'] = $occ . ' ' . substr((string)$d['starts_at'], 11, 8);
$d2['ends_at'] = $d['ends_at'] !== null ? $occ . ' ' . substr((string)$d['ends_at'], 11, 8) : null;
$d2['created_by'] = $pid;
$newId = Calendar::addEvent((int)$ev['calendar_id'], $d2);
Api::ok(['updated' => (int)$ev['id'], 'new_series' => $newId, 'scope' => 'future']);
}
Calendar::updateEvent((int)$ev['id'], $d);
Api::ok($this->eventFor((int)Api::me()[0], (int)$ev['id']));
}
/** 删除一条日程(含重复日程的三档范围) */
private function deleteEvent(array $ev): void
{
$id = (int)$ev['id'];
$scope = Api::str('scope', 'all');
$occ = substr(Api::str('occ'), 0, 10);
$rrule = (string)($ev['rrule'] ?? '');
$hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ);
if ($rrule !== '' && $hasOcc && $scope === 'once') {
Calendar::skipOnce($id, $occ);
Api::ok(['skipped' => $occ, 'scope' => 'once']);
}
if ($rrule !== '' && $hasOcc && $scope === 'future') {
$rp = Repeat::parse($rrule);
$cut = date('Y-m-d', strtotime($occ . ' -1 day'));
$rp['count'] = 0;
$rp['until'] = $cut;
$firstDay = substr((string)$ev['starts_at'], 0, 10);
if ($cut < $firstDay) {
Calendar::deleteEvent($id);
Api::ok(['deleted' => $id, 'scope' => 'future']);
}
$newRule = 'FREQ=' . strtoupper((string)$rp['freq'])
. (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '')
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z';
Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?')
->execute([$newRule, $id]);
Api::ok(['truncated_to' => $cut, 'scope' => 'future']);
}
Calendar::deleteEvent($id);
Api::ok(['deleted' => $id]);
}
/**
* 重复规则:接受两种写法 ——
* · `rrule` 直接给 RFC 5545 原文(如 `FREQ=WEEKLY;COUNT=5`);
* · 或用 `freq` + `rep_end` + `rep_count` / `rep_until` 三个字段(与网页表单同构)。
* ⚠️ 后者是关键:App 端做重复设置时最自然的就是"选频率、选结束方式",
* 不该逼它去拼 RRULE 字符串(那是把服务端的实现细节漏给了客户端)。
*/
private function rruleFromBody(): string
{
$raw = Api::str('rrule');
if ($raw !== '') { return $raw; }
$freq = Api::str('freq');
if ($freq === '') { return ''; }
return Repeat::build([
'freq' => $freq,
'rep_end' => Api::str('rep_end', 'never'),
'rep_count' => Api::int('rep_count', 10),
'rep_until' => Api::str('rep_until', ''),
]);
}
/* ═══════════════════════════════════════════════════════════════
纪念日(days)
═══════════════════════════════════════════════════════════════ */
private function days(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$a = Anniversary::find($fam, $id);
if (!$a) { Api::notFound('这条纪念日'); }
if ($method === 'GET') { Api::ok($a); }
if ($method === 'DELETE') {
Api::requireWrite();
Anniversary::remove($fam, $id);
Api::ok(['deleted' => $id]);
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Anniversary::save($fam, $this->annivData($a), $id);
Api::ok(Anniversary::find($fam, $id));
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$d = $this->annivData(null);
if ($d['title'] === '') { Api::fail('纪念日得有个名字。', 'unprocessable', 422); }
$newId = Anniversary::save($fam, $d, 0);
Api::ok(Anniversary::find($fam, $newId), 201);
}
Api::ok(Anniversary::all($fam, $pid));
}
/** 纪念日字段(契约与 days/save 路由一致) */
private function annivData(?array $cur): array
{
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
return [
'title' => $has('title') ? Api::str('title', '', 100) : (string)($cur['title'] ?? ''),
'repeat_type' => $has('repeat_type') ? Api::str('repeat_type', 'solar')
: (string)($cur['repeat_type'] ?? 'solar'),
'month' => $has('month') ? Api::int('month', 1) : (int)($cur['month'] ?? 1),
'day' => $has('day') ? Api::int('day', 1) : (int)($cur['day'] ?? 1),
'is_leap' => $has('is_leap') ? Api::bool('is_leap') : !empty($cur['is_leap']),
'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
'person_ids' => $has('person_ids') ? Api::ints('person_ids')
: array_map(static fn($p) => (int)$p['id'], (array)($cur['people'] ?? [])),
];
}
/* ═══════════════════════════════════════════════════════════════
任务
═══════════════════════════════════════════════════════════════ */
private function tasks(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$t = Task::find($fam, $id, $pid);
if (!$t) { Api::notFound('这项任务'); }
// 子动作:状态 / 搬看板 / 评论
if ($sub === 'status' && ($method === 'POST' || $method === 'PATCH')) {
Api::requireWrite();
Task::setStatus($fam, $id, Api::str('status', 'todo'), Api::int('position'));
Api::ok(Task::find($fam, $id, $pid));
}
if ($sub === 'move' && $method === 'POST') {
Api::requireWrite();
Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid);
Api::ok(Task::find($fam, $id, $pid));
}
if ($sub === 'comments') {
if ($method === 'GET') {
Api::ok(Task::comments($id));
}
if ($method === 'POST') {
Api::requireWrite();
$txt = Api::str('content', '', 4000);
if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); }
Api::ok(['id' => Task::addComment($id, $pid, $txt)], 201);
}
}
if ($method === 'GET') { Api::ok($t); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Task::update($fam, $id, [
'title' => Api::str('title', (string)$t['title'], 200),
'note' => Api::str('note', (string)($t['note'] ?? '')),
'due_at' => Api::dt('due_at', $t['due_at'] ?? null),
'repeat_rule' => Api::str('repeat_rule', (string)($t['repeat_rule'] ?? '')),
'parent_id' => Api::int('parent_id', (int)($t['parent_id'] ?? 0)),
'assignee_id' => Api::int('assignee_id', (int)($t['assignee_id'] ?? 0)),
'category_id' => Api::int('category_id', (int)($t['category_id'] ?? 0)),
'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)),
'status' => (string)$t['status'], // 状态只走 /status 子动作
]);
Api::ok(Task::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Task::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('任务得写清楚要做什么。', 'unprocessable', 422); }
$newId = Task::create($fam, $pid, [
'title' => $title,
'note' => Api::str('note', ''),
'due_at' => Api::dt('due_at', ''),
'repeat_rule' => Api::str('repeat_rule', ''),
'parent_id' => Api::int('parent_id'),
// 🔴 v44 第 5 条「负责人默认是自己」——API 建任务时同样默认自己,
// 与网页表单一致(不传 assignee_id 就是"我负责")。
'assignee_id' => Api::int('assignee_id', $pid),
'category_id' => Api::int('category_id'),
'board_id' => Api::int('board_id'),
'status' => Api::str('status', 'todo'),
]);
foreach (Api::people(Api::ints('shares'), $fam) as $sp) {
Task::setShare($newId, $sp, 'write');
}
Api::ok(Task::find($fam, $newId, $pid), 201);
}
// ---- 列表:三种视图 ----
// ?view=board → 按看板分组(默认)
// ?view=status&status=doing → 跨看板按状态
// ?view=done → 已完成(含分类/关键词过滤)
$view = Api::str('view', 'board');
if ($view === 'status') {
Api::ok(Task::byStatus($fam, $pid, Api::str('status', 'todo')));
}
if ($view === 'done') {
Api::ok(Task::doneList($fam, $pid, Api::int('category_id'), Api::str('keyword')));
}
// 看板视图:把"列"与"任务"一起给出来(App 一次请求就够画整屏)
$boards = Task::boards($fam);
/* ⚠️ `Task::boardColumns()` 返回的是**包了一层的数组**
(`['cols'=>[boardId => rows], 'boards'=>…, 'defId'=>…]`),
不是"boardId => rows"本身 —— 直接按 boardId 取会永远拿到空列
(实测:接口返回的 boards 里 tasks 全是 [],而断言只看"键在不在"就漏过去了)。
⇒ 必须取 `['cols']` 那一层。 */
$bc = Task::boardColumns($fam, $pid);
$cols = $bc['cols'] ?? [];
$out = [];
foreach ($boards as $b) {
$out[] = [
'id' => (int)$b['id'],
'name' => (string)$b['name'],
'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']),
'tasks' => array_values($cols[(int)$b['id']] ?? []),
];
}
if (!empty($cols[0])) { // 还没归到任何看板的兜底列
$out[] = ['id' => 0, 'name' => '其他', 'color' => '', 'tasks' => array_values($cols[0])];
}
Api::ok([
'boards' => $out,
'categories' => Task::categories($fam),
'status' => Task::STATUS,
'repeat' => Task::REPEAT,
]);
}
/* ═══════════════════════════════════════════════════════════════
观影
═══════════════════════════════════════════════════════════════ */
private function movies(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$m = Movie::find($fam, $id, $pid);
if (!$m) { Api::notFound('这条观影记录'); }
if ($sub === 'comments') {
if ($method === 'GET') {
$rv = MovieReview::mine($id, $pid);
Api::ok($rv ? MovieReview::forEvent($id) : []);
}
if ($method === 'POST') {
Api::requireWrite();
$rv = MovieReview::mine($id, $pid);
if (!$rv) { Api::fail('先写一条评价,才能评论。', 'unprocessable', 422); }
$txt = Api::str('content', '', 4000);
if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); }
$cid = MovieReview::addComment((int)$rv['id'], $pid, $txt,
Api::int('parent_id'), Api::str('target_person', ''));
if ($cid <= 0) { Api::fail('评论没保存下来(可能内容太长)。', 'unprocessable', 422); }
Api::ok(['id' => $cid], 201);
}
}
if ($sub === 'review' && ($method === 'POST' || $method === 'PUT' || $method === 'PATCH')) {
Api::requireWrite();
$my = MovieReview::mine($id, $pid);
$err = MovieReview::save(
$id, $fam, $pid,
Api::int('rating'),
Api::str('content', '', 8000),
array_values((array)Api::param('feelings', [])),
$my !== null
);
if ($err !== '') { Api::fail($err, 'unprocessable', 422); }
Api::ok(MovieReview::mine($id, $pid));
}
if ($method === 'GET') { Api::ok($m); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Movie::update($fam, $id, $this->movieData($m));
Api::ok(Movie::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Movie::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$d = $this->movieData(null);
if ($d['title'] === '') { Api::fail('片名不能为空。', 'unprocessable', 422); }
if ($d['starts_at'] === '') { Api::fail('观影时间不能为空。', 'unprocessable', 422); }
$newId = Movie::create($fam, $pid, $d);
Api::ok(Movie::find($fam, $newId, $pid), 201);
}
$list = Movie::all($fam, Api::str('keyword'), $pid);
[$per, $off] = Api::page(50);
Api::list(array_slice(array_values($list), $off, $per), count($list));
}
/** 观影写入字段(新建与更新共用;`$cur` 非空时做"只改给了的字段"的回退) */
private function movieData(?array $cur): array
{
$b = Api::body();
$pick = static function (string $k, $def) use ($b) {
return array_key_exists($k, $b) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET)
? null : $def; // null = "有人给了",下面按 Api::* 取真实值
};
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
return [
'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''),
'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''),
'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''),
'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
'info' => $has('info') ? Api::str('info', '') : (string)($cur['info'] ?? ''),
'cinema_id' => $has('cinema_id') ? Api::int('cinema_id') : (int)($cur['cinema_id'] ?? 0),
'attendees' => $has('attendees') ? Api::ints('attendees') : array_map(
static fn($a) => (int)$a['id'], (array)($cur['attendees'] ?? [])),
];
}
private function cinemas(int $id, string $method): void
{
[$fam] = Api::me();
if ($method === 'GET') { Api::ok(Cinema::all($fam)); }
if ($method === 'POST' || $method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$cur = $id > 0 ? Cinema::find($fam, $id) : null;
if ($id > 0 && !$cur) { Api::notFound('这家影院'); }
$name = Api::str('name', (string)($cur['name'] ?? ''), 100);
if ($name === '') { Api::fail('影院名称不能为空', 'unprocessable', 422); }
$err = Cinema::save(
$fam, $id,
Api::str('place', (string)($cur['place'] ?? ''), 100),
$name,
Api::str('address', (string)($cur['address'] ?? ''), 200) ?: null,
Cinema::normalizeColor(Api::str('color', (string)($cur['color'] ?? '#888888'), 16))
);
if ($err !== '') { Api::fail($err, 'conflict', 409); }
// save() 不返回 id ⇒ 新建时按"同地方同名"回查(表上有唯一性校验,够精确)
if ($id === 0) {
$id = 0;
foreach (Cinema::all($fam) as $c) {
if ((string)$c['name'] === $name
&& (string)($c['place'] ?? '') === Api::str('place')) {
$id = (int)$c['id']; break;
}
}
}
Api::ok(Cinema::find($fam, $id), $cur ? 200 : 201);
}
if ($method === 'DELETE' && $id > 0) {
Api::requireWrite();
Cinema::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
/* ═══════════════════════════════════════════════════════════════
旅程 / 行程
═══════════════════════════════════════════════════════════════ */
private function trips(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$t = Trip::find($fam, $id, $pid);
if (!$t) { Api::notFound('这趟旅程'); }
if ($sub === 'nodes') {
if ($method === 'GET') {
Api::ok(Trip::nodes($id, $pid));
}
if ($method === 'POST') {
Api::requireWrite();
$nid = Trip::saveNode($fam, $id, $this->nodeData(null));
Api::ok(Trip::node($nid), 201);
}
}
if ($sub === 'summary' && $method === 'GET') {
Api::ok(Trip::summary($id, $pid));
}
if ($method === 'GET') { Api::ok($t); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$d = [
'title' => Api::str('title', (string)$t['title'], 200),
'note' => Api::str('note', (string)($t['note'] ?? '')),
'summary' => Api::str('summary', (string)($t['summary'] ?? '')),
];
if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; }
Trip::update($fam, $id, $d);
Api::ok(Trip::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Trip::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('旅程得有个名字。', 'unprocessable', 422); }
$d = ['title' => $title, 'note' => Api::str('note', ''), 'summary' => Api::str('summary', '')];
if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; }
$newId = Trip::create($fam, $pid, $d);
Api::ok(Trip::find($fam, $newId, $pid), 201);
}
Api::ok(Trip::all($fam, Api::str('keyword')));
}
/** 行程节点字段(与网页 node_add 同一份契约) */
private function nodeData(?array $cur): array
{
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
return [
'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''),
'detail' => $has('detail') ? Api::str('detail', '') : (string)($cur['detail'] ?? ''),
'place' => $has('place') ? Api::str('place', '', 200) : (string)($cur['place'] ?? ''),
'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''),
'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''),
'node_type' => $has('node_type') ? Api::str('node_type', 'other') : (string)($cur['node_type'] ?? 'other'),
'status' => $has('status') ? Api::str('status', 'planned') : (string)($cur['status'] ?? 'planned'),
'category' => $has('category') ? Api::str('category', 'plan') : (string)($cur['category'] ?? 'plan'),
// stage 不在这里改:只由新建/显式动作决定(与网页 node_add 同一条纪律)
'stage' => $cur === null ? Api::str('stage', 'plan') : (string)($cur['stage'] ?? 'plan'),
'members' => $has('members') ? Api::ints('members') : [],
'travel_mode' => Api::str('travel_mode', (string)($cur['travel_mode'] ?? '')),
'vehicle_id' => Api::int('vehicle_id', (int)($cur['vehicle_id'] ?? 0)),
'line_name' => Api::str('line_name', ''),
'carrier' => Api::str('carrier', ''),
'trip_no' => Api::str('trip_no', ''),
];
}
/* ═══════════════════════════════════════════════════════════════
记账
═══════════════════════════════════════════════════════════════ */
private function ledger(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$lg = Ledger::find($fam, $id, $pid);
if (!$lg) { Api::notFound('这本账本'); }
if ($sub === 'entries') {
if ($method === 'GET') {
$es = Ledger::entries($id);
Api::ok(['items' => $es, 'summary' => Ledger::summary($es)]);
}
if ($method === 'POST') {
Api::requireWrite();
// ⚠️ `amount` 交给模型自己转分(Ledger::toCents)—— App 传元,
// 与网页表单同一口径;这里**不要**先转一次(会双重换算)。
$amount = Api::param('amount', '');
if (trim((string)$amount) === '') {
Api::fail('金额不能为空。', 'unprocessable', 422);
}
$eid = Ledger::addEntry($id, [
'kind' => Api::str('kind', 'expense'),
'amount' => $amount,
'title' => Api::str('title', '', 120),
'note' => Api::str('note', ''),
'person_id' => Api::int('person_id'),
], $pid);
Api::ok(['id' => $eid], 201);
}
}
if ($method === 'GET') {
$es = Ledger::entries($id);
Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]);
}
if ($method === 'DELETE') {
Api::requireWrite();
Ledger::remove($fam, $id, $pid);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$name = Api::str('name', '', 80);
/* ⚠️ `Ledger::defaultName()` **需要一个 ownerId 参数**(实测:
不给参数会 ArgumentCountError)—— 用当前人补上。 */
$newId = Ledger::create($fam, $pid, $name !== '' ? $name : Ledger::defaultName($pid));
Api::ok(Ledger::find($fam, $newId, $pid), 201);
}
Api::ok(Ledger::mine($fam, $pid));
}
/* ═══════════════════════════════════════════════════════════════
资源 / 书 / 笔记
═══════════════════════════════════════════════════════════════ */
private function resources(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$r = Resource::find($fam, $id, $pid);
if (!$r) { Api::notFound('这条资源'); }
if ($method === 'GET') { Api::ok($r); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Resource::update($fam, $id, [
'title' => Api::str('title', (string)$r['title'], 200),
'content' => Api::str('content', (string)($r['content'] ?? '')),
'category'=> Api::str('category', (string)($r['category'] ?? '')),
]);
Api::ok(Resource::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Resource::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('资源得有个标题。', 'unprocessable', 422); }
$newId = Resource::create($fam, $pid, $pid, [
'title' => $title,
'content' => Api::str('content', ''),
'category' => Api::str('category', ''),
]);
Api::ok(Resource::find($fam, $newId, $pid), 201);
}
Api::ok(Resource::all($fam, $pid, Api::int('owner_id'), Api::str('keyword'),
Api::str('sort', 'used')));
}
private function books(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$bk = Book::find($fam, $id);
if (!$bk) { Api::notFound('这本书'); }
if ($sub === 'notes' && $method === 'GET') { Api::ok(Book::notes($id, $pid)); }
if ($sub === 'notes' && $method === 'POST') {
Api::requireWrite();
$txt = Api::str('content', '', 8000);
if ($txt === '') { Api::fail('笔记内容不能为空。', 'unprocessable', 422); }
$nid = Book::addNote($fam, $id, $pid, [
'content' => $txt,
'kind' => Api::str('kind', 'note'),
'chapter' => Api::str('chapter', '', 120),
'loc' => Api::str('loc', '', 120),
]);
if ($nid <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); }
Api::ok(['id' => $nid], 201);
}
if ($method === 'GET') { Api::ok($bk); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$d = $bk;
if (array_key_exists('status', Api::body())) { $d['status'] = Api::str('status'); }
if (array_key_exists('current_page', Api::body())) { $d['current_page'] = Api::str('current_page'); }
if (array_key_exists('rating', Api::body())) { $d['rating'] = Api::int('rating'); }
Book::update($fam, $id, $d);
Api::ok(Book::find($fam, $id));
}
if ($method === 'DELETE') {
Api::requireWrite();
Book::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('书名不能为空。', 'unprocessable', 422); }
$newId = Book::create($fam, $pid, [
'title' => $title,
'author' => Api::str('author', '', 120),
'category' => Api::str('category', ''),
'status' => Api::str('status', 'want'),
]);
Api::ok(Book::find($fam, $newId), 201);
}
Api::ok(Book::all($fam, [
'status' => Api::str('status'),
'category' => Api::str('category'),
'owner' => Api::int('owner_id'),
'keyword' => Api::str('keyword'),
]));
}
private function notes(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$n = Note::find($fam, $id, $pid);
if (!$n) { Api::notFound('这条笔记'); }
if ($method === 'GET') { Api::ok($n); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Note::update($fam, $id, $pid, [
'title' => Api::str('title', (string)$n['title'], 200),
'body' => Api::str('body', (string)($n['body'] ?? '')),
'tags' => Api::param('tags', $n['tags'] ?? ''),
]);
Api::ok(Note::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Note::destroy($fam, $id, $pid);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); }
$newId = Note::create($fam, $pid, [
'title' => $title,
'body' => Api::str('body', ''),
'tags' => Api::param('tags', []),
]);
Api::ok(Note::find($fam, $newId, $pid), 201);
}
[$per, $off] = Api::page(50);
Api::ok([
'tags' => Note::tagCloud($fam, $pid),
'items' => Note::all($fam, $pid, Api::str('keyword'), Api::str('tag'), $per, $off),
]);
}
/* ═══════════════════════════════════════════════════════════════
v58 · 增量同步:各模块的「变更指纹」
═══════════════════════════════════════════════════════════════ */
/**
* 每个模块的指纹由哪些表合成。
*
* 🔴 为什么要这个接口(用户 2026-10-17:「首次全量,之后增量」):
* 端上每次打开都全量拉 12 个模块,其中 11 个绝大多数时候没有任何变化 ——
* 白耗流量、白等时间,用户还看不到"同步到底在干什么"。
* 有了指纹,端上先问一句「哪些模块变了」,**只拉变了的**。
*
* 🔴 指纹为什么是「行数 + 最大 id + 最大 updated_at」三件而不是只用 updated_at:
* 本项目**有一半的表根本没有 `updated_at` 列**(`movie_details`、
* `person_roster`、`anniversaries`、`ledger_entries` … 实测 61 张表里只有 16 张有)。
* 只用 updated_at 会把"新增一部电影"判成"没变" ⇒ 端上永远看不到新数据。
* 加入 `COUNT(*)` + `MAX(id)` 后,**新增与删除一定被抓到**。
*
* ⚠️ **已知局限(必须写在这里,别让后人以为它是万能的)**:
* "**修改**一张没有 updated_at 的表里**已有的一行**"(比如改某部电影的海报 key)
* 合成指纹前后一模一样 ⇒ 判成"没变"。
* 端上对此的兜底是**陈旧上限**:每过一段时间(或每 N 轮)强制全量一次
* (见端上 `FbSyncAll` 的 `FORCE_FULL_AFTER_MS`)。这是缓存一致性的标准折中,
* 不是漏洞 —— 但不能当成"已解决"。
*
* ⚠️ 表名是**写死的**(这里才知道"哪个模块由哪几张表组成")。
* 写错了不会报错,只会让那个模块的指纹偏旧。所以取指纹时一旦**一张表都没查到**
* 就返回 `null`,端上把 `null` 视为「无法判断 ⇒ 每次都拉」。
* ⇒ 失败方向是"多拉一次",**永远不会是"漏同步"**。
*
* ⚠️ 指纹**不按家庭过滤**(`calendar_events`/`movie_details` 这类表连 family_id 都没有):
* 别家写入只会让我们多拉一次,不会串数据(内容接口本身按家庭隔离)。
* 单家庭部署下等于没有影响。
*/
private const CHANGE_TABLES = [
'movies' => ['movie_details', 'movie_reviews', 'cinemas', 'movie_review_comments'],
'roster' => ['persons', 'person_roster', 'person_tags', 'person_groups',
'person_contacts', 'person_jobs', 'person_notes', 'nicknames', 'relations'],
'calendar' => ['calendar_events', 'calendar_event_overrides', 'events',
'event_attendees', 'event_photos', 'calendars'],
'tasks' => ['tasks', 'task_comments', 'task_boards', 'task_shares'],
'days' => ['anniversaries', 'anniversary_people'],
'trips' => ['trip_details', 'trip_nodes', 'trip_node_members',
'trip_node_revisions', 'trip_expenses'],
'ledger' => ['ledgers', 'ledger_entries', 'ledger_entry_snapshots', 'ledger_shares'],
'resources' => ['resources', 'resource_shares', 'attachments'],
'books' => ['books', 'book_chapters', 'book_notes'],
'notes' => ['notes', 'note_blocks', 'note_tags', 'note_shares'],
'notifications' => ['notifications'],
// 🔴 `kin`(家谱)与 `roster` **同源**:它整页都是从 persons / relations /
// nicknames 现算出来的,没有自己的表。所以指纹用的就是名册那三张 ——
// 名册一变,家谱必然要重拉;反过来也一样。分开列不会更准,只会多查几遍。
'kin' => ['persons', 'relations', 'nicknames'],
// 'home' 故意**不在**这里:它是派生页(天气、农历、统计都要现算),
// "变没变"由上面这些模块决定,而端上单独判它没有意义
// ⇒ 端上对 home 的处理是"只要别的模块变了就跟着重拉",见 FbSyncAll。
];
/**
* `GET /api/v1/changes` —— 返回各模块的变更指纹。
*
* 响应:
* ```
* { ok: true, data: {
* server_time: '2026-10-18T09:00:00+08:00',
* modules: { movies: '120:120:2026-10-17 21:03:11', roster: null, ... },
* stale_after_ms: 86400000
* } }
* ```
* 指纹是**不透明字符串**,端上只做「相等 / 不相等」比较,不解析。
* `null` = 服务端自己都判断不了(表缺失/查询失败)⇒ 端上必须拉。
*/
private function changes(string $method): void
{
Api::me(); // 必须带令牌:指纹本身不算机密,但没理由让匿名者试探"家里有没有新数据"
if ($method !== 'GET') {
Api::fail('只支持 GET。', 'method_not_allowed', 405);
}
$db = Database::get();
$out = [];
$unknown = [];
foreach (self::CHANGE_TABLES as $mod => $tables) {
$fp = self::moduleFingerprint($db, $tables);
$out[$mod] = $fp;
if ($fp === null) {
$unknown[] = $mod;
}
}
// 全库水位:所有模块指纹拼起来的哈希。
// ⚠️ 端上主要用它做**快速路径**(水位没变 ⇒ 连逐个比都不用比,整轮跳过)。
Api::ok([
'server_time' => date('c'),
'modules' => $out,
'all' => hash('sha256', json_encode($out) . ''),
'unknown' => $unknown,
// 端上"陈旧上限"的建议值(1 天);端上也可以自己定更短
'stale_after_ms' => 24 * 3600 * 1000,
]);
}
/**
* 算一个模块的指纹:`行数:最大id:最大updated_at`,各表累加/取最大。
*
* @param string[] $tables 候选表名(不存在或没权限的会被跳过)
* @return string|null 一张表都没查到 ⇒ null(无法判断)
*/
private static function moduleFingerprint(\PDO $db, array $tables): ?string
{
$rows = 0;
$maxId = 0;
$maxUpd = '';
$touched = 0;
foreach ($tables as $t) {
// ⚠️ 表名要拼进 SQL(标识符不能走占位符)⇒ 先严格白名单,防注入
if (preg_match('/^[a-z][a-z0-9_]{0,40}$/', $t) !== 1) {
continue;
}
try {
$st = $db->query("SELECT COUNT(*) AS c, COALESCE(MAX(`id`), 0) AS m FROM `$t`");
$row = $st !== false ? $st->fetch() : false;
if ($row === false) {
continue;
}
$touched++;
$rows += (int)$row['c'];
$maxId = max($maxId, (int)$row['m']);
} catch (\Throwable $e) {
continue; // 表不存在(老库)/ 没这张表 —— 跳过,不影响别的表
}
// updated_at 单独 try:**很多表没有这一列**(见 CHANGE_TABLES 的说明)
try {
$u = $db->query("SELECT COALESCE(MAX(`updated_at`), '') FROM `$t`");
$s = $u !== false ? (string)$u->fetchColumn() : '';
if ($s > $maxUpd) {
$maxUpd = $s;
}
} catch (\Throwable $e) {
// 这一列不存在:只靠行数 + 最大 id 兜(新增/删除仍能被抓到)
}
}
if ($touched === 0) {
return null;
}
return $rows . ':' . $maxId . ':' . $maxUpd;
}
}