Files
FamilyBook/app/ApiHandler.php
T
laoyang 13a5d08696 v45 API 补录:手机号登录换令牌 + 我的名册 / 家谱接口 + 出口层敏感字段脱敏
🔴 为什么现在才提交(这是「补录」,代码早已上线并验证):
   v45 第三/第四批当时,同一工作区里有**并行的鸿蒙端工作流**正在改这个文件
   (手机号登录换令牌、media.php 的 ?token=)。按「绝不把别人正在写的裹进
   自己的提交」的纪律,那次只提交了自己的 5 个文件,本文件被**故意留下**。
   此后文件静止约 25 小时、端上工作流也已收尾 ⇒ 现在补录,让仓库与线上一致
   (线上 md5 = 本地 md5,deploy 是从工作树打包的,所以线上一直是这份)。

① `POST /api/v1/login` —— 手机号 + 密码 → API 令牌
   原先 App 只能「先去网页签发令牌、再手动粘贴」,能用但没法给别人用。
   与网页登录有意不同:不建会话(API 无状态);限流落**文件**
   (`sys_get_temp_dir()/fb_login_<sha256(ip|phone)>.json`,60s 窗口 / 5 次)
   而不进 $_SESSION;失败**统一**提示(不区分「没这个人」与「密码错」,
   否则接口成了手机号是否已注册的探测器);有效期用**服务端时间**算。
   成功时顺带 `Calendar::seedDefaults()`(全新安装的兜底)。

② `GET /api/v1/roster` —— 「**我**名册里的人」(我加的 + 别人分享的)
   与 `/people`(全家 801 人 = 通讯录底账)区分开:App 名册页要用这个,
   否则一屏 800 人且是「别人的通讯录」。组装**照抄 Web 端 index.php 的
   people 分支**(同一套类/判据):Person::roster → Kinship::label /
   Nickname::display / PersonQuery::phonePlain / PersonQuery::birthdaysOf /
   PersonTag::mapFor / Pinyin::initials,外加标签云与标签词表。
   ⚠️ 复用这些类而非另写,否则会「网页搜得到、App 搜不到」。

③ `GET /api/v1/kin` —— 家谱(以我为中心的关系图)
   用 `Kinship::familyMap($fam,$pid,$people,2,2)`,与 Web 端 kin/tree.php
   同一套 ⇒ 端上与网页的亲属关系必然一致。按代数排序,拍平直接关系字段。

④ 🔴 信息泄露修复:`stripSensitivePerson()`
   `Person::all()` / `Person::roster()` 都是 `SELECT p.*`,把
   `phone_hash` / `phone_encrypted` / `id_card_encrypted` / `birthday_id_card` /
   `birthday_lunar*` 一路吐给 App。前两个是 AES 密文(离线爆破材料),
   `phone_hash` 是「手机号是否属于这个家」的判定键。
   ⇒ 在 **API 出口层**脱敏(列表 / 详情 / 新建 / PATCH 四个出口都套)。
   ⚠️ **不能改 `Person::all()` 本身**:网页端确实在用(ContactImporter 靠
   phone_hash 去重、PersonQuery 靠 phone_encrypted 按手机号搜)
   ⇒ 网页要、App 不要 ⇒ 差异落在出口层。

线上验证(全绿):GET login→405 / 缺参→422 / 错密码→401「手机号或密码不对」
/ 不存在的手机号→**同样的** 401 与提示 / 连续错→第 6 次起 429 / 名册 800 人 /
标签云 15 个 / 称谓正确(爸爸→父亲…)/ 手机号解密正确 / 生日阴阳历 /
首字母 ypzbb / 6 个敏感字段全去除(列表与详情都验)。
2026-10-14 09:41:34 +08:00

