PMFlow/backend/app/api/sstc.py

1640 lines
52 KiB
Python

"""
SSTC 唯一事实链路 API
覆盖模块:
- 项目概览/链路导航/风险预警
- 信号源
- 需求宪章(含对齐)
- 任务拆解与执行
- 发布验证
- 复盘沉淀
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import and_, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.response import success_response
from ..database import get_db
from ..models import (
AICallLog,
AlignmentRecord,
ProjectSignalLink,
Signal,
Task,
TruthNode,
User,
UserProject,
)
from ..schemas.sstc import (
AlignmentConfirmRequest,
AlignmentStartRequest,
CharterUpdateRequest,
ReleaseCreateRequest,
ReleaseStatusPatchRequest,
ReviewArchiveRequest,
ReviewCreateRequest,
TaskStatusPatchRequest,
)
from ..services.llm import llm_service
from ..services.operation_log_service import log_operation
from ..services.auth import has_any_role
from ..services.skill_service import recommend_personnel
from ..services import skill_score_service
from ..services.sstc_service import (
ALIGNED_STATUSES,
DONE_STATUSES,
RELEASE_VERIFIED_STATUSES,
SSTCService,
)
from ..services import workload_service
from ..dependencies import get_current_user, require_project_member_or_manager
from ..utils.title import compact_title
router = APIRouter(tags=["sstc"])
def _parse_project_id(project_id: str) -> int:
try:
return int(project_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid project_id") from exc
def _parse_node_id(node_id: str, label: str) -> int:
try:
return int(node_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc
def _as_dict(value: Any) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str):
import json
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"text": value}
except (TypeError, ValueError):
return {"text": value}
return {}
def _parse_due_date(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid due_date") from exc
async def _ensure_project(
svc: SSTCService,
project_id: int,
):
project = await svc.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
async def _ensure_charter(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
charter_id: int,
) -> TruthNode:
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == charter_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "charter",
)
)
)
charter = result.scalar_one_or_none()
if not charter:
raise HTTPException(status_code=404, detail="Charter not found")
return charter
async def _ensure_task(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
task_id: int,
) -> Task:
result = await db.execute(
select(Task).where(
and_(
Task.id == task_id,
Task.project_id == project_id,
Task.tenant_id == tenant_id,
)
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
async def _ensure_release(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
release_node_id: int,
) -> TruthNode:
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == release_node_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "release",
)
)
)
release_node = result.scalar_one_or_none()
if not release_node:
raise HTTPException(status_code=404, detail="Release plan not found")
return release_node
def _assert_charter_aligned(charter: TruthNode) -> None:
if (charter.status or "").lower() not in ALIGNED_STATUSES:
raise HTTPException(
status_code=409,
detail="宪章未对齐,任务环节未解锁",
)
def _is_manager(user: User) -> bool:
return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"])
def _require_manager(user: User) -> None:
if not _is_manager(user):
raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作")
@router.get("/projects/{project_id}/sstc/overview")
async def get_board_overview(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
overview = await svc.build_overview(pid)
stages = await svc.build_stages(pid)
risks = await svc.build_risks(pid)
return success_response(
{
"overview": overview,
"stages": stages,
"risks": risks,
}
)
@router.get("/projects/{project_id}/sstc/stages")
async def get_stages(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
return success_response(await svc.build_stages(pid))
@router.get("/projects/{project_id}/sstc/risks")
async def get_risks(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
return success_response(await svc.build_risks(pid))
@router.get("/projects/{project_id}/sstc-chain")
async def get_sstc_chain(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
return success_response(await svc.build_chain(pid))
@router.get("/projects/{project_id}/signals")
async def list_signals(
project_id: str,
status: Optional[str] = Query(default=None, description="pending/linked/archived"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
signals = await svc.get_signals(pid)
charters = await svc.get_charters(pid)
linked_signal_ids: set[int] = set()
for c in charters:
payload = _as_dict(c.content)
for sid in payload.get("signal_ids", []):
if isinstance(sid, int):
linked_signal_ids.add(sid)
rows = []
for s in signals:
row_status = "pending"
if int(s.id) in linked_signal_ids:
row_status = "linked"
if bool(s.processed):
row_status = "archived"
if status and row_status != status:
continue
rows.append(
{
"id": str(s.id),
"title": (s.content or "")[:80] or "未命名信号",
"content": s.content,
"priority": s.priority,
"signal_type": s.source or "other",
"source": s.source,
"status": row_status,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
)
return success_response(rows)
@router.post("/projects/{project_id}/signals/{signal_id}/archive")
async def archive_signal(
project_id: str,
signal_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
sid = _parse_node_id(signal_id, "signal_id")
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
result = await db.execute(
select(Signal).where(
and_(Signal.id == sid, Signal.tenant_id == int(current_user.tenant_id))
)
)
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
before = {"processed": signal.processed}
signal.processed = True
await log_operation(
db,
tenant_id=int(current_user.tenant_id),
user_id=int(current_user.id),
project_id=pid,
entity_type="signal",
entity_id=str(sid),
action="archive",
before_data=before,
after_data={"processed": True},
)
await db.commit()
return success_response({"id": str(signal.id), "status": "archived"})
class CharterCreateRequest(BaseModel):
title: str
content: dict[str, Any] | None = None
signal_ids: list[int]
@router.get("/projects/{project_id}/charters")
async def list_charters(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
charters = await svc.get_charters(pid)
payload = [
{
"id": str(c.id),
"title": c.title,
"content": _as_dict(c.content),
"signal_ids": _as_dict(c.content).get("signal_ids", []),
"status": c.status,
"version": c.version,
"parent_id": str(c.parent_id) if c.parent_id else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in charters
]
return success_response(payload)
@router.post("/projects/{project_id}/charters")
async def create_charter(
project_id: str,
body: CharterCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
if not body.signal_ids:
raise HTTPException(status_code=400, detail="宪章必须关联至少一个信号")
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
link_result = await db.execute(
select(ProjectSignalLink.signal_id).where(
and_(
ProjectSignalLink.project_id == pid,
ProjectSignalLink.signal_id.in_(body.signal_ids),
ProjectSignalLink.tenant_id == tenant_id,
)
)
)
valid_signal_ids = {int(v) for v in link_result.scalars().all()}
if set(body.signal_ids) - valid_signal_ids:
raise HTTPException(status_code=400, detail="部分信号未关联到当前项目")
content = dict(body.content or {})
content["signal_ids"] = sorted(set(body.signal_ids))
charter = TruthNode(
node_type="charter",
parent_id=None,
project_id=pid,
created_by=int(current_user.id),
title=compact_title(body.title, max_len=24),
content=content,
version=1,
status="draft",
tenant_id=tenant_id,
)
db.add(charter)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id="new",
action="create",
after_data={"title": charter.title, "signal_ids": content["signal_ids"]},
)
await db.commit()
await db.refresh(charter)
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": _as_dict(charter.content),
"signal_ids": _as_dict(charter.content).get("signal_ids", []),
"status": charter.status,
"version": charter.version,
}
)
@router.get("/projects/{project_id}/charters/{charter_id}")
async def get_charter_detail(
project_id: str,
charter_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
content = _as_dict(charter.content)
signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)]
signal_rows = []
if signal_ids:
signal_result = await db.execute(
select(Signal).where(
and_(Signal.id.in_(signal_ids), Signal.tenant_id == tenant_id)
)
)
signal_rows = list(signal_result.scalars().all())
task_result = await db.execute(
select(Task).where(
and_(
Task.truth_node_id == cid,
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
tasks = list(task_result.scalars().all())
align_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.tenant_id == tenant_id,
)
)
)
alignments = list(align_result.scalars().all())
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": content,
"signal_ids": signal_ids,
"signals": [
{
"id": str(s.id),
"source": s.source,
"content": s.content,
"priority": s.priority,
"impact_score": s.impact_score,
}
for s in signal_rows
],
"tasks": [
{
"id": str(t.id),
"title": t.title,
"description": t.description,
"status": t.status,
"priority": t.priority,
"estimated_hours": float(t.estimated_hours) if t.estimated_hours else None,
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks
],
"alignment_records": [
{
"user_id": str(r.user_id),
"score": r.alignment_score,
"confirmed": bool(r.is_confirmed),
"comments": r.comments,
}
for r in alignments
],
"status": charter.status,
"version": charter.version,
}
)
@router.patch("/projects/{project_id}/charters/{charter_id}")
async def update_charter(
project_id: str,
charter_id: str,
body: CharterUpdateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before = {
"title": charter.title,
"content": _as_dict(charter.content),
"version": charter.version,
"status": charter.status,
}
aligned = (charter.status or "").lower() in ALIGNED_STATUSES
if aligned and body.force_new_version:
new_node = TruthNode(
node_type="charter",
parent_id=int(charter.id),
project_id=pid,
created_by=int(current_user.id),
title=compact_title((body.title or charter.title), max_len=24),
content=body.content if body.content is not None else _as_dict(charter.content),
version=int(charter.version or 1) + 1,
status="draft",
tenant_id=tenant_id,
)
db.add(new_node)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(charter.id),
action="new_version",
before_data=before,
after_data={"new_charter_id": "pending"},
)
await db.commit()
await db.refresh(new_node)
return success_response(
{
"id": str(new_node.id),
"version": new_node.version,
"status": new_node.status,
"title": new_node.title,
"content": _as_dict(new_node.content),
"new_version_created": True,
}
)
if body.title is not None:
charter.title = compact_title(body.title, max_len=24)
if body.content is not None:
charter.content = body.content
charter.status = "draft"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(charter.id),
action="update",
before_data=before,
after_data={
"title": charter.title,
"content": _as_dict(charter.content),
"version": charter.version,
"status": charter.status,
},
)
await db.commit()
await db.refresh(charter)
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": _as_dict(charter.content),
"status": charter.status,
"version": charter.version,
"new_version_created": False,
}
)
@router.post("/projects/{project_id}/charters/{charter_id}/alignment/start")
async def start_alignment(
project_id: str,
charter_id: str,
body: AlignmentStartRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before = {"status": charter.status}
charter.status = "aligning"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(cid),
action="start_alignment",
before_data=before,
after_data={"status": charter.status, "note": body.note},
)
await db.commit()
return success_response({"id": str(cid), "status": charter.status})
@router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm")
async def confirm_alignment(
project_id: str,
charter_id: str,
body: AlignmentConfirmRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
row_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.user_id == int(current_user.id),
AlignmentRecord.tenant_id == tenant_id,
)
)
)
row = row_result.scalar_one_or_none()
if row is None:
row = AlignmentRecord(
truth_node_id=cid,
user_id=int(current_user.id),
alignment_score=body.score,
is_confirmed=body.confirmed,
comments=body.comments,
tenant_id=tenant_id,
)
db.add(row)
else:
row.alignment_score = body.score
row.is_confirmed = body.confirmed
row.comments = body.comments
member_ids = await svc.get_project_member_ids(pid)
confirmed_map: set[int] = set()
rows_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.tenant_id == tenant_id,
AlignmentRecord.is_confirmed.is_(True),
)
)
)
for r in rows_result.scalars().all():
confirmed_map.add(int(r.user_id))
all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False
if all_confirmed:
charter.status = "aligned"
elif (charter.status or "").lower() not in {"aligned"}:
charter.status = "aligning"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="alignment",
entity_id=f"{cid}:{current_user.id}",
action="confirm",
after_data={
"score": body.score,
"confirmed": body.confirmed,
"all_confirmed": all_confirmed,
"charter_status": charter.status,
},
)
await db.commit()
return success_response(
{
"charter_id": str(cid),
"charter_status": charter.status,
"all_confirmed": all_confirmed,
"confirmed_count": len(confirmed_map),
"member_count": len(member_ids),
}
)
class TaskCreateRequest(BaseModel):
title: str
description: Optional[str] = None
required_skill_tag_ids: Optional[list[int]] = None
priority: str = "medium"
estimated_hours: Optional[float] = None
due_date: Optional[str] = None
class TaskUpdateRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[int] = None
required_skill_tag_ids: Optional[list[int]] = None
priority: Optional[str] = None
estimated_hours: Optional[float] = None
due_date: Optional[str] = None
@router.post("/projects/{project_id}/charters/{charter_id}/tasks")
async def create_task(
project_id: str,
charter_id: str,
body: TaskCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
_assert_charter_aligned(charter)
due_date = _parse_due_date(body.due_date)
task = Task(
tenant_id=tenant_id,
truth_node_id=cid,
project_id=pid,
assignee_id=None,
title=compact_title(body.title, max_len=32),
description=body.description,
required_skill_tag_ids=body.required_skill_tag_ids or [],
status="todo",
priority=body.priority,
estimated_hours=body.estimated_hours,
due_date=due_date,
)
db.add(task)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id="new",
action="create",
after_data={
"title": task.title,
"truth_node_id": cid,
"priority": body.priority,
},
)
await db.commit()
await db.refresh(task)
return success_response(
{
"id": str(task.id),
"title": task.title,
"status": task.status,
"priority": task.priority,
"charter_id": str(cid),
}
)
@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown")
async def ai_breakdown_tasks(
project_id: str,
charter_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
_assert_charter_aligned(charter)
prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组:
[
{{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}}
]
宪章标题:{charter.title}
宪章内容:{_as_dict(charter.content)}
"""
try:
rows = await llm_service.generate_task_breakdown_async(
prompt,
db=db,
tenant_id=tenant_id,
project_id=pid,
)
except RuntimeError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc
created = []
prompt_tokens = max(len(prompt) // 4, 1)
completion_tokens = 0
for item in rows or []:
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
if not title:
continue
priority = str(item.get("priority") or "P1").lower()
mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low"
task = Task(
tenant_id=tenant_id,
truth_node_id=cid,
project_id=pid,
assignee_id=None,
title=title,
description=item.get("description"),
required_skill_tag_ids=[],
status="todo",
priority=mapped_priority,
estimated_hours=float(item.get("estimated_hours") or 0) or None,
)
db.add(task)
created.append(task)
completion_tokens += max(len(title) // 2, 1)
db.add(
AICallLog(
tenant_id=tenant_id,
user_id=int(current_user.id),
function_name="sstc_ai_breakdown",
prompt=prompt,
response=f"created_count={len(created)}",
tokens_used=prompt_tokens + completion_tokens,
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
status="success",
)
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(cid),
action="ai_breakdown",
after_data={"created_count": len(created)},
)
await db.commit()
return success_response(
{
"created_count": len(created),
"items": [
{"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status}
for t in created
],
}
)
@router.get("/projects/{project_id}/tasks/board")
async def get_task_board(
project_id: str,
assignee_id: Optional[int] = Query(default=None),
priority: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
result = await db.execute(
select(Task).where(
and_(
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
tasks = list(result.scalars().all())
if assignee_id is not None:
tasks = [t for t in tasks if t.assignee_id == assignee_id]
if priority:
tasks = [t for t in tasks if (t.priority or "").lower() == priority.lower()]
rows = []
for t in tasks:
risk_count = 0
if (t.status or "").lower() == "blocked":
risk_count += 1
if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES:
risk_count += 1
rows.append(
{
"id": str(t.id),
"title": t.title,
"description": t.description,
"status": t.status,
"priority": t.priority,
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
"due_date": t.due_date.isoformat() if t.due_date else None,
"estimated_hours": float(t.estimated_hours) if t.estimated_hours else None,
"risk_count": risk_count,
"truth_node_id": str(t.truth_node_id),
}
)
return success_response(rows)
@router.patch("/projects/{project_id}/tasks/{task_id}/status")
async def patch_task_status(
project_id: str,
task_id: str,
body: TaskStatusPatchRequest,
background_tasks: BackgroundTasks,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
if not _is_manager(current_user) and int(task.assignee_id or 0) != int(current_user.id):
raise HTTPException(status_code=403, detail="仅负责人或任务执行人可更新状态")
before_status = (task.status or "").lower()
before = {"status": task.status}
task.status = body.status
if body.status in DONE_STATUSES:
task.completed_at = datetime.now(timezone.utc).replace(tzinfo=None)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(tid),
action="status_change",
before_data=before,
after_data={"status": body.status},
)
if task.assignee_id:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=int(task.assignee_id),
)
else:
await db.commit()
became_done = (body.status or "").lower() in DONE_STATUSES and before_status not in DONE_STATUSES
if became_done and task.assignee_id:
background_tasks.add_task(
skill_score_service.process_task_completion,
task_id=int(task.id),
tenant_id=tenant_id,
operator_user_id=int(current_user.id),
)
return success_response({"id": str(task.id), "status": task.status})
@router.patch("/projects/{project_id}/tasks/{task_id}")
async def patch_task(
project_id: str,
task_id: str,
body: TaskUpdateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
before = {
"title": task.title,
"description": task.description,
"assignee_id": task.assignee_id,
"priority": task.priority,
"estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None,
"due_date": task.due_date.isoformat() if task.due_date else None,
}
old_assignee_id = int(task.assignee_id) if task.assignee_id else None
if body.title is not None:
task.title = compact_title(body.title, max_len=32)
if body.description is not None:
task.description = body.description
if body.assignee_id is not None:
task.assignee_id = body.assignee_id
if body.required_skill_tag_ids is not None:
task.required_skill_tag_ids = body.required_skill_tag_ids
if body.priority is not None:
task.priority = body.priority
if body.estimated_hours is not None:
task.estimated_hours = body.estimated_hours
if body.due_date is not None:
task.due_date = _parse_due_date(body.due_date)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(tid),
action="update",
before_data=before,
after_data={
"title": task.title,
"description": task.description,
"assignee_id": task.assignee_id,
"priority": task.priority,
"estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None,
"due_date": task.due_date.isoformat() if task.due_date else None,
},
)
touched_user_ids: set[int] = set()
if old_assignee_id:
touched_user_ids.add(old_assignee_id)
if task.assignee_id:
touched_user_ids.add(int(task.assignee_id))
if touched_user_ids:
for uid in touched_user_ids:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=uid,
)
else:
await db.commit()
return success_response({"id": str(task.id), "status": task.status})
@router.delete("/projects/{project_id}/tasks/{task_id}")
async def delete_task(
project_id: str,
task_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
assignee_id = int(task.assignee_id) if task.assignee_id else None
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(tid),
action="delete",
before_data={
"assignee_id": assignee_id,
"estimated_hours": float(task.estimated_hours) if task.estimated_hours else None,
"status": task.status,
},
)
await db.execute(
delete(Task).where(
and_(
Task.id == tid,
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
if assignee_id:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=assignee_id,
)
else:
await db.commit()
return success_response({"id": str(tid), "deleted": True})
@router.post("/projects/{project_id}/tasks/{task_id}/recommend-assignee")
async def recommend_assignee_for_task(
project_id: str,
task_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
tid = _parse_node_id(task_id, "task_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid)
required_skills = [str(x) for x in (task.required_skill_tag_ids or [])]
recommendations = await recommend_personnel(
db=db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task.description or task.title,
task_id=str(task.id),
limit=5,
)
project_user_ids_result = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == pid,
)
)
)
project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()}
active_users_result = await db.execute(
select(User.id).where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
)
active_user_ids = {int(uid) for uid in active_users_result.scalars().all()}
filtered = []
for item in recommendations.get("recommendations", []):
try:
uid = int(item.get("user_id"))
except (TypeError, ValueError):
continue
if uid in project_user_ids and uid in active_user_ids:
filtered.append(item)
recommendations["recommendations"] = filtered
recommendations["total_count"] = len(filtered)
return success_response(recommendations)
@router.post("/projects/{project_id}/tasks/optimize-workload")
async def optimize_task_workload(
project_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
task_result = await db.execute(
select(Task).where(
and_(Task.project_id == pid, Task.tenant_id == tenant_id, Task.status.in_(["todo", "in_progress"]))
)
)
tasks = list(task_result.scalars().all())
wl_result = await db.execute(
select(User.id, User.workload)
.join(
UserProject,
and_(
UserProject.user_id == User.id,
UserProject.tenant_id == tenant_id,
UserProject.project_id == pid,
),
)
.where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
)
members = [{"id": int(uid), "workload": int(wl or 0)} for uid, wl in wl_result.all()]
if not members:
return success_response({"updated": 0, "message": "no active users"})
members.sort(key=lambda x: x["workload"])
updated = 0
touched_user_ids: set[int] = set()
for task in tasks:
if task.assignee_id:
continue
picked = members[0]
task.assignee_id = int(picked["id"])
touched_user_ids.add(int(picked["id"]))
picked["workload"] += int(float(task.estimated_hours or 1) * 2)
members.sort(key=lambda x: x["workload"])
updated += 1
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(pid),
action="optimize_workload",
after_data={"updated": updated},
)
if touched_user_ids:
for uid in touched_user_ids:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=uid,
)
else:
await db.commit()
return success_response({"updated": updated})
@router.get("/projects/{project_id}/releases")
async def list_releases(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
releases = await svc.get_releases(pid)
return success_response(
[
{
"id": str(r.id),
"title": r.title,
"status": r.status,
"content": _as_dict(r.content),
"version": r.version,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in releases
]
)
@router.post("/projects/{project_id}/releases")
async def create_release_plan(
project_id: str,
body: ReleaseCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=int(body.charter_id),
)
if not body.task_ids:
raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务")
task_result = await db.execute(
select(Task).where(
and_(
Task.id.in_(body.task_ids),
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
tasks = list(task_result.scalars().all())
if len(tasks) != len(body.task_ids):
raise HTTPException(status_code=400, detail="部分任务不存在")
not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES]
if not_done:
raise HTTPException(status_code=409, detail="发布计划仅允许绑定已完成任务")
content = {
"charter_id": int(charter.id),
"task_ids": [int(t.id) for t in tasks],
"planned_at": body.planned_at,
"verification_items": [],
"verification_done": False,
}
release_node = TruthNode(
tenant_id=tenant_id,
node_type="release",
parent_id=int(charter.id),
project_id=pid,
created_by=int(current_user.id),
title=compact_title(body.title, max_len=24),
content=content,
version=1,
status="pending",
)
db.add(release_node)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="release",
entity_id="new",
action="create",
after_data={"title": release_node.title, "task_ids": body.task_ids},
)
await db.commit()
await db.refresh(release_node)
return success_response(
{
"id": str(release_node.id),
"title": release_node.title,
"status": release_node.status,
"content": _as_dict(release_node.content),
}
)
@router.patch("/projects/{project_id}/releases/{release_node_id}/status")
async def patch_release_status(
project_id: str,
release_node_id: str,
body: ReleaseStatusPatchRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
before = {"status": release_node.status}
release_node.status = body.status
content = _as_dict(release_node.content)
content["status_note"] = body.note
release_node.content = content
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="release",
entity_id=str(rid),
action="status_change",
before_data=before,
after_data={"status": body.status, "note": body.note},
)
await db.commit()
return success_response({"id": str(release_node.id), "status": release_node.status})
@router.post("/projects/{project_id}/releases/{release_node_id}/verification/generate")
async def generate_release_verification(
project_id: str,
release_node_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
content = _as_dict(release_node.content)
charter_id = int(content.get("charter_id") or 0)
charter = await _ensure_charter(
db,
tenant_id=tenant_id,
project_id=pid,
charter_id=charter_id,
)
charter_content = _as_dict(charter.content)
success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or []
if not success_metrics:
success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"]
items = [
{
"id": f"check-{i+1}",
"title": str(metric),
"expected": "达到预期",
"status": "pending",
"verified_by": None,
"verified_at": None,
}
for i, metric in enumerate(success_metrics)
]
content["verification_items"] = items
release_node.content = content
await db.commit()
return success_response({"release_id": str(release_node.id), "verification_items": items})
@router.post("/projects/{project_id}/releases/{release_node_id}/verification/complete")
async def complete_release_verification(
project_id: str,
release_node_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(release_node_id, "release_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db, tenant_id=tenant_id, project_id=pid, release_node_id=rid
)
content = _as_dict(release_node.content)
items = content.get("verification_items", [])
now_str = datetime.now(timezone.utc).isoformat()
for item in items:
item["status"] = "done"
item["verified_by"] = str(current_user.id)
item["verified_at"] = now_str
content["verification_items"] = items
content["verification_done"] = True
release_node.content = content
release_node.status = "verified"
await db.commit()
return success_response({"id": str(release_node.id), "status": release_node.status})
@router.get("/projects/{project_id}/reviews")
async def list_reviews(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
pid = _parse_project_id(project_id)
svc = SSTCService(db, tenant_id=int(current_user.tenant_id))
await _ensure_project(svc, pid)
reviews = await svc.get_reviews(pid)
return success_response(
[
{
"id": str(r.id),
"title": r.title,
"status": r.status,
"content": _as_dict(r.content),
"version": r.version,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in reviews
]
)
@router.post("/projects/{project_id}/reviews")
async def create_review(
project_id: str,
body: ReviewCreateRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
release_node = await _ensure_release(
db,
tenant_id=tenant_id,
project_id=pid,
release_node_id=int(body.release_node_id),
)
if (release_node.status or "").lower() not in RELEASE_VERIFIED_STATUSES:
raise HTTPException(status_code=409, detail="发布未验证,复盘环节未解锁")
review_node = TruthNode(
tenant_id=tenant_id,
node_type="review",
parent_id=int(release_node.id),
project_id=pid,
created_by=int(current_user.id),
title=compact_title(body.title, max_len=24),
content=body.content,
version=1,
status="draft",
)
db.add(review_node)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="review",
entity_id="new",
action="create",
after_data={"title": review_node.title, "release_node_id": body.release_node_id},
)
await db.commit()
await db.refresh(review_node)
return success_response(
{
"id": str(review_node.id),
"title": review_node.title,
"status": review_node.status,
"content": _as_dict(review_node.content),
}
)
@router.post("/projects/{project_id}/reviews/{review_node_id}/archive")
async def archive_review(
project_id: str,
review_node_id: str,
body: ReviewArchiveRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
rid = _parse_node_id(review_node_id, "review_node_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
result = await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == rid,
TruthNode.project_id == pid,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "review",
)
)
)
review_node = result.scalar_one_or_none()
if not review_node:
raise HTTPException(status_code=404, detail="Review not found")
before = {"status": review_node.status}
review_node.status = "archived" if body.archive else "draft"
content = _as_dict(review_node.content)
content["archive_note"] = body.note
content["archived_at"] = datetime.now(timezone.utc).isoformat() if body.archive else None
review_node.content = content
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="review",
entity_id=str(rid),
action="archive" if body.archive else "unarchive",
before_data=before,
after_data={"status": review_node.status},
)
await db.commit()
return success_response({"id": str(review_node.id), "status": review_node.status})