Files
FamilyBook/app/ApiHandler.php
T
laoyang e44f37e902 财务折线:补 /ledger/<id>/timeline API 端点 #24
用户原话(2026-10-28):「鸿蒙端对齐网页端」。财务概览卡「净资产变化」在
端上是空占位,数据源 Web 端早有(Ledger::timeline + ledger/total_chart),
只缺一个 API 给端上。

· GET /api/v1/ledger/<id>/timeline —— 返回时序点数组(date/net/asset/debt)
  加当前汇总。与 Web 端 total_chart 用**同一份 Ledger::timeline($id)**,
  两端曲线必然一致。

判据:_i24_timeline_api.py 8/0(2xx · items 数组 · 每点 net=asset-debt ·
有数据源),_i24_falsify.py 注掉 timeline → ④ 变红。
2026-10-28 21:47:27 +08:00

3180 lines
168 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,
/* 🔴 v62:影院管理补齐(用户点名「增加影院/修改影院/高级搜索/
统计/年度报告」)。端上可据此探测:老服务端没有这三条时
只显示影院列表,不显示搜索/统计/报告的入口。 */
'movies_stats' => 1,
'movies_report' => 1,
'movies_search' => 1,
/* 🔴 v64(用户 2026-10-19):端上「记一场」要与网页端**同步实现**
—— 片名联想 + 一键获取简介与海报(豆瓣),海报/观影照片多图上传。
⚠️ 这两件事网页端早就有(`movies/douban` 路由 + `movies/create`
的 poster/photo 槽位),缺的只是**移动端入口**:
网页那条走 `index.php?r=movies/douban`,App 打不到。
⇒ `movies_douban` = `/movies/douban`(suggest / 抓取 / 缩略图代理);
`movie_media` = `/movies/<id>/poster|photos`(multipart 上传)。
端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */
/* 🔴 v66:旅程「车辆管理 / 节点类型」两条 CRUD 接口。
端上据此探测:老服务端没有它们时,设置菜单里那两项
只是说明,不显示增删改按钮。 */
'trips_vehicles' => 1,
'trips_nodetypes' => 1,
'movies_douban' => 1,
'movie_media' => 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;
/* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。
⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 ——
`Api::dispatch()` 只解析三段(`/<res>/<id>/<sub>`),
四段的 `/trips/vehicles` 会被当成「trips 的第 0 条」而落到列表。 */
case 'vehicles': $this->vehicles($id, $method); return;
case 'nodetypes': $this->nodeTypes($id, $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, $sub, $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'] ?? '')
);
/* 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容,
例如**工作单位、职务**。」
⇒ 端上列表要显示"当前那段工作经历"的单位/职务。
⚠️ 具体经历在下面**一次批量**取回(`PersonJob::mapFor`),
这里只准备字段位——**绝不在这里逐人查库**(N+1)。 */
$row['jobs'] = [];
// 🔴 脱敏:名册行同样是 `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)
));
}
/* 🔴 v62:**一次**把所有人的工作经历取回来(`IN (…)` 单条 SQL)——
⚠️ 不能在装饰循环里逐人 `PersonJob::list()`:名册上千人就是上千次查询。
排序与 `PersonJob::list()` 一致,端上取第一段即"当前/最近的那段"。 */
$jobsOf = PersonJob::mapFor(array_map(static fn(array $r): int => (int)$r['id'], $rows));
foreach ($rows as &$row2) {
$row2['jobs'] = $jobsOf[(int)$row2['id']] ?? [];
}
unset($row2);
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('这条日程'); }
/* 🔴 v73(2026-10-21 真机实测):**读**不该要求写权限。
原先这里是"先判 canWriteEvent、再分 GET/PATCH/DELETE",
后果:共享给我**只读**的日历本里的日程,连**详情**都打不开 ——
客户端表现为"部分详情没读回来:读不到详情",而那条日程
在列表里好好的(列表走 /calendar,不经过这里)。
允许读的安全依据是 `eventFor()` 已经卡了 `family_id`:
跨家庭的 id 在上一行就 404 了,能走到这里的一定是同家庭成员,
而 `Calendar::month()` 本来就对成员开放全家的日程。
⇒ 写权限只挡**写**(PATCH/PUT/DELETE),读一律放行。 */
if ($method === 'GET') { Api::ok($ev); }
if (!Calendar::canWriteEvent($ev, $pid)) {
Api::fail('这本日历你没有写权限。', 'forbidden', 403);
}
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') {
// 单条也要带上关联人,否则端上"编辑"没法回填(见 annivData 的说明)
$a['person_ids'] = Anniversary::peopleOf($a);
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);
}
/* 列表:自定义的每行补一个 `person_ids`。
⚠️ `Anniversary::list()` 是 `SELECT * FROM anniversaries` —— 不含关联人,
端上就只能显示"没有关联人",编辑时也回填不了。
(派生出来的生日行 `source` 是 `birthday_*`,人就在 `person_id` 里,不用补。) */
$rows = Anniversary::all($fam, $pid);
foreach ($rows as &$r) {
if ((string)($r['source'] ?? 'custom') === 'custom') {
$r['person_ids'] = Anniversary::peopleOf($r);
}
}
unset($r);
Api::ok($rows);
}
/** 纪念日字段(契约与 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')
: $this->annivPeopleOf($cur),
];
}
/**
* 现有纪念日的关联人(PATCH 未带 `person_ids` 时的兜底)。
*
* 🔴🔴 2026-10-20 修的数据丢失:原来写的是 `$cur['people'] ?? []`,
* 而 `$cur` 来自 `Anniversary::find()` —— 那条 SQL 是 `SELECT * FROM anniversaries`,
* **根本不含 `people` 这个键** ⇒ 兜底恒为 `[]` ⇒
* 「只改个标题」的 PATCH 会把这条纪念日的关联人**全部清空**。
* 网页端没暴露,是因为网页表单每次都会把选人框整份提交上来;
* 而 App 端做「编辑」时很自然只发改动的那几个字段 —— 一改就丢人。
* 这里改成真去 `anniversary_people` 读一次。
*
* ⚠️ `peopleOf()` 自己带静态缓存,同一请求里反复读同一 id 不会反复查库。
*
* @param array|null $cur 当前记录(新建时为 null)
* @return int[] 关联人 person id
*/
private function annivPeopleOf(?array $cur): array
{
if (!$cur) {
return [];
}
// 调用方**已经**把 people 填进来的话,优先用它的(省一次查询)
if (is_array($cur['people'] ?? null) && $cur['people']) {
return array_map(static fn($p) => (int)($p['id'] ?? 0), $cur['people']);
}
return Anniversary::peopleOf($cur);
}
/* ═══════════════════════════════════════════════════════════════
任务
═══════════════════════════════════════════════════════════════ */
/**
* 一条任务 → 给 App 的形状。
*
* 🔴 v79 为什么要包这一层:`Task::find()` 只 SELECT `t.*`,**不含看板名/看板色**
* (网页端是在模板里再查一次)。端上面板要显示"这条在哪个看板"、
* 还要拿它的颜色去画那颗小圆点 —— 让端上为了两个字段再打一次
* `/tasks?view=boards` 是多余的一跳,就在服务端补齐。
* `board_color_calc` 与首页任务用的是**同一个**派生口径,
* 否则同一个看板在首页和详情里是两种颜色(用户能一眼看出来)。
*/
private static function taskOut(int $fam, int $id, int $pid): array
{
$t = Task::find($fam, $id, $pid);
if (!$t) {
return [];
}
$bid = (int)($t['board_id'] ?? 0);
$t['board_name'] = Task::boardName($fam, $bid > 0 ? $bid : null);
$t['board_color_calc'] = Task::boardColorById($fam, $bid > 0 ? $bid : null);
return $t;
}
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(self::taskOut($fam, $id, $pid));
}
if ($sub === 'move' && $method === 'POST') {
Api::requireWrite();
Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid);
Api::ok(self::taskOut($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);
}
}
/* ═══════════════════════════════════════════════════════════
v79(用户 2026-10-22):「鸿蒙端的功能,也需要同样适配……
任务模块的功能需要完善。」
⇒ 端上要对齐网页 v78 的「就地面板」,而面板里的**加附件**一直没有 API
(网页端走的是 `tasks/attach_add` 这条表单路由)。
────────────────────────────────────────────────────────────
POST /api/v1/tasks/<id>/attach multipart,字段名 `file`
DELETE /api/v1/tasks/<id>/attach 参数 `attachment_id`
⚠️ 路由只解析「资源/id/子动作」**三段**(`Api::dispatch`),没有第四段
⇒ 删附件**必须**把 id 放参数里。写成 `/attach/<aid>` 的话,
`Api::dispatch` 只会给到 `$sub='attach'`,aid 直接丢掉(静默 404)。
⚠️ 两个分支都回**整条任务**(`Task::find`,含 attachs):端上拿到就能
直接重画面板,不用再补一次 GET。
⚠️ 权限除了 `requireWrite()` 还要 `Task::canWrite()`:前者只说明
"这个账号不是只读访客",后者才说明"这条任务归我管"。
═══════════════════════════════════════════════════════════ */
if ($sub === 'attach') {
Api::requireWrite();
if (!Task::canWrite($t, $pid)) {
Api::fail('这项任务不能改。', 'forbidden', 403);
}
if ($method === 'POST') {
$file = $_FILES['file'] ?? null;
if (!is_array($file)) {
Api::fail('没有收到文件。', 'unprocessable', 422);
}
$r = \App\Attachment::save($fam, Task::ATTACH, $id, $file, $pid);
if (empty($r['ok'])) {
$msg = (string)($r['error'] ?? '');
Api::fail($msg !== '' ? $msg : '文件没存下来', 'unprocessable', 422);
}
Api::ok(self::taskOut($fam, $id, $pid), 201);
}
if ($method === 'DELETE') {
$aid = Api::int('attachment_id');
if ($aid <= 0) {
Api::fail('要指定删哪个附件。', 'bad_request', 400);
}
if (!\App\Attachment::removeFrom(Task::ATTACH, $id, $aid)) {
Api::notFound('这个附件');
}
Api::ok(self::taskOut($fam, $id, $pid));
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'GET') { Api::ok(self::taskOut($fam, $id, $pid)); }
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(self::taskOut($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(self::taskOut($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')));
}
/* v79:**只给元数据**(看板 + 分类),不带任务。
🔴 为什么需要它:端上「挪看板 / 选分类」要的是"**全部**看板",
而看板视图(下面那段)每列都塞满了任务,为了拿 5 个名字去解析几百条任务
很浪费;更关键的是——**空看板在别的视图里根本不存在**
(端上原来只能从已加载任务里反推看板 ⇒ 空看板永远选不到)。
网页端同一份数据来自 `Task::boards()` / `Task::categories()`。 */
if ($view === 'boards') {
$bs = [];
foreach (Task::boards($fam) as $b) {
$bs[] = [
'id' => (int)$b['id'],
'name' => (string)$b['name'],
'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']),
'open' => (int)($b['open_count'] ?? 0),
];
}
$cs = [];
foreach (Task::categories($fam) as $c) {
$cs[] = ['id' => (int)$c['id'], 'name' => (string)$c['name']];
}
Api::ok([
'boards' => $bs,
'categories' => $cs,
'status' => Task::STATUS,
'repeat' => Task::REPEAT,
]);
}
// 看板视图:把"列"与"任务"一起给出来(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();
/* 🔴 v62(用户 2026-10-19):「设置里面的影院管理,这些功能都还没有,
应该补充,就是增加影院呢、修改影院呢、**高级搜索、统计、年度报告**,
这些全部都应该补充进去。」
⇒ 网页端早就有 `movies/search` / `movies/stats` / `movies/report`
(见 `MovieStats`),但 **API 一个都没暴露** —— 端上只能干看着。
这里按网页同一套 `MovieStats` 暴露成子路由,口径与网页**完全一致**
(统计数字两边必须一样,否则用户会发现"网页 124 部、App 121 部")。
⚠️ 这三个都是 **家族级**(不针对某一条观影记录)⇒ 必须在 `$id > 0`
分支**之前**处理,否则 `/movies/stats` 会被当成"id=0 的详情"。 */
if ($sub === 'stats' && $id === 0) {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
Api::ok([
'overview' => MovieStats::overview($fam),
'by_cinema'=> MovieStats::byCinema($fam),
'by_person'=> MovieStats::byPerson($fam, $pid),
'by_month' => MovieStats::byMonth($fam, 12),
'by_year' => MovieStats::byYear($fam),
]);
}
if ($sub === 'report' && $id === 0) {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
$years = MovieStats::years($fam);
$year = (int)Api::int('year');
// 没给年份 / 给的年份没有数据 ⇒ 退回最新有数据的那一年(与网页同一规则)
if ($year <= 0 || !in_array($year, array_map('intval', $years), true)) {
$year = count($years) > 0 ? (int)$years[0] : (int)date('Y');
}
Api::ok([
'year' => $year,
'years' => count($years) > 0 ? array_map('intval', $years) : [$year],
'stats' => MovieStats::annual($fam, $pid, $year),
]);
}
if ($sub === 'search' && $id === 0) {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
$p = [
'title' => Api::str('title'),
'cinema_id' => Api::int('cinema_id'),
'person_id' => Api::int('person_id'),
'date_from' => (string)(Api::param('date_from') ?? ''),
'date_to' => (string)(Api::param('date_to') ?? ''),
];
$has = false;
foreach ($p as $v) { if ($v !== '' && $v !== 0) { $has = true; break; } }
// ⚠️ 条件全空时**不查全库**(那等于"列出所有观影",不是搜索)——
// 与网页 `hasCond` 同一行为,端上据此提示"先设条件"。
Api::ok([
'has_cond' => $has ? 1 : 0,
'rows' => $has ? MovieStats::search($fam, $p, $pid) : [],
]);
}
/* ────────────────────────────────────────────────────────────────
🔴 v64(用户 2026-10-19)**豆瓣自动补充**(片名联想 / 抓取详情+海报 / 缩略图代理)
用户原话:「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。
这个在网页端是直接实现了的,我们的手机端也必须同步实现。」
⇒ 网页端那条路由(`index.php` 的 `movies/douban`)**一行没改、逻辑照搬**,
只是换一个 App 打得到的入口。三件事与网页**逐条对齐**:
· `action=suggest` → 片名候选(下拉联想);
· `action=thumb` → 代理豆瓣缩略图(**回图片字节,不是 JSON 信封**);
· 不带 action → 抓详情 + 海报落盘(poster 槽位管线),回 JSON。
⚠️ 为什么缩略图必须由服务端代取:豆瓣有防盗链,客户端直连一律 403。
`Douban::thumb()` 里已经带 Referer 了。
⚠️ 为什么用 query 传 action 而不是 `/movies/douban/thumb`:
`Api::dispatch()` 只解析「资源 / id / 子动作」两层,
三段路径里第三段会被丢掉。query 是最省事、也不跟路由打架的形态。
⚠️ **路径必须是 `movies/douban`**:`Api::dispatch()` 取 `parts[1]` 当子动作,
非数字时 `$id === 0` ⇒ 正好落在 $id>0 分支**之前**,不会被当成"id=0 的详情"。
──────────────────────────────────────────────────────────────── */
if ($sub === 'douban' && $id === 0) {
$act = Api::str('action');
if ($act === 'suggest') {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
$res = Douban::suggest(Api::str('q'));
if (!$res['ok']) {
Api::fail((string)($res['error'] ?? '搜索失败'), 'upstream', 502);
}
$items = (array)($res['items'] ?? []);
/* ⚠️ 逐个把候选缩略图换成 **API 形式**的代理地址。
端上拿不到 `index.php?r=` 那个网页入口(见 `Douban::apiThumbUrl`)。
⚠️ 原始地址只在过了白名单时才下发(见 `buildSuggestItems` 的 `img`)。 */
foreach ($items as &$it) {
$rawImg = (string)($it['img'] ?? '');
$it['thumb'] = $rawImg !== '' ? Douban::apiThumbUrl($rawImg) : '';
unset($it['img']);
}
unset($it);
Api::ok(['items' => array_values($items)]);
}
if ($act === 'thumb') {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
$img = Douban::thumb(Api::str('u'));
if ($img === null) {
Api::fail('取不到这张图。', 'not_found', 404);
}
// ⚠️ 这里**故意不走 Api 信封**:调用方是 `Image()`,它要的是图片字节。
// 与 `media.php` 同一口径(那个也不走信封)。
if (!headers_sent()) {
header('Content-Type: ' . $img['mime']);
header('Content-Length: ' . strlen($img['body']));
header('Cache-Control: public, max-age=86400');
}
echo $img['body'];
exit;
}
if ($act !== '') {
Api::fail('不支持的 action。', 'bad_request', 400);
}
// 默认:按 id 或片名抓详情 + 海报落盘
if ($method !== 'POST' && $method !== 'GET') {
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
Api::requireWrite();
$idDb = Api::str('id'); // ⚠️ 豆瓣条目 id(**字符串**,不是本函数参数里那个数字 id)
$qDb = Api::str('q');
if ($idDb !== '') {
$resDb = Douban::fetchById($idDb);
} elseif ($qDb !== '') {
$resDb = Douban::fetchByTitle($qDb);
} else {
Api::fail('请输入电影名称。', 'unprocessable', 422);
}
if (!$resDb['ok']) {
Api::fail((string)($resDb['error'] ?? '抓取失败'), 'upstream', 502);
}
// 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口)
// ⚠️ 与网页那条路由**同一套**(`Upload::image`),端上不做任何裁剪 ——
// 服务端保证落库的一定是 2:3。
$posterKey = '';
$posterShow = '';
if ((string)($resDb['poster_url'] ?? '') !== '') {
$bytes = Douban::downloadPoster((string)$resDb['poster_url']);
if ($bytes !== '') {
$tmp = tempnam(sys_get_temp_dir(), 'fbdb');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$up = Upload::image($fam, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster');
@unlink($tmp);
if ($up['ok']) {
$posterKey = (string)$up['file'];
$posterShow = Storage\Storage::url($fam, $posterKey);
}
}
}
}
Api::ok([
'source' => 'douban',
'title' => (string)($resDb['title'] ?? ''),
'meta' => $resDb['meta'],
/* 拼好的简介(含导演/主演/年份/类型/片长/国家/评分 + 剧情原文)。
在服务端拼、由端上填空 —— 与网页端**同一份输出**:
这段文本会**落库**(movie_details.info),两端拼法必须一致。 */
'summary' => Douban::summaryText(
(array)$resDb['meta'],
(string)($resDb['title'] ?? ''),
true
),
'poster_key' => $posterKey,
'poster' => $posterShow,
]);
}
/* ────────────────────────────────────────────────────────────────
🔴 v64:**海报 / 观影照片上传**(端上「记一场」多图)
用户原话:「**允许上传多张图片,包括海报。**」
⇒ 网页端是靠 `movies/create` 的 multipart 表单一次性带上
(`poster` 单张 + `photo[]` 多张,见 index.php 那段)。
但 App 这边"先 JSON 建记录、再逐个传图"更稳:
· JSON 建记录失败时不会留下半张孤儿图;
· 图片传失败也**不挡记录**(与网页同一取舍:一条记录因为一张
5MB 原图存不进去,看着像功能坏了);
· 一次传一张,进度好做、重试粒度小。
⚠️ 图片走**旧值保留**语义:传一张海报就是"换掉旧海报并删旧对象"
(与网页 `movies/update` 里那段一致);照片是**追加**,不替换
(替换会把用户前几次传的全冲掉)。
⚠️ 槽位固定:海报 `poster`(2:3 裁)、照片 `photo`(free 等比缩)。
槽位决定比例与输出尺寸,由 `Upload::image` 在服务端保证。
⚠️ 必须是 `$id > 0`(记录已存在)—— 所以这个分支放在下面那一段里。
──────────────────────────────────────────────────────────────── */
if (($sub === 'poster' || $sub === 'photos') && $id === 0) {
Api::fail('要指定是哪一条观影记录。', 'bad_request', 400);
}
if ($id > 0) {
$m = Movie::find($fam, $id, $pid);
if (!$m) { Api::notFound('这条观影记录'); }
if ($sub === 'poster' || $sub === 'photos') {
if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
Api::requireWrite();
if ($sub === 'poster') {
// ⚠️ 表单字段名固定 `poster`(与网页端同名)——端上拼 multipart 时用
$up = Upload::image($fam, $_FILES['poster'] ?? [], 'poster');
if (!$up['ok']) {
Api::fail((string)($up['error'] ?? '海报没存下来'), 'unprocessable', 422);
}
$newKey = (string)$up['file'];
$oldKey = (string)($m['poster_key'] ?? '');
Movie::setPoster($id, $newKey);
// 换图顺手删旧对象(与网页 movies/update 同一口径,否则堆孤儿)
if ($oldKey !== '' && $oldKey !== $newKey) {
Storage\Storage::delete($fam, $oldKey);
}
Api::ok([
'poster_key' => $newKey,
'poster' => Storage\Storage::url($fam, $newKey),
]);
}
// ---- 观影照片(可多张;字段 `photo[]`,一次传几张都行)----
$files = self::normalizeFiles('photo');
if ($files === []) {
Api::fail('没有收到图片。', 'unprocessable', 422);
}
$added = [];
$warns = [];
foreach ($files as $f) {
$up = Upload::image($fam, $f, 'photo');
if ($up['ok']) {
$added[] = (string)$up['file'];
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = (string)$up['error'];
}
}
if ($added === []) {
Api::fail($warns ? implode(';', $warns) : '这几张都没存下来',
'unprocessable', 422);
}
/* ⚠️ **追加**而不是替换:`Movie::addPhotos()` 保留库里已有的那些。
(替换的实现会把用户上一次传的照片全删掉 —— 与"多图"的直觉相反。)
⚠️ 只把这次**新增成功**的那些传进去,不碰旧的。 */
Movie::addPhotos($id, $added);
Api::ok([
'added' => $added,
'photos' => array_map(
static fn(array $p): array => [
'id' => (int)$p['id'],
'key' => (string)$p['key'],
'url' => Storage\Storage::url($fam, (string)$p['key']),
],
Movie::photos($id)
),
'warns' => $warns,
]);
}
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));
}
/**
* 把 `$_FILES['name']` 归一成「一个文件一个数组」的列表(支持 `name[]` 多文件)。
*
* 🔴 v64:端上「记一场」允许多张观影照片(用户:「允许上传多张图片」)。
* PHP 对 `<input type="file" name="photo[]" multiple>` 给的
* `$_FILES['photo']` 是**按字段竖切**的(`name`/`type`/`tmp_name`/… 各一个数组),
* 每个 `Upload::image()` 要的是**横切**的一行。
* ⇒ 这里做一次转置,别在业务分支里手写索引。
*
* ⚠️ 单文件与多文件两种形态都要认(单文件时 `name` 是字符串,不是数组)——
* 端上第一次传 1 张、第二次传 3 张,走的是同一段代码。
* ⚠️ `UPLOAD_ERR_NO_FILE` 的那种"空槽"要**丢掉**:
* 文件多选控件在用户取消选择时会留一个空槽,交给 `Upload::image`
* 会被判成"没传图"(error 空串)从而静默少一张 —— 不如这里就过滤掉。
*
* @return array<int,array{name:string,type:string,tmp_name:string,error:int,size:int}>
*/
private static function normalizeFiles(string $field): array
{
if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
return [];
}
$f = $_FILES[$field];
// 单文件形态:直接包一层
if (!is_array($f['name'])) {
return (int)($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE ? [] : [$f];
}
$out = [];
foreach (array_keys($f['name']) as $i) {
$one = [
'name' => (string)($f['name'][$i] ?? ''),
'type' => (string)($f['type'][$i] ?? ''),
'tmp_name' => (string)($f['tmp_name'][$i] ?? ''),
'error' => (int)($f['error'][$i] ?? UPLOAD_ERR_NO_FILE),
'size' => (int)($f['size'][$i] ?? 0),
];
if ($one['error'] === UPLOAD_ERR_NO_FILE) {
continue;
}
$out[] = $one;
}
return $out;
}
/** 观影写入字段(新建与更新共用;`$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'] ?? [])),
/* 🔴 v64:**豆瓣抓来的海报键**(端上不传文件,只把这个键带上)。
⚠️ 与网页端 `movies/create` 里那段同一个契约(隐藏字段 `poster_key`),
但校验放在**这里**(模型入口)而不是各调用点 ——
键必须形如 `posters/xxx.jpg`,否则手搓请求能让一行记录指向
别的家庭的存储对象。
⚠️ 只在"有人给了这个字段"时改(`$has` 语义):改别的字段时
顺带把海报清空是最不能接受的那种数据丢失。 */
'poster_key' => $has('poster_key')
? (preg_match('#^posters/[A-Za-z0-9._-]+$#', Api::str('poster_key'))
? Api::str('poster_key') : '')
: (string)($cur['poster_key'] ?? ''),
];
}
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));
}
/* 🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗?
为什么这里没有增加题图的功能呢?」
⇒ 端上补题图上传。槽位**必须**是 `cover`(`app/ImageSlot.php` 里
1920×1080 / 16:9),与网页版 `trips/form.php` 的 `cover` 文件域
同一条管线 —— 端上不裁剪,裁/缩/转 JPEG 全在服务端做。
⚠️ 表单字段名固定 `cover`(端上拼 multipart 时用);
与观影的 `poster`(2:3)不是同一个槽位,别串。
⚠️ 换图顺手删旧对象:否则每换一次题图就在存储里堆一张孤儿图
(与 `movies/<id>/poster` 那条同一口径)。 */
if ($sub === 'cover') {
if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
Api::requireWrite();
$up = Upload::image($fam, $_FILES['cover'] ?? [], 'cover');
if (!$up['ok']) {
Api::fail((string)($up['error'] ?? '题图没存下来'), 'unprocessable', 422);
}
$newKey = (string)$up['file'];
$oldKey = (string)($t['cover_key'] ?? '');
Trip::setCover($id, $newKey);
if ($oldKey !== '' && $oldKey !== $newKey) {
Storage\Storage::delete($fam, $oldKey);
}
Api::ok([
'cover_key' => $newKey,
'cover' => Storage\Storage::url($fam, $newKey),
]);
}
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();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
// ⚠️ `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);
}
/* 🔴 v68:改一笔 / 删一笔。
⚠️ 为什么 id 放在 **body/query** 而不是第 4 段路径:
`Api::dispatch()` 只解析 `/<res>/<id>/<sub>` **三段**,
`PATCH /ledgers/3/entries/17` 的第 4 段会被直接丢掉,
请求会落到"整本账本"上(v66 在 `trips/vehicles` 上踩过一次)。 */
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
$eid = Api::int('id');
if ($eid <= 0) { Api::fail('要改哪一笔?缺 `id`。', 'unprocessable', 422); }
$amount = Api::param('amount', '');
if (trim((string)$amount) === '') {
Api::fail('金额不能为空。', 'unprocessable', 422);
}
Ledger::updateEntry($id, $eid, [
'kind' => Api::str('kind', 'expense'),
'amount' => $amount,
'title' => Api::str('title', '', 120),
'note' => Api::str('note', ''),
'person_id' => Api::int('person_id'),
]);
Api::ok(['updated' => $eid]);
}
if ($method === 'DELETE') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
$eid = Api::int('id');
if ($eid <= 0) { Api::fail('要删哪一笔?缺 `id`。', 'unprocessable', 422); }
Ledger::removeEntry($id, $eid);
Api::ok(['deleted' => $eid]);
}
}
/* 🔴 工单 #24(用户 2026-10-28「鸿蒙端对齐网页端」):财务折线图。
端上「净资产变化(盘点成线)」原来是个空占位;数据源 Web 端早就有
(`Ledger::timeline` + `ledger/total_chart`),只是没 API 给端上。
这里补 `GET /api/v1/ledger/<id>/timeline`,把时序点吐给端上自己画。
⚠️ 与 Web 端 total_chart 同一份 `Ledger::timeline($id)` ——
两端曲线必然一致,不会"App 说涨、网页说跌"。 */
if ($sub === 'timeline') {
if ($method === 'GET') {
Api::ok([
'items' => Ledger::timeline($id),
'summary' => Ledger::summary(Ledger::entries($id)),
]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
/* 🔴 v68:共享给谁(端上"财务 → 设置 → 共享给谁"原来是个灰条)。
`Ledger::setShare/unshare/shares` 早就在模型里,只是没有路由。
⚠️ 只有**账本主人**能改共享关系:拿到 write 权限的被共享者
不该能再把账本转手给别人(否则等于改了别人的账本)。 */
if ($sub === 'shares') {
$isOwner = (int)($lg['owner_id'] ?? 0) === $pid;
if ($method === 'GET') {
Api::ok(['items' => Ledger::shares($id)]);
}
if ($method === 'POST') {
Api::requireWrite();
if (!$isOwner) { Api::fail('只有账本主人能改共享。', 'forbidden', 403); }
$who = Api::int('person_id');
if ($who <= 0) { Api::fail('要共享给谁?缺 `person_id`。', 'unprocessable', 422); }
Ledger::setShare($id, $who, Api::str('perm', 'read'));
Api::ok(['items' => Ledger::shares($id)]);
}
if ($method === 'DELETE') {
Api::requireWrite();
if (!$isOwner) { Api::fail('只有账本主人能改共享。', 'forbidden', 403); }
$who = Api::int('person_id');
if ($who <= 0) { Api::fail('要取消谁的共享?缺 `person_id`。', 'unprocessable', 422); }
Ledger::unshare($id, $who);
Api::ok(['items' => Ledger::shares($id)]);
}
}
if ($method === 'GET') {
$es = Ledger::entries($id);
Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]);
}
/* v68:账本改名(端上"账本名字"那条原来也是灰条) */
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
if (!Ledger::canWrite($lg)) { Api::fail('这本账本你只有查看权限。', 'forbidden', 403); }
$nm = Api::str('name', '', 60);
if ($nm === '') { Api::fail('账本名字不能为空。', 'unprocessable', 422); }
Ledger::rename($fam, $id, $nm);
Api::ok(Ledger::find($fam, $id, $pid));
}
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')));
}
/* ═══════════════════════════════════════════════════════════════
旅程三项设置(v66,用户 2026-10-20)
用户原话:「旅程的……车辆管理、出行方式、节点设置,这些东西都还没完成。」
网页端早就有这两块(`trips/vehicles.php` / `trips/types.php`),
表与模型(`Vehicle` / `NodeType` 的 `save/destroy`)也齐 ——
**缺的只是 API 路由**,所以端上那三个菜单项一直点不开。
⚠️ 「出行方式」**没有**这一对接口:它是枚举常量(`Trip::TRAVEL_MODES`),
加一种要改代码,不存在"用户自己管理"这回事。
⚠️ 这里**不复制**模型的校验逻辑:名称必填、重名、内置不可删、
还有 N 段行程在用……全在 `Vehicle` / `NodeType` 里(网页端同一份)。
接口只负责把 `ok=false` 的 `error` 原样回给端上 —— 那句话已经是人话。
═══════════════════════════════════════════════════════════════ */
/**
* `/api/v1/vehicles` —— 出行用的车(自家 / 租的 / 借的)。
*
* ⚠️ 与财务里 `kind='car'` 的**账目**是两回事(服务端 `Vehicle` 文件头写明):
* 那条账目记的是"这台车值多少钱",没有车牌车型;用户 2026-10-05 明确
* 要求选车下拉里**不要**混进财务的车。
*/
private function vehicles(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
if ($method === 'GET') {
$v = Vehicle::find($fam, $id);
if (!$v) { Api::notFound('这辆车'); }
$v['used_count'] = $this->vehicleUsedCount($id);
Api::ok($v);
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$cur = Vehicle::find($fam, $id);
if (!$cur) { Api::notFound('这辆车'); }
$r = Vehicle::save($fam, $this->vehicleInput($cur), $id);
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$v = Vehicle::find($fam, $id) ?: [];
$v['used_count'] = $this->vehicleUsedCount($id);
Api::ok($v);
}
if ($method === 'DELETE') {
Api::requireWrite();
$r = Vehicle::destroy($fam, $id);
if (empty($r['ok'])) {
/* 422 而不是 400:这是"现在不能删"(还有行程在用),
不是"请求写错了" —— 端上据此原样显示服务端那句话。 */
Api::fail((string)($r['error'] ?? '删除失败。'), 'unprocessable', 422);
}
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$r = Vehicle::save($fam, $this->vehicleInput([]));
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$newId = (int)($r['id'] ?? 0);
$v = Vehicle::find($fam, $newId) ?: [];
$v['used_count'] = $this->vehicleUsedCount($newId);
Api::ok($v, 201);
}
$out = [];
foreach (Vehicle::all($fam) as $v) {
$v['used_count'] = $this->vehicleUsedCount((int)$v['id']);
$out[] = $v;
}
Api::ok($out);
}
/** 一辆车被多少段行程引用(端上据此决定"删除"能不能点) */
private function vehicleUsedCount(int $vehicleId): int
{
if ($vehicleId <= 0) { return 0; }
try {
$st = Database::get()->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?');
$st->execute([$vehicleId]);
return (int)$st->fetchColumn();
} catch (\Throwable $ex) {
return 0;
}
}
/**
* 车字段的取值:**请求里有就用请求的,没有就沿用库里那份**。
*
* ⚠️ 为什么不能像 `resources` 那样直接 `Api::str('plate', $cur['plate'])` 一路写死:
* 那样每多一个字段就要多抄一行,而且 PATCH 只改一个字段时会把别的冲成空。
* 这里逐键取,语义与页面表单提交一致(`Vehicle::save` 自己会 trim/截断)。
*/
private function vehicleInput(array $cur): array
{
return [
'plate' => Api::param('plate', (string)($cur['plate'] ?? '')),
'name' => Api::param('name', (string)($cur['name'] ?? '')),
'vtype' => Api::param('vtype', (string)($cur['vtype'] ?? 'own')),
'vendor' => Api::param('vendor', (string)($cur['vendor'] ?? '')),
'order_no' => Api::param('order_no', (string)($cur['order_no'] ?? '')),
'is_default' => Api::param('is_default', (int)($cur['is_default'] ?? 0)),
'note' => Api::param('note', (string)($cur['note'] ?? '')),
];
}
/**
* `/api/v1/nodetypes` —— 行程节点类型(游玩 / 用餐 / 赶路…,可自定义)。
*
* 🔴 改名会**级联**到已有行程(`trip_nodes.node_type` 存的是名字,不是 id)——
* 这条在 `NodeType::save` 里做,接口不要重复实现。
*/
private function nodeTypes(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
if ($method === 'GET') {
$t = NodeType::find($fam, $id);
if (!$t) { Api::notFound('这个类型'); }
$t['used_count'] = NodeType::usedCount($fam, (string)$t['name']);
Api::ok($t);
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$cur = NodeType::find($fam, $id);
if (!$cur) { Api::notFound('这个类型'); }
$r = NodeType::save($fam, $this->nodeTypeInput($cur), $id);
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$t = NodeType::find($fam, $id) ?: [];
$t['used_count'] = NodeType::usedCount($fam, (string)($t['name'] ?? ''));
Api::ok($t);
}
if ($method === 'DELETE') {
Api::requireWrite();
$r = NodeType::destroy($fam, $id);
if (empty($r['ok'])) {
// 内置类型 / 还有行程在用 —— 两种都是"现在不能删",不是请求写错
Api::fail((string)($r['error'] ?? '删除失败。'), 'unprocessable', 422);
}
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$r = NodeType::save($fam, $this->nodeTypeInput([]));
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$newId = (int)($r['id'] ?? 0);
$t = NodeType::find($fam, $newId) ?: [];
$t['used_count'] = 0;
Api::ok($t, 201);
}
/* ⚠️ `NodeType::all()` 内部会**自愈地**补内置类型(`seedDefaults` /
`seedFromTripLog`),所以这里调它就是"保证列表一定不为空"。 */
$out = [];
foreach (NodeType::all($fam) as $t) {
$t['used_count'] = NodeType::usedCount($fam, (string)$t['name']);
$out[] = $t;
}
Api::ok($out);
}
/** 节点类型字段的取值(同 `vehicleInput`:请求优先、缺了沿用库里) */
private function nodeTypeInput(array $cur): array
{
return [
'name' => Api::param('name', (string)($cur['name'] ?? '')),
'icon' => Api::param('icon', (string)($cur['icon'] ?? '')),
];
}
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 $sub, string $method): void
{
[$fam, $pid] = Api::me();
/* ═══════════════════════════════════════════════════════════
工单 #23(用户 2026-10-28「鸿蒙端对齐网页端」):笔记图片块。
端上要"图文混排"(往笔记里插图),缺两步:
· 上传图片拿附件 key → 这里补 `POST /api/v1/notes/upload`
· 拿到的 key 作为 `image` 块塞进 `blocks` 存 —— `noteBlocksIn()`
本来就认 `blocks` 数组(见 `cleanBlocks` 的 image 分支),
`Note::claimUploads` 也会按块里的 key 把"草稿附件"认领到笔记上。
🔴 上传是**草稿态**、**不依赖笔记 id**(新建笔记时还没有 id):
图片先挂在 target_id=0,等 `blocks` 带 key 提交时再认领——
与 Web 端 `notes/upload` 路由同一套语义。
⚐ 注意:`Notes/upload` 的 `$sub='upload'`、`$id=0`,所以这里必须
放在 `if ($id > 0)` **之前**(否则新建笔记插不了图)。
═══════════════════════════════════════════════════════════ */
if ($sub === 'upload') {
Api::requireWrite();
if ($method === 'POST') {
$file = $_FILES['file'] ?? null;
if (!is_array($file)) {
Api::fail('没有收到文件。', 'unprocessable', 422);
}
$r = \App\Attachment::save($fam, Note::ATTACH, 0, $file, $pid);
if (empty($r['ok'])) {
$msg = (string)($r['error'] ?? '');
Api::fail($msg !== '' ? $msg : '图片没存下来', 'unprocessable', 422);
}
// 🔴 `Attachment::save` 只回 id 不回 key ⇒ 反查一行拿 key
// (与 Web 端 `notes/upload` 路由同一招,见 index.php 那段)。
$key = '';
foreach (\App\Attachment::list(Note::ATTACH, 0) as $a) {
if ((int)$a['id'] === (int)($r['id'] ?? 0)) {
$key = (string)$a['key'];
break;
}
}
Api::ok([
'key' => $key,
'id' => (int)($r['id'] ?? 0),
], 201);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
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();
/* 🔴🔴 v65(2026-10-20 修的数据丢失):`Note::update()` 会把
`cleanBlocks($d['blocks'] ?? [])` **整组替换**写回(先 DELETE 再 INSERT),
而原来这里传的是 `'body' => …` —— `Note` 根本不读 `body`,只读 `blocks`
⇒ 请求里的正文被忽略、`blocks` 恒为 `[]` ⇒
**只改个标题就会把整条笔记的正文全部删光**。
这里改成:
· 请求给了 `blocks` / `body` → 按请求替换;
· 都没给(只想改标题/标签)→ **沿用库里的块**。 */
$blocks = $this->noteBlocksIn();
if ($blocks === null) {
$blocks = $n['blocks'];
}
Note::update($fam, $id, $pid, [
'title' => Api::str('title', (string)$n['title'], 200),
'blocks' => $blocks,
'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,
/* 🔴 v65:同上,`Note::create()` 读的是 `blocks`。
原来传 `'body'` ⇒ 新建出来的笔记**只有标题、一个字都没有**。 */
'blocks' => $this->noteBlocksIn() ?? [],
'tags' => Api::param('tags', []),
]);
if ($newId <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); }
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),
]);
}
/**
* 请求里的「正文」→ `note_blocks` 数组。
*
* 🔴🔴 为什么要有这个函数(2026-10-20 修的一处**数据丢失**):
* `Note::create()` / `Note::update()` 读的键是 **`blocks`**
* (`note_blocks` 表,一条笔记由若干块组成:text / image / …),
* 而本文件的 `notes()` 原来传的是 **`body`** —— `Note` 压根不看这个键。
* 后果:
* · `POST /notes`:正文被丢掉,新笔记只有标题;
* · `PATCH /notes/<id>`:`blocks` 恒为 `[]`,而 `update()` 是
* **整组替换**(先 `DELETE FROM note_blocks`)⇒ **整条笔记的正文被删光**。
* 网页端没暴露,是因为网页表单提交的是 `blocks[]`;只有走 API 的客户端会中招。
*
* 兼容两种写法:
* · `blocks`:数组,原样交给 `Note::cleanBlocks()` 清洗(网页表单那种);
* · `body`:**纯文本**,整段包成一个 `text` 块(App 端最自然的写法)。
*
* @return array|null 请求里**根本没提**正文时返回 null
* —— 调用方据此区分"要清空"和"没改",
* 对 null 一律沿用库里的块(见 `notes()` 的 PATCH 分支)。
*/
private function noteBlocksIn(): ?array
{
$body = Api::body();
if (array_key_exists('blocks', $body) && is_array($body['blocks'])) {
return $body['blocks'];
}
if (array_key_exists('body', $body) || array_key_exists('body', $_POST)) {
$txt = (string)Api::str('body', '');
return $txt === '' ? [] : [['kind' => 'text', 'content' => $txt]];
}
return null;
}
/* ═══════════════════════════════════════════════════════════════
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;
}
}