2026-10-12 15:31:03 +08:00
|
|
|
|
<?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
|
|
|
|
|
|
{
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 健康检查 / 版本探测:**不需要令牌**(App 首次填「服务器地址」时就打它)
|
2026-10-12 15:31:03 +08:00
|
|
|
|
if ($res === 'ping' || $res === '') {
|
|
|
|
|
|
Api::ok([
|
2026-10-18 03:34:47 +08:00
|
|
|
|
'service' => 'familybook',
|
|
|
|
|
|
'api' => 'v1',
|
|
|
|
|
|
'time' => date('c'),
|
|
|
|
|
|
// 🔴 v55:端上"输地址 → 连接"那一步要能确认
|
|
|
|
|
|
// "这是个家书服务器、且支持账号登录" —— 只回 HTTP 200 不够
|
|
|
|
|
|
// (任何网页都 200,用户填错地址会让他在登录页反复试密码)。
|
|
|
|
|
|
'app' => '家书',
|
|
|
|
|
|
'features' => [
|
|
|
|
|
|
'login' => 1,
|
|
|
|
|
|
// 认手机号也认用户名(见 `login()` 的说明)
|
|
|
|
|
|
'login_account' => 1,
|
|
|
|
|
|
// v57:登录即"一台设备一个会话",`/me` 滑动续期 24h,
|
|
|
|
|
|
// `/devices` 可在网页端/端上踢设备
|
|
|
|
|
|
'device_session' => 1,
|
|
|
|
|
|
'home_memories' => 1,
|
|
|
|
|
|
],
|
2026-10-12 15:31:03 +08:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch ($res) {
|
|
|
|
|
|
case 'me': $this->me($method); return;
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 🔴 v57:设备会话。`logout` 吊销当前这一枚;`devices` 是"我有哪些设备在登录"。
|
|
|
|
|
|
case 'logout': $this->logout($method); return;
|
|
|
|
|
|
case 'devices': $this->devices($id, $method); return;
|
2026-10-12 15:31:03 +08:00
|
|
|
|
case 'tokens': $this->tokens($id, $method); return;
|
|
|
|
|
|
case 'home': $this->home(); return;
|
|
|
|
|
|
case 'people': $this->people($id, $sub, $method); return;
|
2026-10-14 09:41:34 +08:00
|
|
|
|
// 🔴 v45:名册(**我名册里的人**,不是全家 800 人)。
|
|
|
|
|
|
// 与 `people` 的区别见 `roster()` 方法的说明。
|
|
|
|
|
|
case 'roster': $this->roster($method); return;
|
|
|
|
|
|
// 🔴 v45:家谱(以我为中心的关系图)。
|
|
|
|
|
|
// 端上「家谱」页要用:父母 / 配偶 / 子女 / 兄弟姐妹 + 各代层次。
|
|
|
|
|
|
// ⚠️ 数据来自 `relations` 表(只存相邻两代的边),
|
|
|
|
|
|
// 亲属关系靠 `Kinship` 推导 —— 与 Web 端 `kin/tree.php` 同一套。
|
|
|
|
|
|
case 'kin': $this->kin(); return;
|
2026-10-12 15:31:03 +08:00
|
|
|
|
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;
|
2026-10-14 09:41:34 +08:00
|
|
|
|
// 🔴 v45:登录(手机号 + 密码 → API 令牌)。
|
|
|
|
|
|
// 为什么必须走 API 而不是网页登录后复制 Cookie:
|
|
|
|
|
|
// App 是 Bearer 令牌那套,没有会话;而原先前端只能让人
|
|
|
|
|
|
// **手动去网页签发令牌再粘贴**到 App 里 —— 这个体验没法接受。
|
|
|
|
|
|
case 'login': $this->login($method); return;
|
2026-10-12 15:31:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Api::fail('未知的接口资源:' . $res, 'not_found', 404);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 09:41:34 +08:00
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
登录(手机号 + 密码 → API 令牌)
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-18 03:34:47 +08:00
|
|
|
|
* `POST /api/v1/login` —— 用「手机号 或 用户名」+ 密码换一枚 API 令牌。
|
2026-10-14 09:41:34 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴 为什么需要这个接口(v45,用户 2026-10-12 "把服务器接进来、跑正式流程"):
|
|
|
|
|
|
* 原先 App 只能让人**先去网页「个人设置 → API 令牌」签发、再手动粘贴**
|
|
|
|
|
|
* 到 App 的设置页里。这是个"能用但没法给别人用"的流程。
|
|
|
|
|
|
* 有了它,App 首页就能直接登录。
|
|
|
|
|
|
*
|
2026-10-18 03:34:47 +08:00
|
|
|
|
* 🔴 v55(用户 2026-10-17「完整的登录功能……输入用户名密码」)——
|
|
|
|
|
|
* **同一个入口既认手机号也认用户名**:
|
|
|
|
|
|
* · 输入像手机号(纯数字/带 + - 空格)→ 走 `phone_hash` 精确查找;
|
|
|
|
|
|
* · 否则当作**用户名**(= 名册里的姓名)→ 只在"有登录账号的人"里找。
|
|
|
|
|
|
* ⚠️ 为什么要这样(而不是只留手机号):一家人里老人记不住手机号,
|
|
|
|
|
|
* 但一定记得住自己叫什么;而 `accounts` 表里**没有用户名列**
|
|
|
|
|
|
* (见 Schema),为它加列 + 迁移的代价远大于收益。
|
|
|
|
|
|
* 姓名在库里本来就有(`persons.name`),且带账号的人极少(一家几个人),
|
|
|
|
|
|
* 重名风险低到可以接受 —— 真重名时**明确报错让他改用手机号**,
|
|
|
|
|
|
* 而不是随便挑一个。
|
|
|
|
|
|
* ⚠️ 参数名从 `phone` 扩成 `account`,**仍然接受 `phone`**
|
|
|
|
|
|
* (老版本 App 还在用,不能因为服务端升级就把人挡在门外)。
|
|
|
|
|
|
*
|
2026-10-14 09:41:34 +08:00
|
|
|
|
* ⚠️ 与网页登录(`Auth::login`)的三处**有意不同**:
|
|
|
|
|
|
* ① 不建会话 —— API 是无状态 Bearer,登录只是"换令牌";
|
|
|
|
|
|
* ② 限流不能放 `$_SESSION`(API 没有 session)⇒ 落**文件**,
|
2026-10-18 03:34:47 +08:00
|
|
|
|
* 按"登录名 + 来源 IP"计数(见下方 `loginThrottle`);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
* ③ 失败**不区分**"没这个人"与"密码错" —— 同一句提示,
|
|
|
|
|
|
* 否则接口成了"手机号是否已注册"的探测器。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 成功时**顺带补建默认日历本**(与 `Auth::login` 同样的兜底):
|
|
|
|
|
|
* 全新安装时 `Schema::ensure()` 跑在"建人"之前,
|
|
|
|
|
|
* 那次遍历一个账号都没有 ⇒ 一本日历都不建。放在登录处最稳。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function login(string $method): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($method !== 'POST') {
|
|
|
|
|
|
Api::fail('登录要用 POST。', 'method_not_allowed', 405);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// `account` 优先;没有就退回 `phone`(老客户端兼容,见方法注释)
|
|
|
|
|
|
$account = Api::str('account', '', 60);
|
|
|
|
|
|
if ($account === '') {
|
|
|
|
|
|
$account = Api::str('phone', '', 40);
|
|
|
|
|
|
}
|
2026-10-14 09:41:34 +08:00
|
|
|
|
$password = (string)Api::param('password', '');
|
|
|
|
|
|
$label = Api::str('label', 'App 登录', 60);
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/* ---- 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,设备列表里至少有个称呼
|
|
|
|
|
|
}
|
2026-10-14 09:41:34 +08:00
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
if ($account === '' || $password === '') {
|
|
|
|
|
|
Api::fail('账号(手机号或用户名)和密码都得填。', 'unprocessable', 422);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 限流:失败次数有成本(防爆破)----
|
2026-10-18 03:34:47 +08:00
|
|
|
|
$gate = self::loginThrottle($account);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
if ($gate !== null) {
|
|
|
|
|
|
Api::fail($gate, 'too_many_requests', 429);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
$row = self::findLoginAccount($account);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
|
|
|
|
|
|
// ⚠️ 账号不存在 / 被停用 / 密码错 —— **统一**同一句提示 + 同一个延迟。
|
|
|
|
|
|
// 分开回话就等于告诉对方"这个手机号存在/不存在"。
|
|
|
|
|
|
$ok = $row
|
|
|
|
|
|
&& (string)$row['status'] === 'active'
|
|
|
|
|
|
&& password_verify($password, (string)$row['password_hash']);
|
|
|
|
|
|
if (!$ok) {
|
2026-10-18 03:34:47 +08:00
|
|
|
|
self::loginThrottleFail($account);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
// 固定延迟:让每次失败都近似同价(对脚本爆破是实打实的成本)
|
|
|
|
|
|
usleep(300000);
|
2026-10-18 03:34:47 +08:00
|
|
|
|
Api::fail('账号或密码不对。', 'unauthorized', 401);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
self::loginThrottleClear($account);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
|
|
|
|
|
|
$personId = (int)$row['person_id'];
|
|
|
|
|
|
$familyId = (int)$row['family_id'];
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/* ⚠️ 设备会话**一律 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
|
|
|
|
|
|
);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 家庭名(端上登录成功后立刻能显示"已连上 X 家",
|
|
|
|
|
|
// 不必再多打一次 `/me`;拿不到就给空串,不影响登录)
|
|
|
|
|
|
$familyName = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
$f = Family::find($familyId) ?: [];
|
|
|
|
|
|
$familyName = (string)($f['name'] ?? '');
|
|
|
|
|
|
} catch (\Throwable $ex) {
|
|
|
|
|
|
error_log('[familybook] 登录取家庭名失败(已跳过):' . $ex->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 09:41:34 +08:00
|
|
|
|
Api::ok([
|
|
|
|
|
|
'token' => $token,
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 到期时间由**服务端**算(端上只拿来显示,绝不据此判断"还能不能用")
|
|
|
|
|
|
'expires_at' => date('Y-m-d H:i:s', time() + ApiToken::TTL_HOURS * 3600),
|
|
|
|
|
|
'ttl_hours' => ApiToken::TTL_HOURS,
|
|
|
|
|
|
// 端上要用它记"上次校验成功的时间"(本机时钟可以改,服务端时间不行)
|
|
|
|
|
|
'server_time'=> date('c'),
|
2026-10-14 09:41:34 +08:00
|
|
|
|
'person' => [
|
|
|
|
|
|
'id' => $personId,
|
|
|
|
|
|
'name' => (string)$row['name'],
|
|
|
|
|
|
'avatar' => (string)($row['avatar'] ?? ''),
|
|
|
|
|
|
'role' => (string)$row['role'],
|
|
|
|
|
|
'family_id' => $familyId,
|
|
|
|
|
|
],
|
2026-10-18 03:34:47 +08:00
|
|
|
|
'family' => ['id' => $familyId, 'name' => $familyName],
|
|
|
|
|
|
// 回显设备,端上可以核对"服务器记住的是不是这台"
|
|
|
|
|
|
'device' => [
|
|
|
|
|
|
'id' => $deviceId,
|
|
|
|
|
|
'name' => $deviceName,
|
|
|
|
|
|
'max_devices'=> ApiToken::MAX_DEVICES,
|
|
|
|
|
|
],
|
2026-10-14 09:41:34 +08:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 按「手机号 或 用户名」找人(登录用)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 两步查找,顺序**不能反**:
|
|
|
|
|
|
* ① **手机号**:`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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 09:41:34 +08:00
|
|
|
|
/* ---- 登录限流(**文件实现**,不碰数据库结构)----
|
|
|
|
|
|
⚠️ 为什么不用 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);
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 🔴 **全部电话**(主号在前 + person_contacts 里的补充电话)。
|
|
|
|
|
|
// App 点人名直接拨号要用:有主号打主号,没主号打补充号
|
|
|
|
|
|
// (用户 2026-10-15 定的规则)。见 PersonQuery::phonesOf 的说明。
|
|
|
|
|
|
$row['phones'] = PersonQuery::phonesOf($row);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
// 生日:阴历/阳历各一条,**只到月日**(年份不外发)
|
|
|
|
|
|
$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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 15:31:03 +08:00
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
我 / 令牌 / 首页
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
private function me(string $method): void
|
|
|
|
|
|
{
|
|
|
|
|
|
[$fam, $pid] = Api::me();
|
|
|
|
|
|
$p = Person::find($pid) ?: [];
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 🔴 角色与写权限**必须来自 `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();
|
2026-10-12 15:31:03 +08:00
|
|
|
|
Api::ok([
|
|
|
|
|
|
'id' => $pid,
|
|
|
|
|
|
'name' => (string)($p['name'] ?? ''),
|
|
|
|
|
|
'avatar' => avatar_url($p['avatar'] ?? null),
|
2026-10-18 03:34:47 +08:00
|
|
|
|
'role' => (string)($act['role'] ?? 'member'),
|
|
|
|
|
|
'can_write'=> (bool)($act['can_write'] ?? false),
|
2026-10-12 15:31:03 +08:00
|
|
|
|
'family' => (function () use ($fam): array {
|
|
|
|
|
|
$f = Family::find($fam) ?: [];
|
|
|
|
|
|
return ['id' => $fam, 'name' => (string)($f['name'] ?? '')];
|
|
|
|
|
|
})(),
|
|
|
|
|
|
// 各模块主题色:App 端顶部/分类色块直接用它,不必自己维护一张表
|
|
|
|
|
|
'colors' => $this->moduleColors($fam),
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/* ---- 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'),
|
2026-10-12 15:31:03 +08:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-18 03:34:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 退出登录:吊销**当前这一枚**令牌(用户 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 15:31:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 首页(`GET /api/v1/home`)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 v54(用户 2026-10-16)—— **补齐网页版首页有的那几样**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「首页上概述性的那个东西先弄好……里面的数据好像有些不符,
|
|
|
|
|
|
* 比如**寒露、重阳节、儿子的生日**,这些都是**乱写的**,要用**真实数据**填写一下。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 根因:网页版首页的信息面板(农历 / 节气 / 节日 / 纪念日 / 生日 / 天气 /
|
|
|
|
|
|
* 近七天数字)是在 `index.php` 里**算好直接塞给模板**的,
|
|
|
|
|
|
* 从来没走过这个接口 ⇒ App 只能拿端上的演示常量顶着,
|
|
|
|
|
|
* 于是"寒露/重阳节/儿子生日"这种写死的假数据就一直显示在真实用户面前。
|
|
|
|
|
|
* ⇒ 这里把 `index.php` 那几段**原样搬过来**(同一批函数、同一条 SQL),
|
|
|
|
|
|
* 保证两端**同一个数字**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么不在端上自己算农历/节气:那些表(`Holiday::solarTermRange` /
|
|
|
|
|
|
* `Lunar::solarToLunar`)在服务端是**一份权威实现**,
|
|
|
|
|
|
* 端上再实现一遍,早晚会出现"网页说今天小寒、手机说昨天"这种对不上的事。
|
|
|
|
|
|
*/
|
2026-10-12 15:31:03 +08:00
|
|
|
|
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')));
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// ⚠️ 农历是**每天一条**的派生条目(7 天就是七八行"廿一 / 廿二…"),
|
|
|
|
|
|
// 在首页这种"接下来要做什么"的清单里纯属噪音 —— 与 index.php 同一取舍。
|
|
|
|
|
|
// (节假日保留:"这周六放假"是有信息量的。)
|
2026-10-12 15:31:03 +08:00
|
|
|
|
$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));
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 全天排最前、其余按时间(与网页版日视图同一顺序 —— 手机首页
|
|
|
|
|
|
// 现在直接复用日程页那块组件,顺序不一致会与日程页看起来"不是同一份数据")。
|
|
|
|
|
|
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-12 15:31:03 +08:00
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// ---- 任务:进行中 + 待办(用户 2026-10-16:「把待办的也放在这」)----
|
|
|
|
|
|
// ⚠️ 与网页版 `index.php` **同口径**:原来是"doing + 只把**已逾期**的 todo
|
|
|
|
|
|
// 收进来",现在 user 明确要待办全部进来。
|
|
|
|
|
|
// ⚠️ 顺序 = 进行中在前、待办在后(各组内部仍按截止时间升序,
|
|
|
|
|
|
// `byStatus` 的 ORDER BY 已经排好)—— 用户原话"放在进行中的下面"。
|
2026-10-12 15:31:03 +08:00
|
|
|
|
$tasks = Task::byStatus($fam, $pid, 'doing');
|
|
|
|
|
|
foreach (Task::byStatus($fam, $pid, 'todo') as $t) {
|
2026-10-18 03:34:47 +08:00
|
|
|
|
$tasks[] = $t;
|
2026-10-12 15:31:03 +08:00
|
|
|
|
}
|
2026-10-18 03:34:47 +08:00
|
|
|
|
// 给每条任务补**派生看板色**:与网页版首页**同一函数**
|
|
|
|
|
|
//(用户 2026-10-08 报过"首页任务圆点颜色没跟看板一致")。
|
|
|
|
|
|
// ⚠️ 不能直接读 `board_color`(没设色时为 null),要走派生:
|
|
|
|
|
|
// 同一个看板名永远同一个色,用户才记得住。
|
|
|
|
|
|
foreach ($tasks as &$tk) {
|
|
|
|
|
|
$tk['board_color_calc'] = Task::boardColorById($fam, (int)($tk['board_id'] ?? 0) ?: null);
|
|
|
|
|
|
}
|
|
|
|
|
|
unset($tk);
|
|
|
|
|
|
|
|
|
|
|
|
$overdueN = 0;
|
|
|
|
|
|
foreach ($tasks as $t) {
|
|
|
|
|
|
if (Task::isOverdue($t['due_at'] ?? null)) { $overdueN++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 农历(抬头要"农历几月几日",不要"二〇二六年"那一段)----
|
|
|
|
|
|
$lunarToday = Lunar::solarToLunar((int)date('Y'), (int)date('n'), (int)date('j'));
|
|
|
|
|
|
$lunarShort = $lunarToday === null ? '' : (
|
|
|
|
|
|
Lunar::monthName((int)$lunarToday['month'], !empty($lunarToday['is_leap']))
|
|
|
|
|
|
. Lunar::dayName((int)$lunarToday['day']));
|
|
|
|
|
|
$lunarLine = $lunarToday === null ? '' : Lunar::label(
|
|
|
|
|
|
(int)$lunarToday['year'], (int)$lunarToday['month'], (int)$lunarToday['day'],
|
|
|
|
|
|
!empty($lunarToday['is_leap']));
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 日子标记:节气 / 法定节日 / 传统节日 / 纪念日 / 生日 ----
|
|
|
|
|
|
$marks = Home::todayMarks($fam, $pid, $today);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 天气(三级回落:个人经纬度 → 内置城市表 → 家庭级兜底 → 不显示)----
|
|
|
|
|
|
$wloc = Home::weatherLocation($pid, $fam);
|
|
|
|
|
|
$weather = $wloc === null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: Home::weather($fam, (string)$wloc['lat'], (string)$wloc['lon'], (string)$wloc['place']);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 概述卡底部的数字(与网页版 `hb-stats` **同一口径**)----
|
|
|
|
|
|
// 🔴 用户 2026-10-16:「下面……**未来7天**……这几个就是去掉了,不要了。」
|
|
|
|
|
|
// ⇒ 概述卡里原来那格"未来七天"(=`$ev7`,today..+7 天的条数)
|
|
|
|
|
|
// **不再显示**;网页版概述卡本来也是这几个:
|
|
|
|
|
|
// 条日程(今天)/ 件在做 / 件待办 / 件逾期。
|
|
|
|
|
|
// ⚠️ 数字必须与**下面那块任务列表同源**(v24 第 25 批用户报过
|
|
|
|
|
|
// 「为什么显示 0 条待办呢?任务看板明明有一个进行中的任务啊」
|
|
|
|
|
|
// —— 根因就是"数字和列表两个口径",所以这里直接数 `$tasks`)。
|
|
|
|
|
|
$doingN = 0;
|
|
|
|
|
|
$todoRest = 0;
|
|
|
|
|
|
foreach ($tasks as $t) {
|
|
|
|
|
|
if ((string)($t['status'] ?? 'todo') === 'doing') { $doingN++; }
|
|
|
|
|
|
else { $todoRest++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
$stat = [
|
|
|
|
|
|
'today_events' => count($todayEvents),
|
|
|
|
|
|
'doing' => $doingN,
|
|
|
|
|
|
'todo' => $todoRest,
|
|
|
|
|
|
'overdue' => $overdueN,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-12 15:31:03 +08:00
|
|
|
|
Api::ok([
|
|
|
|
|
|
'greeting' => Home::greeting(
|
2026-10-18 03:34:47 +08:00
|
|
|
|
Nickname::display($pid, null) ?: '', $today, count($soon), $overdueN),
|
2026-10-12 15:31:03 +08:00
|
|
|
|
'date' => $today,
|
|
|
|
|
|
'weekday' => Home::weekday($today),
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/** 抬头:`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'],
|
2026-10-12 15:31:03 +08:00
|
|
|
|
'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('这个人'); }
|
2026-10-18 03:34:47 +08:00
|
|
|
|
|
|
|
|
|
|
// 🔴 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 15:31:03 +08:00
|
|
|
|
if ($method === 'GET') {
|
2026-10-18 03:34:47 +08:00
|
|
|
|
Api::ok(self::personDetail($p, $pid));
|
2026-10-12 15:31:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($method === 'PATCH' || $method === 'PUT') {
|
|
|
|
|
|
Api::requireWrite();
|
2026-10-18 03:34:47 +08:00
|
|
|
|
self::applyPersonWrite($id, $p, $fam, $pid);
|
|
|
|
|
|
Api::ok(self::personDetail((array)Person::find($id), $pid));
|
2026-10-12 15:31:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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); }
|
2026-10-18 03:34:47 +08:00
|
|
|
|
$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);
|
2026-10-12 15:31:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 列表:支持 ?q= 搜索、?kind=member|contact
|
|
|
|
|
|
$list = Person::all($fam, Api::str('q'), Api::str('kind', 'member'), $pid);
|
2026-10-14 09:41:34 +08:00
|
|
|
|
// ⚠️ 逐行脱敏(见 stripSensitivePerson 的说明:网页要、App 不要)
|
|
|
|
|
|
$safe = [];
|
|
|
|
|
|
foreach ($list as $row) {
|
|
|
|
|
|
$safe[] = self::stripSensitivePerson((array)$row);
|
|
|
|
|
|
}
|
|
|
|
|
|
Api::list($safe);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:34:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 单人**完整详情**(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 09:41:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把人员行里**不该给到 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;
|
2026-10-12 15:31:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
通知
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
2026-10-14 09:41:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家谱(`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),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 15:31:03 +08:00
|
|
|
|
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),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|