1363 lines
66 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'),
]);
}
switch ($res) {
case 'me': $this->me($method); return;
case 'tokens': $this->tokens($id, $method); return;
case 'home': $this->home(); return;
case 'people': $this->people($id, $sub, $method); return;
// 🔴 v45:名册(**我名册里的人**,不是全家 800 人)。
// 与 `people` 的区别见 `roster()` 方法的说明。
case 'roster': $this->roster($method); return;
// 🔴 v45:家谱(以我为中心的关系图)。
// 端上「家谱」页要用:父母 / 配偶 / 子女 / 兄弟姐妹 + 各代层次。
// ⚠️ 数据来自 `relations` 表(只存相邻两代的边),
// 亲属关系靠 `Kinship` 推导 —— 与 Web 端 `kin/tree.php` 同一套。
case 'kin': $this->kin(); return;
case 'notifications': $this->notifications($id, $sub, $method); return;
case 'calendar': $this->calendar($sub, $method); return;
case 'events': $this->events($id, $sub, $method); return;
case 'days': $this->days($id, $method); return; // 纪念日
case 'tasks': $this->tasks($id, $sub, $method); return;
case 'movies': $this->movies($id, $sub, $method); return;
case 'cinemas': $this->cinemas($id, $method); return;
case 'trips': $this->trips($id, $sub, $method); return;
case 'ledger': $this->ledger($id, $sub, $method); return;
case 'resources': $this->resources($id, $method); return;
case 'books': $this->books($id, $sub, $method); return;
case 'notes': $this->notes($id, $method); return;
// 🔴 v45:登录(手机号 + 密码 → API 令牌)。
// 为什么必须走 API 而不是网页登录后复制 Cookie:
// App 是 Bearer 令牌那套,没有会话;而原先前端只能让人
// **手动去网页签发令牌再粘贴**到 App 里 —— 这个体验没法接受。
case 'login': $this->login($method); return;
}
Api::fail('未知的接口资源:' . $res, 'not_found', 404);
}
/* ═══════════════════════════════════════════════════════════════
登录(手机号 + 密码 → API 令牌)
═══════════════════════════════════════════════════════════════ */
/**
* `POST /api/v1/login` —— 用手机号 + 密码换一枚 API 令牌。
*
* 🔴 为什么需要这个接口(v45,用户 2026-10-12 "把服务器接进来、跑正式流程"):
* 原先 App 只能让人**先去网页「个人设置 → API 令牌」签发、再手动粘贴**
* 到 App 的设置页里。这是个"能用但没法给别人用"的流程。
* 有了它,App 首页就能直接登录。
*
* ⚠️ 与网页登录(`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);
}
$phone = Api::str('phone', '', 40);
$password = (string)Api::param('password', '');
$label = Api::str('label', 'App 登录', 60);
// 令牌有效期(天)。0 = 永不过期(与网页签发令牌的默认行为一致)。
$days = Api::int('days', 0);
if ($phone === '' || $password === '') {
Api::fail('手机号和密码都得填。', 'unprocessable', 422);
}
// ---- 限流:失败次数有成本(防爆破)----
$gate = self::loginThrottle($phone);
if ($gate !== null) {
Api::fail($gate, 'too_many_requests', 429);
}
$hash = Crypto::hash($phone);
$st = Database::get()->prepare(
'SELECT a.id AS account_id, a.password_hash, a.role, a.status,
p.id AS person_id, p.family_id, p.name, p.avatar
FROM accounts a
JOIN persons p ON p.id = a.person_id
WHERE p.phone_hash = ? LIMIT 1'
);
$st->execute([$hash]);
$row = $st->fetch();
// ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。
// 分开回话就等于告诉对方"这个手机号存在/不存在"。
$ok = $row
&& (string)$row['status'] === 'active'
&& password_verify($password, (string)$row['password_hash']);
if (!$ok) {
self::loginThrottleFail($phone);
// 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本)
usleep(300000);
Api::fail('手机号或密码不对。', 'unauthorized', 401);
}
self::loginThrottleClear($phone);
$personId = (int)$row['person_id'];
$familyId = (int)$row['family_id'];
$expiresAt = null;
if ($days > 0) {
// ⚠️ 用服务端时间算,不信客户端传的日期
$expiresAt = date('Y-m-d H:i:s', time() + $days * 86400);
}
$token = ApiToken::issue($personId, $label, $expiresAt);
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());
}
Api::ok([
'token' => $token,
'expires_at' => $expiresAt,
'person' => [
'id' => $personId,
'name' => (string)$row['name'],
'avatar' => (string)($row['avatar'] ?? ''),
'role' => (string)$row['role'],
'family_id' => $familyId,
],
]);
}
/* ---- 登录限流(**文件实现**,不碰数据库结构)----
⚠️ 为什么不用 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);
// 生日:阴历/阳历各一条,**只到月日**(年份不外发)
$row['birthdays'] = PersonQuery::birthdaysOf($row);
// 标签
$row['tags'] = $tagsOf[(int)$row['id']] ?? [];
// 首字母(App 侧"输入即筛选"用;与网页 filter.js 的 data-initials 同一来源)
$row['initials'] = Pinyin::initials(
(string)$row['name'] . ' ' . (string)($row['my_nickname'] ?? '')
);
// 🔴 脱敏:名册行同样是 `SELECT p.*`,敏感密文一律不下发
// (见 stripSensitivePerson 的说明)。
$row = self::stripSensitivePerson($row);
}
unset($row);
// ---- 标签筛选(`?tag=` 为空 = 全部)----
$tagFilter = Api::str('tag');
if ($tagFilter !== '') {
$rows = array_values(array_filter(
$rows,
static fn(array $r): bool => in_array($tagFilter, $r['tags'] ?? [], true)
));
}
Api::ok([
'people' => array_values($rows),
// 标签云(筛选条用)+ 标签词表(编辑时选已有标签)
'tagCloud' => PersonTag::cloud($fam, $pid),
'groups' => PersonGroup::orderFor($fam, $pid),
'tagFilter' => $tagFilter,
'kind' => $kind,
'meId' => $pid,
'q' => $q,
]);
}
/* ═══════════════════════════════════════════════════════════════
我 / 令牌 / 首页
═══════════════════════════════════════════════════════════════ */
private function me(string $method): void
{
[$fam, $pid] = Api::me();
$p = Person::find($pid) ?: [];
Api::ok([
'id' => $pid,
'name' => (string)($p['name'] ?? ''),
'avatar' => avatar_url($p['avatar'] ?? null),
'role' => Auth::roleWord(),
'family' => (function () use ($fam): array {
$f = Family::find($fam) ?: [];
return ['id' => $fam, 'name' => (string)($f['name'] ?? '')];
})(),
// 各模块主题色:App 端顶部/分类色块直接用它,不必自己维护一张表
'colors' => $this->moduleColors($fam),
]);
}
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 里"退出登录/吊销设备") */
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);
}
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')));
$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));
$tasks = Task::byStatus($fam, $pid, 'doing');
foreach (Task::byStatus($fam, $pid, 'todo') as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) { $tasks[] = $t; }
}
Api::ok([
'greeting' => Home::greeting(
Nickname::display($pid, null) ?: '', $today, count($soon),
count(array_filter($tasks, static fn($t) => Task::isOverdue($t['due_at'] ?? null)))),
'date' => $today,
'weekday' => Home::weekday($today),
'today_events' => $todayEvents,
'tasks' => $tasks,
'unread' => Notification::unreadCount($pid),
]);
}
/* ═══════════════════════════════════════════════════════════════
名册 / 人
═══════════════════════════════════════════════════════════════ */
private function people(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$p = Person::find($id);
// 只认本家的(跨家 id 直接当不存在,不泄露"别人家有没有这个人")
if (!$p || (int)$p['family_id'] !== $fam) { Api::notFound('这个人'); }
if ($method === 'GET') {
Api::ok(self::stripSensitivePerson($p));
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Person::update($id, [
'name' => Api::str('name', (string)$p['name'], 50),
'gender' => Api::str('gender', (string)($p['gender'] ?? '')),
'birthday'=> Api::dt('birthday', $p['birthday'] ?? null),
'note' => Api::str('note', (string)($p['note'] ?? '')),
'phone' => Api::str('phone', (string)($p['phone'] ?? ''), 40),
]);
Api::ok(self::stripSensitivePerson((array)Person::find($id)));
}
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, [
'name' => $name,
'kind' => Api::str('kind', 'member'),
'gender' => Api::str('gender', ''),
'note' => Api::str('note', ''),
'phone' => Api::str('phone', '', 40),
]);
Api::ok(self::stripSensitivePerson((array)Person::find($newId)), 201);
}
// 列表:支持 ?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 的字段**去掉(就地改,返回同一个数组)。
*
* 🔴 v45 新增(用户 2026-10-12 接服务器时发现):
* `Person::all()` 是 `SELECT p.*`,于是把 `phone_hash` /
* `phone_encrypted` / `id_card_encrypted` 一起吐给了 App。
* 这三个都是**敏感物**:
* · `phone_encrypted` / `id_card_encrypted` 是 AES 密文,
* 给到客户端就等于把**离线爆破的材料**递出去了;
* · `phone_hash` 是"手机号是否属于这个家"的判定键,同样不该外流。
* App 侧一个都用不到(显示手机号要的是**解密后的明文**,
* 走的是另一条路)。
*
* ⚠️ 为什么在**这一层**脱敏,而不去改 `Person::all()`:
* 网页端**确实在用**这些字段 —— `ContactImporter` 靠 `phone_hash`
* 做导入去重、`PersonQuery` 靠 `phone_encrypted` 支持"按手机号搜索"。
* 从 `all()` 里删会**打断网页功能**。
* ⇒ 网页要、App 不要 ⇒ 差异就该落在"出口"这一层。
*
* ⚠️ 顺手去掉的还有几个**纯服务端内部**字段(`is_placeholder` 之外的
* 统计/审计列),App 的 UI 里没有对应位置,传了只是徒增体积。
*/
private static function stripSensitivePerson(array $row): array
{
unset(
$row['phone_hash'],
$row['phone_encrypted'],
$row['id_card_encrypted'],
// 生日相关的三个原始列:App 显示生日只用 `birthday`,
// 而 `birthday_id_card` / `birthday_lunar` 是**推算生日**用的中间量
// (农历、证件号),属于隐私推算材料,没必要下发。
$row['birthday_id_card'],
$row['birthday_lunar'],
$row['birthday_lunar_leap']
);
return $row;
}
/* ═══════════════════════════════════════════════════════════════
通知
═══════════════════════════════════════════════════════════════ */
/**
* 家谱(`GET /api/v1/kin`)。
*
* 🔴 为什么单独一个接口、而不是塞进 `/roster`:
* `/roster` 是"**我名册里**的人"(可筛可搜,数据是**一堆人**);
* 家谱是"**以我为中心的关系图**"(谁是谁的父母、配偶、子女,
* 是**一张网**)。两者的形状与用途都不同,塞在一起会让
* 名册接口多背一份"大部分人不用"的图谱数据。
*
* ⚠️ 用的是 `Kinship::familyMap($fam, $me, $people, up, down)` ——
* 与 Web 端 `kin/tree.php` **同一个方法**,所以端上与网页看到的
* 亲属关系必然一致(不会出现"App 说他是堂兄、网页说是表兄")。
* `$up` / `$down` 用默认 2 —— 手机上再往外排就看不下了。
*/
private function kin(): void
{
[$fam, $pid] = Api::me();
$people = Person::map($fam);
$map = Kinship::familyMap($fam, $pid, $people, 2, 2);
$graph = Kinship::graph($fam);
// ---- 把人整理成"一人一条 + 关系字段"的形状,便于端上直接渲染 ----
$byGen = [];
$gen = $map['gen'] ?? [];
$ago = $map['unit_of'] ?? [];
foreach ($gen as $id => $g) {
if (!isset($people[$id])) { continue; }
$row = $people[$id];
$label = Kinship::label($graph, $pid, (int)$id, $people);
$byGen[] = [
'id' => (int)$id,
'name' => (string)$row['name'],
'shown' => \App\Nickname::display($pid, $row) ?: (string)$row['name'],
'label' => $label, // 「我该怎么称呼他」
'gen' => (int)$g, // 0 = 我;负 = 长辈;正 = 晚辈
'avatar' => $row['avatar'] ?? null,
'avatar_color' => $row['avatar_color'] ?? null,
'unit' => (int)($ago[$id] ?? 0), // 户(夫妻单元)号
// 直接关系(拍平,端上画连线/分组用)
'parents' => array_map('intval', Kinship::parentIds($graph, (int)$id)),
'children' => array_map('intval', Kinship::childIds($graph, (int)$id)),
'spouses' => array_map('intval', Kinship::spouseIds($graph, (int)$id)),
'siblings' => array_map('intval', Kinship::siblingIds($graph, (int)$id)),
];
}
// 按代数排序(长辈在前、晚辈在后),同代按名字拼音稳定
usort($byGen, static function (array $a, array $b): int {
return $a['gen'] <=> $b['gen'] ?: strcmp($a['name'], $b['name']);
});
Api::ok([
'meId' => $pid,
'people' => $byGen,
'units' => $map['units'] ?? [],
'up' => 2,
'down' => 2,
'total' => count($byGen),
]);
}
private function notifications(int $id, string $sub, string $method): void
{
[, $pid] = Api::me();
if ($sub === 'read' || ($method === 'POST' && $sub === 'read')) {
Api::ok(['marked' => Notification::markAllRead($pid)]);
}
if ($sub === 'clear' || $method === 'DELETE') {
if ($id > 0) {
Api::ok(['deleted' => Notification::delete($id, $pid)]);
}
Api::ok(['cleared' => Notification::clearMine($pid)]);
}
Api::ok([
'unread' => Notification::unreadCount($pid),
'items' => Notification::mine($pid, 50),
]);
}
/* ═══════════════════════════════════════════════════════════════
日程
═══════════════════════════════════════════════════════════════ */
private function calendar(string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($sub === 'books') { // GET /calendar/books
Api::ok(Calendar::mine($fam, $pid));
}
// GET /calendar?from=2026-10-01&to=2026-10-31 (默认本月)
$from = Api::str('from', date('Y-m-01'));
$to = Api::str('to', date('Y-m-t'));
$cals = Calendar::mine($fam, $pid);
$rows = Calendar::month($fam, $cals, $pid, $from, $to);
Api::ok($rows);
}
private function events(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$ev = $this->eventFor($fam, $id);
if (!$ev) { Api::notFound('这条日程'); }
if (!Calendar::canWriteEvent($ev, $pid)) {
Api::fail('这本日历你没有写权限。', 'forbidden', 403);
}
if ($method === 'GET') { Api::ok($ev); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$this->writeEvent($pid, $ev);
}
if ($method === 'DELETE') {
Api::requireWrite();
$this->deleteEvent($ev);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$calId = Api::int('calendar_id');
$cal = Calendar::mineOne($fam, $pid, $calId);
if (!$cal || !Calendar::canWrite($cal, $pid) || Calendar::isReadonlyKind((string)$cal['kind'])) {
Api::fail('这本日历不能写入。', 'forbidden', 403);
}
$d = $this->eventData(null);
if ($d['starts_at'] === '') { Api::fail('开始时间不能为空。', 'unprocessable', 422); }
if ($d['title'] === '') { Api::fail('日程得有个标题。', 'unprocessable', 422); }
$d['created_by'] = $pid;
$newId = Calendar::addEvent((int)$cal['id'], $d);
Api::ok($this->eventFor($fam, $newId), 201);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
/** 取一条日程(本家范围内;不存在返回 null) */
private function eventFor(int $fam, int $id): ?array
{
$ev = Calendar::event($id);
if (!$ev) { return null; }
$cal = Calendar::find((int)$ev['calendar_id']);
if (!$cal || (int)$cal['family_id'] !== $fam) { return null; }
$ev['skips'] = Calendar::skipsOf($id);
return $ev;
}
/**
* 日程写入字段(新建与更新共用;`$cur` 非空做"只改给了的字段"回退)。
*
* 🔴 重复日程的**三档范围**(改/删这一次、这一次及以后、全部)——
* 网页那两段逻辑(event_update / event_del)比较长,这里**不复制**它们,
* 而是让 App 用同一组 `scope` + `occ` 参数,并在 API 里做同样的三档处理。
* (复制两份业务规则是"必然长歪"的写法;这里把差异收在一个私有方法里。)
*/
private function eventData(?array $cur): array
{
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
$starts = $has('starts_at') ? Api::dt('starts_at') : (string)($cur['starts_at'] ?? '');
$ends = $has('ends_at') ? Api::dt('ends_at') : ($cur['ends_at'] ?? null);
$allDay = $has('all_day') ? Api::bool('all_day') : !empty($cur['all_day']);
if ($allDay) {
$starts = $starts !== null && $starts !== '' ? substr((string)$starts, 0, 10) . ' 00:00:00' : (string)$starts;
$ends = $ends !== null && $ends !== '' ? substr((string)$ends, 0, 10) . ' 00:00:00' : null;
}
return [
'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''),
'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''),
'starts_at' => (string)($starts ?? ''),
'ends_at' => $ends,
'all_day' => $allDay,
'reminder_minutes' => $has('reminder_minutes') ? Api::param('reminder_minutes', [])
: ($cur['reminder_minutes'] ?? ''),
'status' => $has('status') ? Api::str('status', 'confirmed')
: (string)($cur['status'] ?? 'confirmed'),
'transp' => $has('transp') ? Api::bool('transp') : !empty($cur['transp']),
'rrule' => $has('freq') || $has('rrule') ? $this->rruleFromBody()
: (string)($cur['rrule'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
];
}
/** 更新一条日程(含重复日程的三档范围) */
private function writeEvent(int $pid, array $ev): void
{
$d = $this->eventData($ev);
$scope = Api::str('scope', 'all');
$occ = substr(Api::str('occ'), 0, 10);
$rrule = (string)($ev['rrule'] ?? '');
$hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ);
if ($rrule !== '' && $hasOcc && $scope === 'once') {
// 只改这一次:原系列跳掉那天 + 落一条独立单次
Calendar::detachOnce(['id' => (int)$ev['id'], 'calendar_id' => (int)$ev['calendar_id']],
$occ, $d, $pid);
Api::ok(['updated' => (int)$ev['id'], 'scope' => 'once']);
}
if ($rrule !== '' && $hasOcc && $scope === 'future') {
// 这一次及以后:老系列截到前一天,新系列从这天起
$rp = Repeat::parse($rrule);
$cut = date('Y-m-d', strtotime($occ . ' -1 day'));
$rp['until'] = $cut;
$rp['count'] = 0;
Calendar::updateEvent((int)$ev['id'], [
'title' => (string)($ev['title'] ?? ''),
'location' => (string)($ev['location'] ?? ''),
'starts_at' => (string)$ev['starts_at'],
'ends_at' => $ev['ends_at'] ?: null,
'all_day' => (int)($ev['all_day'] ?? 0) === 1,
'reminder_minutes' => $ev['reminder_minutes'] ?? '',
'status' => (string)($ev['status'] ?? 'confirmed'),
'transp' => (int)($ev['transp'] ?? 0) === 1,
'rrule' => 'FREQ=' . strtoupper((string)$rp['freq'])
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z',
'note' => (string)($ev['note'] ?? ''),
]);
$d2 = $d;
$d2['starts_at'] = $occ . ' ' . substr((string)$d['starts_at'], 11, 8);
$d2['ends_at'] = $d['ends_at'] !== null ? $occ . ' ' . substr((string)$d['ends_at'], 11, 8) : null;
$d2['created_by'] = $pid;
$newId = Calendar::addEvent((int)$ev['calendar_id'], $d2);
Api::ok(['updated' => (int)$ev['id'], 'new_series' => $newId, 'scope' => 'future']);
}
Calendar::updateEvent((int)$ev['id'], $d);
Api::ok($this->eventFor((int)Api::me()[0], (int)$ev['id']));
}
/** 删除一条日程(含重复日程的三档范围) */
private function deleteEvent(array $ev): void
{
$id = (int)$ev['id'];
$scope = Api::str('scope', 'all');
$occ = substr(Api::str('occ'), 0, 10);
$rrule = (string)($ev['rrule'] ?? '');
$hasOcc = (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $occ);
if ($rrule !== '' && $hasOcc && $scope === 'once') {
Calendar::skipOnce($id, $occ);
Api::ok(['skipped' => $occ, 'scope' => 'once']);
}
if ($rrule !== '' && $hasOcc && $scope === 'future') {
$rp = Repeat::parse($rrule);
$cut = date('Y-m-d', strtotime($occ . ' -1 day'));
$rp['count'] = 0;
$rp['until'] = $cut;
$firstDay = substr((string)$ev['starts_at'], 0, 10);
if ($cut < $firstDay) {
Calendar::deleteEvent($id);
Api::ok(['deleted' => $id, 'scope' => 'future']);
}
$newRule = 'FREQ=' . strtoupper((string)$rp['freq'])
. (!empty($rp['interval']) && (int)$rp['interval'] > 1 ? ';INTERVAL=' . (int)$rp['interval'] : '')
. ($rp['freq'] === 'weekday' ? ';BYDAY=MO,TU,WE,TH,FR' : '')
. ';UNTIL=' . str_replace('-', '', $cut) . 'T235959Z';
Database::get()->prepare('UPDATE calendar_events SET rrule = ?, sequence = sequence + 1 WHERE id = ?')
->execute([$newRule, $id]);
Api::ok(['truncated_to' => $cut, 'scope' => 'future']);
}
Calendar::deleteEvent($id);
Api::ok(['deleted' => $id]);
}
/**
* 重复规则:接受两种写法 ——
* · `rrule` 直接给 RFC 5545 原文(如 `FREQ=WEEKLY;COUNT=5`);
* · 或用 `freq` + `rep_end` + `rep_count` / `rep_until` 三个字段(与网页表单同构)。
* ⚠️ 后者是关键:App 端做重复设置时最自然的就是"选频率、选结束方式",
* 不该逼它去拼 RRULE 字符串(那是把服务端的实现细节漏给了客户端)。
*/
private function rruleFromBody(): string
{
$raw = Api::str('rrule');
if ($raw !== '') { return $raw; }
$freq = Api::str('freq');
if ($freq === '') { return ''; }
return Repeat::build([
'freq' => $freq,
'rep_end' => Api::str('rep_end', 'never'),
'rep_count' => Api::int('rep_count', 10),
'rep_until' => Api::str('rep_until', ''),
]);
}
/* ═══════════════════════════════════════════════════════════════
纪念日(days)
═══════════════════════════════════════════════════════════════ */
private function days(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$a = Anniversary::find($fam, $id);
if (!$a) { Api::notFound('这条纪念日'); }
if ($method === 'GET') { Api::ok($a); }
if ($method === 'DELETE') {
Api::requireWrite();
Anniversary::remove($fam, $id);
Api::ok(['deleted' => $id]);
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Anniversary::save($fam, $this->annivData($a), $id);
Api::ok(Anniversary::find($fam, $id));
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$d = $this->annivData(null);
if ($d['title'] === '') { Api::fail('纪念日得有个名字。', 'unprocessable', 422); }
$newId = Anniversary::save($fam, $d, 0);
Api::ok(Anniversary::find($fam, $newId), 201);
}
Api::ok(Anniversary::all($fam, $pid));
}
/** 纪念日字段(契约与 days/save 路由一致) */
private function annivData(?array $cur): array
{
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
return [
'title' => $has('title') ? Api::str('title', '', 100) : (string)($cur['title'] ?? ''),
'repeat_type' => $has('repeat_type') ? Api::str('repeat_type', 'solar')
: (string)($cur['repeat_type'] ?? 'solar'),
'month' => $has('month') ? Api::int('month', 1) : (int)($cur['month'] ?? 1),
'day' => $has('day') ? Api::int('day', 1) : (int)($cur['day'] ?? 1),
'is_leap' => $has('is_leap') ? Api::bool('is_leap') : !empty($cur['is_leap']),
'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
'person_ids' => $has('person_ids') ? Api::ints('person_ids')
: array_map(static fn($p) => (int)$p['id'], (array)($cur['people'] ?? [])),
];
}
/* ═══════════════════════════════════════════════════════════════
任务
═══════════════════════════════════════════════════════════════ */
private function tasks(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$t = Task::find($fam, $id, $pid);
if (!$t) { Api::notFound('这项任务'); }
// 子动作:状态 / 搬看板 / 评论
if ($sub === 'status' && ($method === 'POST' || $method === 'PATCH')) {
Api::requireWrite();
Task::setStatus($fam, $id, Api::str('status', 'todo'), Api::int('position'));
Api::ok(Task::find($fam, $id, $pid));
}
if ($sub === 'move' && $method === 'POST') {
Api::requireWrite();
Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid);
Api::ok(Task::find($fam, $id, $pid));
}
if ($sub === 'comments') {
if ($method === 'GET') {
Api::ok(Task::comments($id));
}
if ($method === 'POST') {
Api::requireWrite();
$txt = Api::str('content', '', 4000);
if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); }
Api::ok(['id' => Task::addComment($id, $pid, $txt)], 201);
}
}
if ($method === 'GET') { Api::ok($t); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Task::update($fam, $id, [
'title' => Api::str('title', (string)$t['title'], 200),
'note' => Api::str('note', (string)($t['note'] ?? '')),
'due_at' => Api::dt('due_at', $t['due_at'] ?? null),
'repeat_rule' => Api::str('repeat_rule', (string)($t['repeat_rule'] ?? '')),
'parent_id' => Api::int('parent_id', (int)($t['parent_id'] ?? 0)),
'assignee_id' => Api::int('assignee_id', (int)($t['assignee_id'] ?? 0)),
'category_id' => Api::int('category_id', (int)($t['category_id'] ?? 0)),
'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)),
'status' => (string)$t['status'], // 状态只走 /status 子动作
]);
Api::ok(Task::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Task::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('任务得写清楚要做什么。', 'unprocessable', 422); }
$newId = Task::create($fam, $pid, [
'title' => $title,
'note' => Api::str('note', ''),
'due_at' => Api::dt('due_at', ''),
'repeat_rule' => Api::str('repeat_rule', ''),
'parent_id' => Api::int('parent_id'),
// 🔴 v44 第 5 条「负责人默认是自己」——API 建任务时同样默认自己,
// 与网页表单一致(不传 assignee_id 就是"我负责")。
'assignee_id' => Api::int('assignee_id', $pid),
'category_id' => Api::int('category_id'),
'board_id' => Api::int('board_id'),
'status' => Api::str('status', 'todo'),
]);
foreach (Api::people(Api::ints('shares'), $fam) as $sp) {
Task::setShare($newId, $sp, 'write');
}
Api::ok(Task::find($fam, $newId, $pid), 201);
}
// ---- 列表:三种视图 ----
// ?view=board → 按看板分组(默认)
// ?view=status&status=doing → 跨看板按状态
// ?view=done → 已完成(含分类/关键词过滤)
$view = Api::str('view', 'board');
if ($view === 'status') {
Api::ok(Task::byStatus($fam, $pid, Api::str('status', 'todo')));
}
if ($view === 'done') {
Api::ok(Task::doneList($fam, $pid, Api::int('category_id'), Api::str('keyword')));
}
// 看板视图:把"列"与"任务"一起给出来(App 一次请求就够画整屏)
$boards = Task::boards($fam);
/* ⚠️ `Task::boardColumns()` 返回的是**包了一层的数组**
(`['cols'=>[boardId => rows], 'boards'=>…, 'defId'=>…]`),
不是"boardId => rows"本身 —— 直接按 boardId 取会永远拿到空列
(实测:接口返回的 boards 里 tasks 全是 [],而断言只看"键在不在"就漏过去了)。
⇒ 必须取 `['cols']` 那一层。 */
$bc = Task::boardColumns($fam, $pid);
$cols = $bc['cols'] ?? [];
$out = [];
foreach ($boards as $b) {
$out[] = [
'id' => (int)$b['id'],
'name' => (string)$b['name'],
'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']),
'tasks' => array_values($cols[(int)$b['id']] ?? []),
];
}
if (!empty($cols[0])) { // 还没归到任何看板的兜底列
$out[] = ['id' => 0, 'name' => '其他', 'color' => '', 'tasks' => array_values($cols[0])];
}
Api::ok([
'boards' => $out,
'categories' => Task::categories($fam),
'status' => Task::STATUS,
'repeat' => Task::REPEAT,
]);
}
/* ═══════════════════════════════════════════════════════════════
观影
═══════════════════════════════════════════════════════════════ */
private function movies(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$m = Movie::find($fam, $id, $pid);
if (!$m) { Api::notFound('这条观影记录'); }
if ($sub === 'comments') {
if ($method === 'GET') {
$rv = MovieReview::mine($id, $pid);
Api::ok($rv ? MovieReview::forEvent($id) : []);
}
if ($method === 'POST') {
Api::requireWrite();
$rv = MovieReview::mine($id, $pid);
if (!$rv) { Api::fail('先写一条评价,才能评论。', 'unprocessable', 422); }
$txt = Api::str('content', '', 4000);
if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); }
$cid = MovieReview::addComment((int)$rv['id'], $pid, $txt,
Api::int('parent_id'), Api::str('target_person', ''));
if ($cid <= 0) { Api::fail('评论没保存下来(可能内容太长)。', 'unprocessable', 422); }
Api::ok(['id' => $cid], 201);
}
}
if ($sub === 'review' && ($method === 'POST' || $method === 'PUT' || $method === 'PATCH')) {
Api::requireWrite();
$my = MovieReview::mine($id, $pid);
$err = MovieReview::save(
$id, $fam, $pid,
Api::int('rating'),
Api::str('content', '', 8000),
array_values((array)Api::param('feelings', [])),
$my !== null
);
if ($err !== '') { Api::fail($err, 'unprocessable', 422); }
Api::ok(MovieReview::mine($id, $pid));
}
if ($method === 'GET') { Api::ok($m); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Movie::update($fam, $id, $this->movieData($m));
Api::ok(Movie::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Movie::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$d = $this->movieData(null);
if ($d['title'] === '') { Api::fail('片名不能为空。', 'unprocessable', 422); }
if ($d['starts_at'] === '') { Api::fail('观影时间不能为空。', 'unprocessable', 422); }
$newId = Movie::create($fam, $pid, $d);
Api::ok(Movie::find($fam, $newId, $pid), 201);
}
$list = Movie::all($fam, Api::str('keyword'), $pid);
[$per, $off] = Api::page(50);
Api::list(array_slice(array_values($list), $off, $per), count($list));
}
/** 观影写入字段(新建与更新共用;`$cur` 非空时做"只改给了的字段"的回退) */
private function movieData(?array $cur): array
{
$b = Api::body();
$pick = static function (string $k, $def) use ($b) {
return array_key_exists($k, $b) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET)
? null : $def; // null = "有人给了",下面按 Api::* 取真实值
};
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
return [
'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''),
'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''),
'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''),
'location' => $has('location') ? Api::str('location', '', 255) : (string)($cur['location'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
'info' => $has('info') ? Api::str('info', '') : (string)($cur['info'] ?? ''),
'cinema_id' => $has('cinema_id') ? Api::int('cinema_id') : (int)($cur['cinema_id'] ?? 0),
'attendees' => $has('attendees') ? Api::ints('attendees') : array_map(
static fn($a) => (int)$a['id'], (array)($cur['attendees'] ?? [])),
];
}
private function cinemas(int $id, string $method): void
{
[$fam] = Api::me();
if ($method === 'GET') { Api::ok(Cinema::all($fam)); }
if ($method === 'POST' || $method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$cur = $id > 0 ? Cinema::find($fam, $id) : null;
if ($id > 0 && !$cur) { Api::notFound('这家影院'); }
$name = Api::str('name', (string)($cur['name'] ?? ''), 100);
if ($name === '') { Api::fail('影院名称不能为空', 'unprocessable', 422); }
$err = Cinema::save(
$fam, $id,
Api::str('place', (string)($cur['place'] ?? ''), 100),
$name,
Api::str('address', (string)($cur['address'] ?? ''), 200) ?: null,
Cinema::normalizeColor(Api::str('color', (string)($cur['color'] ?? '#888888'), 16))
);
if ($err !== '') { Api::fail($err, 'conflict', 409); }
// save() 不返回 id ⇒ 新建时按"同地方同名"回查(表上有唯一性校验,够精确)
if ($id === 0) {
$id = 0;
foreach (Cinema::all($fam) as $c) {
if ((string)$c['name'] === $name
&& (string)($c['place'] ?? '') === Api::str('place')) {
$id = (int)$c['id']; break;
}
}
}
Api::ok(Cinema::find($fam, $id), $cur ? 200 : 201);
}
if ($method === 'DELETE' && $id > 0) {
Api::requireWrite();
Cinema::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
/* ═══════════════════════════════════════════════════════════════
旅程 / 行程
═══════════════════════════════════════════════════════════════ */
private function trips(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$t = Trip::find($fam, $id, $pid);
if (!$t) { Api::notFound('这趟旅程'); }
if ($sub === 'nodes') {
if ($method === 'GET') {
Api::ok(Trip::nodes($id, $pid));
}
if ($method === 'POST') {
Api::requireWrite();
$nid = Trip::saveNode($fam, $id, $this->nodeData(null));
Api::ok(Trip::node($nid), 201);
}
}
if ($sub === 'summary' && $method === 'GET') {
Api::ok(Trip::summary($id, $pid));
}
if ($method === 'GET') { Api::ok($t); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$d = [
'title' => Api::str('title', (string)$t['title'], 200),
'note' => Api::str('note', (string)($t['note'] ?? '')),
'summary' => Api::str('summary', (string)($t['summary'] ?? '')),
];
if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; }
Trip::update($fam, $id, $d);
Api::ok(Trip::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Trip::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('旅程得有个名字。', 'unprocessable', 422); }
$d = ['title' => $title, 'note' => Api::str('note', ''), 'summary' => Api::str('summary', '')];
if (array_key_exists('is_public', Api::body())) { $d['is_public'] = Api::bool('is_public') ? '1' : '0'; }
$newId = Trip::create($fam, $pid, $d);
Api::ok(Trip::find($fam, $newId, $pid), 201);
}
Api::ok(Trip::all($fam, Api::str('keyword')));
}
/** 行程节点字段(与网页 node_add 同一份契约) */
private function nodeData(?array $cur): array
{
$has = static fn(string $k): bool
=> array_key_exists($k, Api::body()) || array_key_exists($k, $_POST) || array_key_exists($k, $_GET);
return [
'title' => $has('title') ? Api::str('title', '', 200) : (string)($cur['title'] ?? ''),
'detail' => $has('detail') ? Api::str('detail', '') : (string)($cur['detail'] ?? ''),
'place' => $has('place') ? Api::str('place', '', 200) : (string)($cur['place'] ?? ''),
'starts_at' => $has('starts_at') ? (string)(Api::dt('starts_at') ?? '') : (string)($cur['starts_at'] ?? ''),
'ends_at' => $has('ends_at') ? (string)(Api::dt('ends_at') ?? '') : (string)($cur['ends_at'] ?? ''),
'node_type' => $has('node_type') ? Api::str('node_type', 'other') : (string)($cur['node_type'] ?? 'other'),
'status' => $has('status') ? Api::str('status', 'planned') : (string)($cur['status'] ?? 'planned'),
'category' => $has('category') ? Api::str('category', 'plan') : (string)($cur['category'] ?? 'plan'),
// stage 不在这里改:只由新建/显式动作决定(与网页 node_add 同一条纪律)
'stage' => $cur === null ? Api::str('stage', 'plan') : (string)($cur['stage'] ?? 'plan'),
'members' => $has('members') ? Api::ints('members') : [],
'travel_mode' => Api::str('travel_mode', (string)($cur['travel_mode'] ?? '')),
'vehicle_id' => Api::int('vehicle_id', (int)($cur['vehicle_id'] ?? 0)),
'line_name' => Api::str('line_name', ''),
'carrier' => Api::str('carrier', ''),
'trip_no' => Api::str('trip_no', ''),
];
}
/* ═══════════════════════════════════════════════════════════════
记账
═══════════════════════════════════════════════════════════════ */
private function ledger(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$lg = Ledger::find($fam, $id, $pid);
if (!$lg) { Api::notFound('这本账本'); }
if ($sub === 'entries') {
if ($method === 'GET') {
$es = Ledger::entries($id);
Api::ok(['items' => $es, 'summary' => Ledger::summary($es)]);
}
if ($method === 'POST') {
Api::requireWrite();
// ⚠️ `amount` 交给模型自己转分(Ledger::toCents)—— App 传元,
// 与网页表单同一口径;这里**不要**先转一次(会双重换算)。
$amount = Api::param('amount', '');
if (trim((string)$amount) === '') {
Api::fail('金额不能为空。', 'unprocessable', 422);
}
$eid = Ledger::addEntry($id, [
'kind' => Api::str('kind', 'expense'),
'amount' => $amount,
'title' => Api::str('title', '', 120),
'note' => Api::str('note', ''),
'person_id' => Api::int('person_id'),
], $pid);
Api::ok(['id' => $eid], 201);
}
}
if ($method === 'GET') {
$es = Ledger::entries($id);
Api::ok(['ledger' => $lg, 'summary' => Ledger::summary($es)]);
}
if ($method === 'DELETE') {
Api::requireWrite();
Ledger::remove($fam, $id, $pid);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$name = Api::str('name', '', 80);
/* ⚠️ `Ledger::defaultName()` **需要一个 ownerId 参数**(实测:
不给参数会 ArgumentCountError)—— 用当前人补上。 */
$newId = Ledger::create($fam, $pid, $name !== '' ? $name : Ledger::defaultName($pid));
Api::ok(Ledger::find($fam, $newId, $pid), 201);
}
Api::ok(Ledger::mine($fam, $pid));
}
/* ═══════════════════════════════════════════════════════════════
资源 / 书 / 笔记
═══════════════════════════════════════════════════════════════ */
private function resources(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$r = Resource::find($fam, $id, $pid);
if (!$r) { Api::notFound('这条资源'); }
if ($method === 'GET') { Api::ok($r); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Resource::update($fam, $id, [
'title' => Api::str('title', (string)$r['title'], 200),
'content' => Api::str('content', (string)($r['content'] ?? '')),
'category'=> Api::str('category', (string)($r['category'] ?? '')),
]);
Api::ok(Resource::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Resource::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('资源得有个标题。', 'unprocessable', 422); }
$newId = Resource::create($fam, $pid, $pid, [
'title' => $title,
'content' => Api::str('content', ''),
'category' => Api::str('category', ''),
]);
Api::ok(Resource::find($fam, $newId, $pid), 201);
}
Api::ok(Resource::all($fam, $pid, Api::int('owner_id'), Api::str('keyword'),
Api::str('sort', 'used')));
}
private function books(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$bk = Book::find($fam, $id);
if (!$bk) { Api::notFound('这本书'); }
if ($sub === 'notes' && $method === 'GET') { Api::ok(Book::notes($id, $pid)); }
if ($sub === 'notes' && $method === 'POST') {
Api::requireWrite();
$txt = Api::str('content', '', 8000);
if ($txt === '') { Api::fail('笔记内容不能为空。', 'unprocessable', 422); }
$nid = Book::addNote($fam, $id, $pid, [
'content' => $txt,
'kind' => Api::str('kind', 'note'),
'chapter' => Api::str('chapter', '', 120),
'loc' => Api::str('loc', '', 120),
]);
if ($nid <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); }
Api::ok(['id' => $nid], 201);
}
if ($method === 'GET') { Api::ok($bk); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$d = $bk;
if (array_key_exists('status', Api::body())) { $d['status'] = Api::str('status'); }
if (array_key_exists('current_page', Api::body())) { $d['current_page'] = Api::str('current_page'); }
if (array_key_exists('rating', Api::body())) { $d['rating'] = Api::int('rating'); }
Book::update($fam, $id, $d);
Api::ok(Book::find($fam, $id));
}
if ($method === 'DELETE') {
Api::requireWrite();
Book::destroy($fam, $id);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('书名不能为空。', 'unprocessable', 422); }
$newId = Book::create($fam, $pid, [
'title' => $title,
'author' => Api::str('author', '', 120),
'category' => Api::str('category', ''),
'status' => Api::str('status', 'want'),
]);
Api::ok(Book::find($fam, $newId), 201);
}
Api::ok(Book::all($fam, [
'status' => Api::str('status'),
'category' => Api::str('category'),
'owner' => Api::int('owner_id'),
'keyword' => Api::str('keyword'),
]));
}
private function notes(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
$n = Note::find($fam, $id, $pid);
if (!$n) { Api::notFound('这条笔记'); }
if ($method === 'GET') { Api::ok($n); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Note::update($fam, $id, $pid, [
'title' => Api::str('title', (string)$n['title'], 200),
'body' => Api::str('body', (string)($n['body'] ?? '')),
'tags' => Api::param('tags', $n['tags'] ?? ''),
]);
Api::ok(Note::find($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
Note::destroy($fam, $id, $pid);
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$title = Api::str('title', '', 200);
if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); }
$newId = Note::create($fam, $pid, [
'title' => $title,
'body' => Api::str('body', ''),
'tags' => Api::param('tags', []),
]);
Api::ok(Note::find($fam, $newId, $pid), 201);
}
[$per, $off] = Api::page(50);
Api::ok([
'tags' => Note::tagCloud($fam, $pid),
'items' => Note::all($fam, $pid, Api::str('keyword'), Api::str('tag'), $per, $off),
]);
}
}