"""Kanban API — 五列任务看板 + 异常检测。""" import copy import logging from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from app.middleware.auth import require_auth from app.models.user import User logger = logging.getLogger(__name__) router = APIRouter(prefix="/kanban", tags=["kanban"]) # ── Mock 数据(TODO: CLI integration) ── NOW = datetime.now(timezone.utc) def _ago(hours: int = 0, minutes: int = 0) -> str: return (NOW - timedelta(hours=hours, minutes=minutes)).isoformat() MOCK_TASKS: list[dict[str, Any]] = [ { "id": "task-001", "title": "生成 taiwan 早报简报", "board": "简报生成", "worker_profile": "taiwan", "worker_type": "news-crawler", "priority": 2, "column": "running", "created_at": _ago(hours=2), "updated_at": _ago(minutes=15), "goal_mode": False, "retries": 0, "failures": 0, "description": "## 任务\n\n每日 07:00 生成 taiwan 地区早报简报\n\n### 步骤\n\n1. 爬取新闻源\n2. 摘要生成\n3. 推送飞书", "block_reason": None, "dependencies": [], "dependents": ["task-004"], "activities": [ {"time": _ago(minutes=15), "user": "system", "action": "开始执行"}, {"time": _ago(hours=2), "user": "hermes", "action": "加入队列"}, ], }, { "id": "task-002", "title": "guangxi 捐赠数据汇总", "board": "数据采集", "worker_profile": "guangxi", "worker_type": "data-collector", "priority": 1, "column": "ready", "created_at": _ago(hours=6), "updated_at": _ago(hours=6), "goal_mode": False, "retries": 0, "failures": 0, "description": "## 任务\n\n汇总 guangxi 地区捐赠数据\n\n### 数据源\n\n- 内部 API\n- 公开报表", "block_reason": None, "dependencies": [], "dependents": [], "activities": [ {"time": _ago(hours=6), "user": "hermes", "action": "就绪"}, ], }, { "id": "task-003", "title": "运维日报推送 (ops-engineer)", "board": "日报推送", "worker_profile": "ops-engineer", "worker_type": "reporter", "priority": 3, "column": "todo", "created_at": _ago(hours=96), "updated_at": _ago(hours=96), "goal_mode": True, "retries": 0, "failures": 0, "description": "## 任务\n\n每日 08:00 推送运维日报\n\n### 验收标准\n\n- [ ] 数据源正常\n- [ ] 推送成功", "block_reason": None, "dependencies": ["task-002"], "dependents": [], "activities": [ {"time": _ago(hours=96), "user": "hermes", "action": "创建任务"}, ], }, { "id": "task-004", "title": "hebo hindsight 归档", "board": "数据归档", "worker_profile": "hebo", "worker_type": "archiver", "priority": 2, "column": "blocked", "created_at": _ago(hours=30), "updated_at": _ago(hours=30), "goal_mode": False, "retries": 2, "failures": 3, "description": "## 任务\n\n每日 21:00 hindsight 归档处理\n\n### 说明\n\n最近连续失败,上游 API 不稳定", "block_reason": "上游 API 连续返回 502,需联系运维排查", "dependencies": [], "dependents": [], "activities": [ {"time": _ago(hours=30), "user": "system", "action": "标记阻塞"}, {"time": _ago(hours=32), "user": "hermes", "action": "重试失败 (第3次)"}, ], }, { "id": "task-005", "title": "guangxi 周报生成", "board": "简报生成", "worker_profile": "guangxi", "worker_type": "reporter", "priority": 1, "column": "todo", "created_at": _ago(hours=12), "updated_at": _ago(hours=12), "goal_mode": False, "retries": 0, "failures": 0, "description": "## 任务\n\n每周一生成 guangxi 周报", "block_reason": None, "dependencies": ["task-002"], "dependents": [], "activities": [ {"time": _ago(hours=12), "user": "hermes", "action": "创建任务"}, ], }, { "id": "task-006", "title": "taiwan 突发新闻推送", "board": "简报生成", "worker_profile": "taiwan", "worker_type": "news-crawler", "priority": 0, "column": "running", "created_at": _ago(hours=1), "updated_at": _ago(minutes=5), "goal_mode": False, "retries": 1, "failures": 0, "description": "## 任务\n\n监测到突发新闻,立即推送飞书通知", "block_reason": None, "dependencies": [], "dependents": [], "activities": [ {"time": _ago(minutes=5), "user": "system", "action": "内容生成中"}, {"time": _ago(hours=1), "user": "hermes", "action": "触发执行"}, ], }, { "id": "task-007", "title": "飞书健康巡检", "board": "系统维护", "worker_profile": "ops-engineer", "worker_type": "health-checker", "priority": 0, "column": "done", "created_at": _ago(hours=72), "updated_at": _ago(hours=48), "goal_mode": True, "retries": 0, "failures": 0, "description": "## 任务\n\n检查飞书连接健康状况\n\n### 验收标准\n\n- [x] API 连通性正常\n- [x] Webhook 可达", "block_reason": None, "dependencies": [], "dependents": [], "activities": [ {"time": _ago(hours=48), "user": "system", "action": "标记完成"}, {"time": _ago(hours=60), "user": "system", "action": "开始执行"}, {"time": _ago(hours=72), "user": "hermes", "action": "加入队列"}, ], }, { "id": "task-008", "title": "密钥轮换检查 (all profiles)", "board": "安全审计", "worker_profile": "ops-engineer", "worker_type": "auditor", "priority": 1, "column": "blocked", "created_at": _ago(hours=50), "updated_at": _ago(hours=48), "goal_mode": False, "retries": 0, "failures": 0, "description": "## 任务\n\n检查所有 Profile 密钥有效期,轮换即将过期的密钥", "block_reason": "等待安全审批,预计下周二完成", "dependencies": [], "dependents": [], "activities": [ {"time": _ago(hours=48), "user": "manual", "action": "标记阻塞"}, {"time": _ago(hours=50), "user": "hermes", "action": "创建任务"}, ], }, { "id": "task-009", "title": "Gateway 配置同步", "board": "系统维护", "worker_profile": "taiwan", "worker_type": "syncer", "priority": 2, "column": "ready", "created_at": _ago(hours=4), "updated_at": _ago(hours=4), "goal_mode": False, "retries": 0, "failures": 1, "description": "## 任务\n\n同步 taiwan Gateway 最新配置", "block_reason": None, "dependencies": [], "dependents": [], "activities": [ {"time": _ago(hours=4), "user": "hermes", "action": "就绪"}, ], }, { "id": "task-010", "title": "历史数据归档 (daily)", "board": "数据归档", "worker_profile": "hebo", "worker_type": "archiver", "priority": 2, "column": "todo", "created_at": _ago(hours=18), "updated_at": _ago(hours=18), "goal_mode": False, "retries": 0, "failures": 0, "description": "## 任务\n\n每日历史数据归档", "block_reason": None, "dependencies": [], "dependents": [], "activities": [ {"time": _ago(hours=18), "user": "hermes", "action": "创建任务"}, ], }, ] # 用 createdAt 判断 unclaimed,为了测试效果把 task-003 设为 96h ago (>72h) # task-004 failure=3 触发 retry_failure # task-004/task-008 blocked 且 updated_at>24h 触发 blocked_timeout def _detect_anomalies(task: dict) -> list[str]: """返回任务异常标记列表。""" anomalies: list[str] = [] updated = datetime.fromisoformat(task["updated_at"]) created = datetime.fromisoformat(task["created_at"]) now = NOW # blocked_timeout:BLOCKED 列超 24h → 标红 if task["column"] == "blocked" and (now - updated) > timedelta(hours=24): anomalies.append("blocked_timeout") # unclaimed:TODO 列超 3 天 → 标黄 if task["column"] == "todo" and (now - created) > timedelta(days=3): anomalies.append("unclaimed") # retry_failure:失败重试≥3 次 → 标红 if task.get("failures", 0) >= 3: anomalies.append("retry_failure") return anomalies # ── API ── @router.get("/tasks") async def list_tasks( worker: str = Query("", description="Worker profile 筛选"), board: str = Query("", description="看板名筛选"), anomaly: str = Query("", description="异常类型: blocked_timeout|unclaimed|retry_failure"), _user: User = Depends(require_auth), ): """跨 Profile 五列任务聚合。""" tasks = copy.deepcopy(MOCK_TASKS) if worker: tasks = [t for t in tasks if t["worker_profile"] == worker] if board: tasks = [t for t in tasks if t["board"] == board] if anomaly: tasks = [t for t in tasks if anomaly in _detect_anomalies(t)] # 五列分组 columns = {"todo": [], "ready": [], "running": [], "blocked": [], "done": []} for t in tasks: anomalies = _detect_anomalies(t) col = t["column"] if col in columns: columns[col].append({**t, "anomalies": anomalies}) # 每列按优先级排序 for col in columns: columns[col].sort(key=lambda x: (x["priority"], x["created_at"])) profiles = {t["worker_profile"] for t in tasks} boards = {t["board"] for t in tasks} return { "todo": columns["todo"], "ready": columns["ready"], "running": columns["running"], "blocked": columns["blocked"], "done": columns["done"], "overview": { "total_tasks": len(tasks), "profile_count": len(profiles), "board_count": len(boards), "running_count": len(columns["running"]), "blocked_count": len(columns["blocked"]), }, } @router.get("/tasks/{task_id}") async def get_task_detail( task_id: str, _user: User = Depends(require_auth), ): """任务详情。""" for t in MOCK_TASKS: if t["id"] == task_id: detail = copy.deepcopy(t) detail["anomalies"] = _detect_anomalies(t) # 补全依赖/被依赖任务信息 dep_ids = detail.get("dependencies", []) dep_tasks = [m for m in MOCK_TASKS if m["id"] in dep_ids] detail["dependency_tasks"] = [ {"id": m["id"], "title": m["title"], "column": m["column"]} for m in dep_tasks ] dep_by_ids = detail.get("dependents", []) dep_by_tasks = [m for m in MOCK_TASKS if m["id"] in dep_by_ids] detail["dependent_tasks"] = [ {"id": m["id"], "title": m["title"], "column": m["column"]} for m in dep_by_tasks ] return detail raise HTTPException(status_code=404, detail="任务不存在") @router.patch("/tasks/{task_id}/move") async def move_task( task_id: str, body: dict, _user: User = Depends(require_auth), ): """拖拽移动任务。""" column = body.get("column") block_reason = body.get("block_reason") valid_columns = {"todo", "ready", "running", "blocked", "done"} if column not in valid_columns: raise HTTPException(status_code=400, detail="无效的列名") for t in MOCK_TASKS: if t["id"] == task_id: # 拖入 BLOCKED 需填写阻塞原因 if column == "blocked": if not block_reason or len(block_reason.strip()) < 10: raise HTTPException( status_code=400, detail="拖入 BLOCKED 列需填写阻塞原因(至少 10 字)", ) t["block_reason"] = block_reason.strip() else: t["block_reason"] = None t["column"] = column t["updated_at"] = datetime.now(timezone.utc).isoformat() t["activities"].insert(0, { "time": t["updated_at"], "user": "manual", "action": f"移动到 {column}", }) return {"success": True, "task": {**t, "anomalies": _detect_anomalies(t)}} raise HTTPException(status_code=404, detail="任务不存在")