v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 2026-10-12 第 1~5 条) 1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。 2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件; 重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。 3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时); 去掉左侧红点只留红线。 4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。 5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己; 删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。 移动端 API(用户第 6 条:导航模块除外,其余全量) - 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。 - Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+ ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。 - 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。 - 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档; docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。 验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、 v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
This commit is contained in:
+352
@@ -0,0 +1,352 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
+1010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* 移动端 / 第三方调用的 **API 令牌**(v44,用户 2026-10-12 第 6 条)。
|
||||
*
|
||||
* 用户原话:「我们现在马上要开始移动终端的开发,所以现在要开发所有接口。」
|
||||
*
|
||||
* 与既有两套凭据的关系(**三套分开、互不代用**):
|
||||
* · 登录口令(accounts.password_hash)—— 网页用,走 session;
|
||||
* · DAV 口令(dav_credentials)—— CalDAV/CardDAV 客户端用,走 Basic;
|
||||
* · **API 令牌(本类)** —— 移动端 App 用,走 `Authorization: Bearer <token>`。
|
||||
* 为什么不复用 DAV 那套口令:那套是**给第三方同步客户端**的、明文要手打进手机;
|
||||
* API 令牌是给自家 App 的,需要**设备名 + 有效期 + 可逐个吊销**,
|
||||
* 生命周期与用途都不同(DAV 那把一吊销,日历同步也跟着断)。
|
||||
*
|
||||
* ⚠️ **令牌只在签发的这一次返回明文**(库里只存 sha256)。
|
||||
* 与 DAV 口令同一取舍:明文落库一旦泄露就全暴露;不返回用户又永远看不到。
|
||||
* ⇒ 网页端把明文塞进一次性 session 槽位、显示一次;App 端它自己存 Keychain。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class ApiToken
|
||||
{
|
||||
/** 令牌前缀:让日志/配置里一眼能认出"这是家书的 API 令牌"(GitHub 的 ghp_ 同理) */
|
||||
public const PREFIX = 'fb_';
|
||||
|
||||
/**
|
||||
* 签发一枚新令牌,返回**明文**(调用方负责立刻交给用户,之后再也拿不到)。
|
||||
*
|
||||
* ⚠️ 用 `random_bytes` 而不是 `random_int` 循环拼短字符集:
|
||||
* 令牌是机器读的(不像 DAV 口令要手打),长度与熵优先。
|
||||
* 32 字节 → base64url 43 字符 → 约 256 bit 熵。
|
||||
*/
|
||||
public static function issue(int $personId, string $label = '', ?string $expiresAt = null): string
|
||||
{
|
||||
$plain = self::PREFIX . self::b64url(random_bytes(32));
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO api_tokens (person_id, token_hash, label, expires_at) VALUES (?,?,?,?)'
|
||||
)->execute([
|
||||
$personId,
|
||||
hash('sha256', $plain),
|
||||
$label !== '' ? mb_substr($label, 0, 60) : null,
|
||||
$expiresAt !== '' ? $expiresAt : null,
|
||||
]);
|
||||
return $plain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一枚令牌,成功返回 [personId, tokenId],失败 null。
|
||||
*
|
||||
* ⚠️ 只认 `Bearer` 头 —— 不接 query string(URL 会进日志/浏览器历史,
|
||||
* 令牌出现在 URL 里等于半公开)。DAV 那套用 Basic 是 RFC 定的,没得选;
|
||||
* API 是我们自己定的,就选更安全的那个。
|
||||
* ⚠️ 逐字节 hash 之后查库(不先解密/比对),配合唯一索引 —— 不走时序分支。
|
||||
* ⚠️ 过期/被吊销一律返回 null(不给"为什么不行"的细粒度错误,
|
||||
* 避免给爆破者提供信息;具体原因网页端能看到)。
|
||||
*/
|
||||
public static function verify(string $plain): ?array
|
||||
{
|
||||
$plain = trim($plain);
|
||||
if ($plain === '' || strpos($plain, self::PREFIX) !== 0) {
|
||||
return null;
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT t.id, t.person_id, a.status
|
||||
FROM api_tokens t
|
||||
JOIN accounts a ON a.person_id = t.person_id
|
||||
WHERE t.token_hash = ?
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$st->execute([hash('sha256', $plain)]);
|
||||
$row = $st->fetch();
|
||||
if (!$row || (string)$row['status'] !== 'active') {
|
||||
return null;
|
||||
}
|
||||
// 记一次使用时间(不给它加索引/不加事务:这是"顺带的统计",不值得拖慢鉴权)
|
||||
try {
|
||||
Database::get()->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$row['id']]);
|
||||
} catch (\Throwable $e) {
|
||||
// 记不上不该拦住这次请求
|
||||
}
|
||||
return [(int)$row['person_id'], (int)$row['id']];
|
||||
}
|
||||
|
||||
/** 某个人的令牌清单(**不含明文**,只给管理界面看) */
|
||||
public static function listFor(int $personId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT id, label, created_at, last_used_at, expires_at
|
||||
FROM api_tokens WHERE person_id = ? ORDER BY id DESC'
|
||||
);
|
||||
$st->execute([$personId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/** 吊销一枚(限自己的) */
|
||||
public static function revoke(int $personId, int $id): bool
|
||||
{
|
||||
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE id = ? AND person_id = ?');
|
||||
$st->execute([$id, $personId]);
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** 吊销自己的全部("手机丢了"时的应急动作) */
|
||||
public static function revokeAll(int $personId): int
|
||||
{
|
||||
$st = Database::get()->prepare('DELETE FROM api_tokens WHERE person_id = ?');
|
||||
$st->execute([$personId]);
|
||||
return $st->rowCount();
|
||||
}
|
||||
|
||||
private static function b64url(string $raw): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -139,7 +139,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '31';
|
||||
public const VERSION = '32';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -256,6 +256,22 @@ CREATE TABLE IF NOT EXISTS `dav_credentials` (
|
||||
COMMENT='DAV 专用口令(日历 CalDAV + 通讯录 CardDAV 共用一把;与登录口令分开)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `api_tokens` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`person_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库',
|
||||
`label` VARCHAR(60) DEFAULT NULL COMMENT '设备名(用户自己填,如「我的手机」)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used_at` DATETIME DEFAULT NULL,
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_api_token` (`token_hash`),
|
||||
KEY `idx_api_person` (`person_id`),
|
||||
CONSTRAINT `fk_api_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='移动端 / 第三方调用的 API 令牌(与 DAV 口令、登录口令分开)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `relations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
|
||||
+7
-7
@@ -54,14 +54,14 @@ final class Task
|
||||
public const STATUS = ['todo' => '待办', 'doing' => '进行中', 'done' => '已完成'];
|
||||
|
||||
/**
|
||||
* 每个状态「到底是什么意思」——**给界面上的人看**(v24 第 25 批 用户第 6 条)。
|
||||
* 每个状态「到底是什么意思」——**给开发看**的语义说明。
|
||||
*
|
||||
* 用户原话:「待办和进行中,有什么区别?」——他不知道区别,就是这一处没写清楚。
|
||||
* 所以这句话要出现在**他看到状态的地方**:卡片上的 title、状态说明、
|
||||
* 以及任务页头那句总说明(见 templates/tasks/index.php)。
|
||||
*
|
||||
* ⚠️ 文案刻意用"我/手头"这种第一人称口语:这是家庭场景,
|
||||
* 写成"尚未开始执行"那种公文腔他自己也不会去读。
|
||||
* ⚠️ 历史(v24 第 25 批 用户第 6 条):用户问过「待办和进行中,有什么区别?」,
|
||||
* 当时把这句话渲染在看板顶部当"状态说明"。
|
||||
* 🔴 v44(用户 2026-10-12 第 4 条):「任务页面,任务下面有一行字…
|
||||
* 这个提示不要了,去掉。」⇒ **界面不再渲染它**(见 templates/tasks/index.php)。
|
||||
* 常量与 `statusHint()` 保留:它仍是这三个状态语义的**唯一书面定义**,
|
||||
* 后端/文案需要时还能取;删掉反而要后人重新考古一遍。
|
||||
*/
|
||||
public const STATUS_HINT = [
|
||||
'todo' => '记下来了,还没动手 —— 排队等着',
|
||||
|
||||
+4
-1
@@ -395,7 +395,10 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
|
||||
if ($when !== '') { $rows[] = ['时间', $when]; }
|
||||
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
|
||||
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
|
||||
if ((string)($ev['status_word'] ?? '') !== '') { $rows[] = ['状态', (string)$ev['status_word']]; }
|
||||
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
|
||||
当然,如果没有地点,这一行就不要显示了。」
|
||||
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
|
||||
没有地点时上面那条 `if` 自然不产出 ⇒ 整行不显示,正是用户要的。 */
|
||||
if ((string)($ev['note'] ?? '') !== '') { $rows[] = ['备注', (string)$ev['note']]; }
|
||||
// ⚠️ 扩展行排在"备注"之后、作为补充(调用方按需给,比如任务给「所属看板」)
|
||||
foreach ($extra as $row) {
|
||||
|
||||
Reference in New Issue
Block a user