deerflow-code/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py
2026-09-07 18:24:55 +08:00

153 lines
6.0 KiB
Python

"""CRUD API for recommended questions."""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["recommended-questions"])
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
class RecommendedQuestionResponse(BaseModel):
id: str
title: str
content: str
sort_order: int = 0
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this question (按岗位分发标签)")
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class RecommendedQuestionsListResponse(BaseModel):
questions: list[RecommendedQuestionResponse]
class RecommendedQuestionCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
content: str = Field(..., min_length=1)
sort_order: int = Field(default=0)
class RecommendedQuestionUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=256)
content: str | None = None
sort_order: int | None = None
class ReorderRequest(BaseModel):
ids: list[str] = Field(..., description="Question IDs in the desired order")
def _get_store(request: Request):
store = getattr(request.app.state, "recommended_question_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Recommended question store not available")
return store
@router.get("/recommended-questions", response_model=RecommendedQuestionsListResponse)
async def list_recommended_questions(
request: Request,
all: bool = Query(default=False, description="Admin management view: return every question, unfiltered by 岗位."),
tag_ids: list[str] | None = Query(default=None, description="Keep questions carrying any of these tags (OR)."),
) -> RecommendedQuestionsListResponse:
store = _get_store(request)
rows = await store.list_all()
responses = [RecommendedQuestionResponse(**r) for r in rows]
# Bulk-attach the tags assigned to each question so the management list can
# display them and the 岗位 filter below can tell tagged from untagged.
# Best-effort: a missing/failing tag store never breaks the question list.
tag_store = getattr(request.app.state, "tag_store", None)
if tag_store is not None and responses:
try:
assignments = await tag_store.list_assignments("recommended_question", [r.id for r in responses])
except Exception:
logger.debug("recommended-question tag attach failed", exc_info=True)
assignments = {}
for resp in responses:
resp.tags = assignments.get(resp.id, [])
if not all:
# Filter by the caller's 岗位 (position) visibility:
# * ``set`` — the position binds recommended_question 标签 → show only
# the questions carrying those bound tags.
# * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions.
# 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。
sync = getattr(request.app.state, "position_sync", None)
if sync is not None:
try:
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
except Exception:
logger.debug("recommended-question visibility resolve failed", exc_info=True)
visible = None
if visible is not None:
responses = [r for r in responses if r.id in visible]
else:
responses = [r for r in responses if not r.tags]
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return RecommendedQuestionsListResponse(questions=responses)
@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201)
async def create_recommended_question(request: Request, body: RecommendedQuestionCreateRequest) -> RecommendedQuestionResponse:
store = _get_store(request)
data = {
"id": str(uuid4()),
"title": body.title.strip(),
"content": body.content.strip(),
"sort_order": body.sort_order,
}
row = await store.create(data)
return RecommendedQuestionResponse(**row)
@router.put("/recommended-questions/{question_id}", response_model=RecommendedQuestionResponse)
async def update_recommended_question(request: Request, question_id: str, body: RecommendedQuestionUpdateRequest) -> RecommendedQuestionResponse:
store = _get_store(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.content is not None:
data["content"] = body.content.strip()
if body.sort_order is not None:
data["sort_order"] = body.sort_order
row = await store.update(question_id, data)
if row is None:
raise HTTPException(status_code=404, detail="Question not found")
return RecommendedQuestionResponse(**row)
@router.delete("/recommended-questions/{question_id}", status_code=204)
async def delete_recommended_question(request: Request, question_id: str) -> None:
store = _get_store(request)
deleted = await store.delete(question_id)
if not deleted:
raise HTTPException(status_code=404, detail="Question not found")
@router.put("/recommended-questions-reorder", response_model=RecommendedQuestionsListResponse)
async def reorder_recommended_questions(request: Request, body: ReorderRequest) -> RecommendedQuestionsListResponse:
store = _get_store(request)
rows = await store.reorder(body.ids)
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])