Files
FamilyBook/app/Api.php
T

352 lines
18 KiB
PHP
Raw Normal View History

<?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],
'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);
}
}