v79 服务端:端上任务面板需要的三个口子

配合鸿蒙端 v79(任务面板 / 任务模块功能补齐),服务端只补三处,
其余全部复用既有路由。

① GET /api/v1/tasks?view=boards —— 只回看板 + 分类元数据,**不带任务**。
   端上原来是从已加载任务里反推看板,那样只能看到"有任务的看板":
   用户刚建好、还没放任务的看板在端上永远选不到(表现为"网页能建,
   端上挪不过去")。

② POST /api/v1/tasks/<id>/attach(multipart 字段 file)与
   DELETE /api/v1/tasks/<id>/attach?attachment_id=<aid> —— 都要先
   requireWrite() 再 Task::canWrite(),两者都回整条任务(端上直接重画)。

③ ApiHandler::taskOut() —— Task::find() 只 SELECT t.*,不含看板名/色
   ⇒ 详情与各动作回包统一补 board_name / board_color_calc(7 个返回点)。

🔴 两条踩过的坑写进注释:
   · Api::dispatch() 只解析「资源/id/子动作」三段,附件 id 不能拼进路径;
   · DELETE 带 JSON body 时 Api::param 读不到(实测),id 必须走 query。

另新增 Attachment::removeFrom():删附件前先确认它确实挂在这条任务上,
拿别条任务的附件 id 来删自己的 → 404(已由 _fb_v79_api.py 钉死)。

验证:php -l 全过、e2e 1558/0、_fb_v79_api.py 26/0、已部署并冒烟。
This commit is contained in:
2026-10-22 16:40:52 +08:00
parent 40fd6c501c
commit f927d7fb23
2 changed files with 123 additions and 5 deletions
+100 -5
View File
@@ -1712,6 +1712,28 @@ final class ApiHandler
任务
═══════════════════════════════════════════════════════════════ */
/**
* 一条任务 → 给 App 的形状。
*
* 🔴 v79 为什么要包这一层:`Task::find()` 只 SELECT `t.*`,**不含看板名/看板色**
* (网页端是在模板里再查一次)。端上面板要显示"这条在哪个看板"、
* 还要拿它的颜色去画那颗小圆点 —— 让端上为了两个字段再打一次
* `/tasks?view=boards` 是多余的一跳,就在服务端补齐。
* `board_color_calc` 与首页任务用的是**同一个**派生口径,
* 否则同一个看板在首页和详情里是两种颜色(用户能一眼看出来)。
*/
private static function taskOut(int $fam, int $id, int $pid): array
{
$t = Task::find($fam, $id, $pid);
if (!$t) {
return [];
}
$bid = (int)($t['board_id'] ?? 0);
$t['board_name'] = Task::boardName($fam, $bid > 0 ? $bid : null);
$t['board_color_calc'] = Task::boardColorById($fam, $bid > 0 ? $bid : null);
return $t;
}
private function tasks(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
@@ -1724,12 +1746,12 @@ final class ApiHandler
if ($sub === 'status' && ($method === 'POST' || $method === 'PATCH')) {
Api::requireWrite();
Task::setStatus($fam, $id, Api::str('status', 'todo'), Api::int('position'));
Api::ok(Task::find($fam, $id, $pid));
Api::ok(self::taskOut($fam, $id, $pid));
}
if ($sub === 'move' && $method === 'POST') {
Api::requireWrite();
Task::moveToBoard($fam, $id, Api::int('board_id'), Api::int('before'), $pid);
Api::ok(Task::find($fam, $id, $pid));
Api::ok(self::taskOut($fam, $id, $pid));
}
if ($sub === 'comments') {
if ($method === 'GET') {
@@ -1743,7 +1765,53 @@ final class ApiHandler
}
}
if ($method === 'GET') { Api::ok($t); }
/* ═══════════════════════════════════════════════════════════
v79(用户 2026-10-22):「鸿蒙端的功能,也需要同样适配……
任务模块的功能需要完善。」
⇒ 端上要对齐网页 v78 的「就地面板」,而面板里的**加附件**一直没有 API
(网页端走的是 `tasks/attach_add` 这条表单路由)。
────────────────────────────────────────────────────────────
POST /api/v1/tasks/<id>/attach multipart,字段名 `file`
DELETE /api/v1/tasks/<id>/attach 参数 `attachment_id`
⚠️ 路由只解析「资源/id/子动作」**三段**(`Api::dispatch`),没有第四段
⇒ 删附件**必须**把 id 放参数里。写成 `/attach/<aid>` 的话,
`Api::dispatch` 只会给到 `$sub='attach'`,aid 直接丢掉(静默 404)。
⚠️ 两个分支都回**整条任务**(`Task::find`,含 attachs):端上拿到就能
直接重画面板,不用再补一次 GET。
⚠️ 权限除了 `requireWrite()` 还要 `Task::canWrite()`:前者只说明
"这个账号不是只读访客",后者才说明"这条任务归我管"。
═══════════════════════════════════════════════════════════ */
if ($sub === 'attach') {
Api::requireWrite();
if (!Task::canWrite($t, $pid)) {
Api::fail('这项任务不能改。', 'forbidden', 403);
}
if ($method === 'POST') {
$file = $_FILES['file'] ?? null;
if (!is_array($file)) {
Api::fail('没有收到文件。', 'unprocessable', 422);
}
$r = \App\Attachment::save($fam, Task::ATTACH, $id, $file, $pid);
if (empty($r['ok'])) {
$msg = (string)($r['error'] ?? '');
Api::fail($msg !== '' ? $msg : '文件没存下来', 'unprocessable', 422);
}
Api::ok(self::taskOut($fam, $id, $pid), 201);
}
if ($method === 'DELETE') {
$aid = Api::int('attachment_id');
if ($aid <= 0) {
Api::fail('要指定删哪个附件。', 'bad_request', 400);
}
if (!\App\Attachment::removeFrom(Task::ATTACH, $id, $aid)) {
Api::notFound('这个附件');
}
Api::ok(self::taskOut($fam, $id, $pid));
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'GET') { Api::ok(self::taskOut($fam, $id, $pid)); }
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
Task::update($fam, $id, [
@@ -1757,7 +1825,7 @@ final class ApiHandler
'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)),
'status' => (string)$t['status'], // 状态只走 /status 子动作
]);
Api::ok(Task::find($fam, $id, $pid));
Api::ok(self::taskOut($fam, $id, $pid));
}
if ($method === 'DELETE') {
Api::requireWrite();
@@ -1787,7 +1855,7 @@ final class ApiHandler
foreach (Api::people(Api::ints('shares'), $fam) as $sp) {
Task::setShare($newId, $sp, 'write');
}
Api::ok(Task::find($fam, $newId, $pid), 201);
Api::ok(self::taskOut($fam, $newId, $pid), 201);
}
// ---- 列表:三种视图 ----
@@ -1801,6 +1869,33 @@ final class ApiHandler
if ($view === 'done') {
Api::ok(Task::doneList($fam, $pid, Api::int('category_id'), Api::str('keyword')));
}
/* v79:**只给元数据**(看板 + 分类),不带任务。
🔴 为什么需要它:端上「挪看板 / 选分类」要的是"**全部**看板",
而看板视图(下面那段)每列都塞满了任务,为了拿 5 个名字去解析几百条任务
很浪费;更关键的是——**空看板在别的视图里根本不存在**
(端上原来只能从已加载任务里反推看板 ⇒ 空看板永远选不到)。
网页端同一份数据来自 `Task::boards()` / `Task::categories()`。 */
if ($view === 'boards') {
$bs = [];
foreach (Task::boards($fam) as $b) {
$bs[] = [
'id' => (int)$b['id'],
'name' => (string)$b['name'],
'color' => Task::boardColor($b['color'] ?? null, (string)$b['name']),
'open' => (int)($b['open_count'] ?? 0),
];
}
$cs = [];
foreach (Task::categories($fam) as $c) {
$cs[] = ['id' => (int)$c['id'], 'name' => (string)$c['name']];
}
Api::ok([
'boards' => $bs,
'categories' => $cs,
'status' => Task::STATUS,
'repeat' => Task::REPEAT,
]);
}
// 看板视图:把"列"与"任务"一起给出来(App 一次请求就够画整屏)
$boards = Task::boards($fam);
/* ⚠️ `Task::boardColumns()` 返回的是**包了一层的数组**
+23
View File
@@ -128,6 +128,29 @@ final class Attachment
}
}
/**
* 删**某个宿主名下**的一颗附件(v79,App API 用)。
*
* 🔴 为什么不能直接用 `remove($id)`:那个只认附件 id,谁传一个别的任务的
* 附件 id 过来都能删掉(越权)。这里把 owner 一起放进 WHERE,
* 「不是这条任务的」一律返回 false,调用方据此回 404。
*/
public static function removeFrom(string $type, int $targetId, int $id): bool
{
if ($id <= 0) {
return false;
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$id, $type, $targetId]);
if ((int)$st->fetchColumn() <= 0) {
return false;
}
self::remove($id);
return true;
}
/**
* 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。
*