用户 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} 复核通过。
361 lines
18 KiB
PHP
361 lines
18 KiB
PHP
<?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);
|
||
}
|
||
} |