` → `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 `)。', '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/<资源>[/][/<动作>]`。 * * ⚠️ 单一入口(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); } }