Files
FamilyBook/app/ApiHandler.php
T
laoyang ebaed21920 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} 复核通过。
2026-10-18 03:34:47 +08:00

1969 lines
100 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,
'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;
// 🔴 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;
}
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());
}
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' => (string)($row['avatar'] ?? ''),
'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。
⚠️ 为什么不用数据库表:这只是一个"短期计数器",
为它加表 + 写迁移的代价大于收益(本应用是单机部署)。
⇒ 落 `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): 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'),
]);
}
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);
// ---- 天气(三级回落:个人经纬度 → 内置城市表 → 家庭级兜底 → 不显示)----
$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'],
'today_events' => $todayEvents,
'tasks' => $tasks,
'unread' => Notification::unreadCount($pid),
]);
}
/* ═══════════════════════════════════════════════════════════════
名册 / 人
═══════════════════════════════════════════════════════════════ */
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),
]);
}
}