Files
laoyang ebaed21920 v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)
用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
2026-10-18 03:34:47 +08:00

361 lines
18 KiB
PHP
Raw Permalink 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 内核**。
*
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。
* 你现在就根据我们现在的功能,把所有接口设计和开发出来。
* 导航这个模块不需要了,因为这个模块是专门为我们的外部端开发的,
* 所以这个不需要,其他全部都需要。」
*
* ════════════════════════════════════════════════════════════════════
* 为什么是一个"内核"而不是在 api.php 里逐条写
* ════════════════════════════════════════════════════════════════════
* 213 个网页路由要映射成 REST。若逐条手写,"取参数 / 鉴权 / 序列化 / 错误码"
* 会在几十个地方各抄一遍 —— 那是**必然**有一天长歪的写法(本项目已经栽过
* 好几轮"两处写法不同步")。所以这里把四件事收进一处:
* · **鉴权**:`Authorization: Bearer <token>` → `Api::actor()`;
* · **取参**:`Api::param()`(JSON body 与表单都能吃);
* · **出参**:`Api::ok() / Api::fail()`(统一信封 + HTTP 码);
* · **动作守卫**:`Api::requireWrite()`(只读账号不能写,与网页一致)。
*
* ════════════════════════════════════════════════════════════════════
* 统一响应信封(**App 端只按这一套解析**)
* ════════════════════════════════════════════════════════════════════
* 成功:{ "ok": true, "data": <任意> }
* 失败:{ "ok": false, "error": "人话错误信息", "code": "bad_request" }
* HTTP 码同时给出:200 / 400 / 401 / 403 / 404 / 409 / 422 / 500。
* ⚠️ 用**信封**而不是裸数据:否则 App 端无法区分"成功但值是 null"
* 与"请求失败"(HTTP 200 + 空 body 的歧义)。
*
* ════════════════════════════════════════════════════════════════════
* 与网页共用业务类的**唯一真值来源**
* ════════════════════════════════════════════════════════════════════
* 所有写操作都走 `App\Movie::create()` 这类**模型方法**(网页路由也是调它们),
* 不在 API 里另写一套 SQL。理由:
* · 权限/归属校验("这本日历能不能写""这个人在不在我家")已经在模型里;
* · 将来网页那边修了业务规则,API 自动跟上,不会出现"App 建的记录与网页建的不同"。
*/
declare(strict_types=1);
namespace App;
final class Api
{
/* ══════════════════════════════════════════════════════════════
鉴权
══════════════════════════════════════════════════════════════ */
/** 从 Authorization 头里取 Bearer 令牌(也接受 `?token=` —— 见下)。 */
public static function bearer(): string
{
/* ⚠️ 取头的三种写法都要认:Apache 直接监听时是 `HTTP_AUTHORIZATION`;
PHP-FPM 下有时只出现在 `REDIRECT_HTTP_AUTHORIZATION`(重写之后);
还有 `apache_request_headers()`(老 Apache + CGI 的兜底)。
本项目已经因为"头取不到"栽过一次(DAV 的 `CGIPassAuth On`,见 README),
所以这里三层都试,**不赌**环境。 */
$raw = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if ($raw === '') { $raw = (string)($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); }
if ($raw === '' && function_exists('apache_request_headers')) {
$h = apache_request_headers();
foreach ($h as $k => $v) {
if (strcasecmp((string)$k, 'Authorization') === 0) { $raw = (string)$v; break; }
}
}
if (preg_match('/^\s*Bearer\s+(.+)$/i', $raw, $m)) {
return trim($m[1]);
}
return '';
}
/**
* 当前调用者。成功返回 ['person_id','family_id','token_id','role','can_write']。
*
* 🔴🔴 **本函数会直接结束请求**(未鉴权时 401 + exit)——这是刻意的:
* 若返回 null 让每个调用点自己判断,总有一天有人忘了判。
*/
public static function actor(): array
{
static $cache = null;
if (is_array($cache)) { return $cache; }
$token = self::bearer();
if ($token === '') {
self::fail('缺少 API 令牌(请在请求头带 `Authorization: Bearer <token>`)。',
'unauthorized', 401);
}
$hit = ApiToken::verify($token);
if ($hit === null) {
self::fail('API 令牌无效、已过期或被吊销。', 'unauthorized', 401);
}
$personId = (int)$hit[0];
$p = Person::find($personId);
if (!$p) {
self::fail('令牌对应的人不存在。', 'unauthorized', 401);
}
/* 角色与写权限**照抄网页那套判定**(Auth::canWrite / isGuest),
否则会出现"网页里不能写、App 里能写"这种权限裂缝。
⚠️ Auth 的方法读的是 $_SESSION —— API 无 session,所以这里按同一个
语义(role + status)**重算**一遍,而不是去读 Auth。 */
$st = Database::get()->prepare(
'SELECT role, status FROM accounts WHERE person_id = ? LIMIT 1'
);
$st->execute([$personId]);
$acct = $st->fetch() ?: [];
$role = (string)($acct['role'] ?? 'member');
if ((string)($acct['status'] ?? 'active') !== 'active') {
self::fail('这个账号已被停用。', 'forbidden', 403);
}
$cache = [
'person_id' => $personId,
'family_id' => (int)$p['family_id'],
'token_id' => (int)$hit[1],
/* ---- v57:当前这次请求是「哪台设备」发来的 ----
`devices` 接口靠它标出「这台设备」(不能误踢自己),
`/me` 靠它把"会话还剩多久"回给端上。
⚠️ 老令牌(v35 之前签的)没有 device_id,这里会是空串 ——
下游判断必须容忍空串,不能拿它当"设备不存在"。 */
'device_id' => (string)($hit['device_id'] ?? ''),
'device_name' => (string)($hit['device_name'] ?? ''),
'expires_at' => isset($hit['expires_at']) && $hit['expires_at'] !== null
? (string)$hit['expires_at'] : null,
'role' => $role,
'can_write' => $role !== 'guest',
];
return $cache;
}
/** 只读账号(guest)不能写 —— 与网页的 `Auth::requireWrite()` 同一语义。 */
public static function requireWrite(): void
{
$a = self::actor();
if (!$a['can_write']) {
self::fail('只读账号不能修改数据。', 'forbidden', 403);
}
}
/* ══════════════════════════════════════════════════════════════
取参与出参
══════════════════════════════════════════════════════════════ */
private static ?array $body = null;
/** 请求体(JSON 或表单),只解析一次。 */
public static function body(): array
{
if (self::$body !== null) { return self::$body; }
$ct = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if (stripos($ct, 'application/json') !== false) {
$raw = (string)file_get_contents('php://input');
$dec = $raw === '' ? [] : json_decode($raw, true);
self::$body = is_array($dec) ? $dec : [];
} else {
self::$body = array_merge($_GET, $_POST);
}
return self::$body;
}
/**
* 取一个参数:GET / POST 表单 / JSON body 都能吃。
* ⚠️ 合并优先级 JSON body > POST > GET —— 与直觉一致(显式 body 最权威)。
*/
public static function param(string $key, $default = null)
{
$b = self::body();
if (array_key_exists($key, $b)) { return $b[$key]; }
if (array_key_exists($key, $_GET)) { return $_GET[$key]; }
return $default;
}
/** 取字符串参数(trim + 可选长度上限) */
public static function str(string $key, string $default = '', int $max = 0): string
{
$v = self::param($key, $default);
$v = is_scalar($v) ? (string)$v : '';
$v = trim($v);
return ($max > 0) ? mb_substr($v, 0, $max) : $v;
}
/** 取整数参数 */
public static function int(string $key, int $default = 0): int
{
$v = self::param($key, $default);
return is_scalar($v) ? (int)$v : $default;
}
/** 取布尔参数(认 1/true/'on'/'yes') */
public static function bool(string $key, bool $default = false): bool
{
$v = self::param($key, $default);
if (is_bool($v)) { return $v; }
if (is_scalar($v)) {
return in_array(strtolower((string)$v), ['1', 'true', 'on', 'yes'], true);
}
return $default;
}
/** 取整数数组(如 attendees[] / shares[]) */
public static function ints(string $key): array
{
$v = self::param($key, []);
if (!is_array($v)) { return []; }
return array_values(array_map('intval', $v));
}
/**
* 🔴 时间参数的**双格式**接受(App 的痛点,刻意做得宽容):
* · 'YYYY-MM-DD HH:MM(:SS)' —— 网页控件产出的格式;
* · 'YYYY-MM-DDTHH:MM(:SS)…' —— ISO 8601(iOS/Android 原生日期控件默认);
* · 'YYYY-MM-DD' —— 纯日期(全天日程;时间补 00:00:00)。
* 解析不出 → 返回 null(**不抛异常**,让调用方决定"忽略"还是"报错")。
*/
public static function dt(string $key, ?string $default = null): ?string
{
$v = self::param($key, null);
if ($v === null || $v === '') { return $default; }
if (!is_scalar($v)) { return $default; }
$s = trim((string)$v);
if ($s === '') { return $default; }
// 先到 UTC 再落地:iOS 给的是带 Z / 带偏移的 ISO 串,直接 substr 会拿到 UTC 时刻
$ts = strtotime($s);
if ($ts === false) { return null; }
// 纯日期:时间部分归零(与网页"全天日程"的口径一致)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
return date('Y-m-d', $ts) . ' 00:00:00';
}
return date('Y-m-d H:i:s', $ts);
}
/** 成功响应并结束请求。 */
public static function ok($data = null, int $code = 200, array $extra = []): void
{
self::send(array_merge(['ok' => true, 'data' => $data], $extra), $code);
}
/** 失败响应并结束请求。 */
public static function fail(string $msg, string $code = 'bad_request', int $http = 400): void
{
self::send(['ok' => false, 'error' => $msg, 'code' => $code], $http);
}
private static function send(array $payload, int $http): void
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
http_response_code($http);
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
/* ══════════════════════════════════════════════════════════════
分页(列表接口统一用它,App 端滚动加载)
══════════════════════════════════════════════════════════════ */
/** 返回 [limit, offset, page],并把它们记在静态字段里供 `page()` 回填 meta。 */
private static array $pageInfo = ['page' => 1, 'per_page' => 50, 'offset' => 0];
public static function page(int $defaultPer = 50, int $maxPer = 200): array
{
$per = max(1, min($maxPer, self::int('per_page', $defaultPer)));
$pg = max(1, self::int('page', 1));
$plan = self::str('paginate', '');
// `paginate=1` 时返回带 meta 的信封;否则(默认)返回裸数组,App 少写一层
self::$pageInfo = ['page' => $pg, 'per_page' => $per, 'offset' => ($pg - 1) * $per];
return [$per, self::$pageInfo['offset'], $pg];
}
/** 列表响应:默认裸数组;带 `paginate=1` 时给出分页 meta。 */
public static function list(array $rows, int $total = -1): void
{
if (self::bool('paginate')) {
self::ok([
'items' => $rows,
'total' => $total >= 0 ? $total : count($rows),
'page' => self::$pageInfo['page'],
'per_page' => self::$pageInfo['per_page'],
]);
}
self::ok($rows);
}
/* ══════════════════════════════════════════════════════════════
便捷:拿"某类资源"的公共前置
══════════════════════════════════════════════════════════════ */
/** 当前家庭 + 当前人(几乎所有接口都要,省得每处 `self::actor()` 解构)。 */
public static function me(): array
{
$a = self::actor();
return [$a['family_id'], $a['person_id']];
}
/** 找不到资源时的统一 404 */
public static function notFound(string $what = '资源'): void
{
self::fail($what . '不存在,或者你不该看到它。', 'not_found', 404);
}
/**
* 把一批 id 过滤成"确实属于这个家庭的人"(防手搓请求把别家的人挂进来)。
*
* ⚠️ 与网页表单同一道防线(index.php 里的全局函数 `filter_family_person_ids`)——
* 但那个函数定义在 **index.php 顶层**,API 这条路不经过 index.php
* (.htaccess 直接重写到 api.php),所以**取不到它**。
* 实测:在 ApiHandler 里直接调它会 `Call to undefined function
* App\filter_family_person_ids()` —— PHP 会先在当前命名空间找。
* ⇒ 这里自己实现一遍,语义与那个全局函数**逐字对齐**
* (查 persons 表、限 family_id、返回去重后的整型数组)。
*/
public static function people(array $ids, int $familyId): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids),
static fn(int $x): bool => $x > 0)));
if (!$ids) { return []; }
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT id FROM persons WHERE family_id = ? AND id IN ($in)"
);
$st->execute(array_merge([$familyId], $ids));
return array_map('intval', array_column($st->fetchAll(), 'id'));
}
/**
* 路由分发:`/api/v1/<资源>[/<id>][/<动作>]`。
*
* ⚠️ 单一入口(api.php 的 .htaccess 重写)—— 不在 URL 里堆 query 参数,
* 这样 App 的网络层可以按 REST 习惯写(GET/POST/PATCH/DELETE + 路径)。
*/
public static function dispatch(): void
{
// 方法模拟:App 端(尤其部分安卓网络库)不方便发 PATCH/DELETE 时
// 用 POST + `_method=DELETE`,这里做一次统一改写。
// ⚠️ 必须用 `Api::param()`(它同时认 JSON body / POST 表单 / GET)——
// 只读 `$_POST['_method']` 的话,JSON 请求体的改写会**静默失效**
// (实测踩过:POST + JSON `_method:DELETE` 得到 405)。
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$override = strtoupper((string)($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? ''));
if ($override === '') { $override = strtoupper((string)self::param('_method', '')); }
if ($method === 'POST' && in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) {
$method = $override;
}
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
/* 去掉前缀:`/api/v1/...`(.htaccess / router.php 那条重写),
或者 `?r=api/v1/...`(index.php 那条兜底 —— 它会把 REQUEST_URI 改成
`/api/v1/...` 再进来,所以这里一条正则就够)。
两种入口的路径最终都归一成 `tasks/12/status` 这样的相对串。 */
$path = preg_replace('#^.*?/api/v1#', '', $path);
$path = trim((string)$path, '/');
$parts = $path === '' ? [] : explode('/', $path);
$res = strtolower($parts[0] ?? '');
$id = isset($parts[1]) && ctype_digit((string)$parts[1]) ? (int)$parts[1] : 0;
$sub = strtolower($parts[1] ?? '');
if (ctype_digit($sub)) { $sub = strtolower((string)($parts[2] ?? '')); }
$h = new ApiHandler();
$h->route($res, $id, $sub, $method);
}
}