import json import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.project_permissions import role_has_api_permission from app.crud import audit as audit_crud from app.crud import faq_category as category_crud from app.crud import faq_item as faq_crud from app.crud import faq_reply as reply_crud from app.crud import member as member_crud from app.schemas.common import PaginatedResponse from app.schemas.faq import ( FaqBestReplyUpdate, FaqCreate, FaqRead, FaqReplyCreate, FaqReplyQuote, FaqReplyRead, FaqStatusUpdate, FaqUpdate, ) from app.utils.pagination import paginate router = APIRouter() def _is_system_admin(current_user) -> bool: return is_system_admin(current_user) def _compact_text(value: str | None, max_length: int = 40) -> str: text = " ".join(str(value or "").split()) if len(text) <= max_length: return text return f"{text[:max_length]}..." @router.post( "/", response_model=FaqRead, status_code=status.HTTP_201_CREATED, summary="创建 FAQ", description="创建项目内 FAQ,权限由项目级权限矩阵控制。", dependencies=[ Depends(require_api_permission("faq:create")), Depends(require_study_not_locked()) ], ) async def create_faq( payload: FaqCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FaqRead: if not payload.study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") cat = await category_crud.get_category(db, payload.category_id) if not cat: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") if cat.study_id != payload.study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配") try: item = await faq_crud.create_item(db, payload, created_by=current_user.id) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if payload.answer: await reply_crud.create_reply( db, faq_id=item.id, study_id=item.study_id, created_by=current_user.id, reply_in=FaqReplyCreate(content=payload.answer), ) await faq_crud.set_status(db, item.id, "PROCESSING") question_name = _compact_text(item.question) await audit_crud.log_action( db, study_id=payload.study_id, entity_type="faq_item", entity_id=item.id, action="CREATE_FAQ_ITEM", detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}”"}, ensure_ascii=False), operator_id=current_user.id, operator_role=await get_operator_role_label(db, payload.study_id, current_user), ) return FaqRead.model_validate(item) @router.get( "/", response_model=PaginatedResponse[FaqRead], summary="FAQ 列表", description="返回全局与项目 FAQ,可按关键词、分类过滤。", dependencies=[Depends(require_api_permission("faq:read"))], ) async def list_faqs( study_id: uuid.UUID | None = None, category_id: uuid.UUID | None = None, keyword: str | None = None, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[FaqRead]: membership_cache: dict[uuid.UUID, str | None] = {} async def _get_member_role(sid: uuid.UUID) -> str | None: if sid in membership_cache: return membership_cache[sid] member = await member_crud.get_member(db, sid, current_user.id) role = member.role_in_study if member else None membership_cache[sid] = role return role if not study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") if not is_system_admin(current_user): member = await member_crud.get_member(db, study_id, current_user.id) if not member or not member.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") items = await faq_crud.list_items( db, study_id=study_id, category_id=category_id, keyword=keyword, study_scope="project", ) visible: list[FaqRead] = [] for it in items: if not _is_system_admin(current_user): role = await _get_member_role(it.study_id) if not role: continue visible.append(FaqRead.model_validate(it)) return paginate(visible, total=len(visible)) @router.get( "/{item_id}", response_model=FaqRead, summary="FAQ 详情", description="获取单条 FAQ,停用 FAQ 需项目级写权限。", dependencies=[Depends(require_api_permission("faq:read"))], ) async def get_faq( item_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FaqRead: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not item.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") return FaqRead.model_validate(item) @router.patch( "/{item_id}", response_model=FaqRead, summary="更新 FAQ", description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制。", dependencies=[ Depends(require_api_permission("faq:update")), Depends(require_study_not_locked()) ], ) async def update_faq( item_id: uuid.UUID, payload: FaqUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FaqRead: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") updated = await faq_crud.update_item(db, item, payload) action = "UPDATE_FAQ_ITEM" question_name = _compact_text(updated.question) detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}”"}, ensure_ascii=False) await audit_crud.log_action( db, study_id=item.study_id, entity_type="faq_item", entity_id=item_id, action=action, detail=detail, operator_id=current_user.id, operator_role=await get_operator_role_label(db, item.study_id, current_user), ) return FaqRead.model_validate(updated) @router.patch( "/{item_id}/status", response_model=FaqRead, summary="更新 FAQ 状态", description="提问者或具备项目级写权限的成员可确认已解决。", dependencies=[ Depends(require_api_permission("faq:update")), Depends(require_study_not_locked()) ], ) async def update_faq_status( item_id: uuid.UUID, payload: FaqStatusUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FaqRead: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if payload.status != "RESOLVED": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决") await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True) updated = await faq_crud.get_item(db, item_id) return FaqRead.model_validate(updated) @router.patch( "/{item_id}/best-reply", response_model=FaqRead, summary="设置最佳回复", description="项目成员可设置最佳回复,全局仅 ADMIN。", dependencies=[ Depends(require_api_permission("faq:update")), Depends(require_study_not_locked()) ], ) async def set_best_reply( item_id: uuid.UUID, payload: FaqBestReplyUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FaqRead: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not item.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if payload.best_reply_id: reply = await reply_crud.get_reply(db, payload.best_reply_id) if not reply or reply.faq_id != item.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复无效") if reply.is_deleted: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复已删除") await faq_crud.set_best_reply(db, item.id, reply.id) await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=item.resolved_by_confirm) else: await faq_crud.set_best_reply(db, item.id, None) if not item.resolved_by_confirm: remaining = await reply_crud.count_active_replies(db, item.id) await faq_crud.set_status( db, item.id, "PROCESSING" if remaining > 0 else "PENDING", resolved_by_confirm=False, ) updated = await faq_crud.get_item(db, item_id) return FaqRead.model_validate(updated) @router.get( "/{item_id}/replies", response_model=PaginatedResponse[FaqReplyRead], summary="FAQ 回复列表", description="获取 FAQ 的回复列表。", dependencies=[Depends(require_api_permission("faq:read"))], ) async def list_replies( item_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[FaqReplyRead]: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") replies = await reply_crud.list_replies(db, item_id) reply_map = {r.id: r for r in replies} result: list[FaqReplyRead] = [] for r in replies: data = FaqReplyRead.model_validate(r) if r.quote_reply_id and r.quote_reply_id in reply_map: quote = reply_map[r.quote_reply_id] if quote.is_deleted: data.quote = FaqReplyQuote( id=quote.id, content="回复已删除", created_by=quote.created_by, created_at=quote.created_at, ) else: data.quote = FaqReplyQuote.model_validate(quote) result.append(data) return paginate(result, total=len(result)) @router.post( "/{item_id}/replies", response_model=FaqReplyRead, status_code=status.HTTP_201_CREATED, summary="创建 FAQ 回复", description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。", dependencies=[ Depends(require_api_permission("faq_reply:create")), Depends(require_study_not_locked()) ], ) async def create_reply( item_id: uuid.UUID, payload: FaqReplyCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FaqReplyRead: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not item.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not payload.content.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空") quote = None if payload.quote_reply_id: quote = await reply_crud.get_reply(db, payload.quote_reply_id) if not quote or quote.faq_id != item.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用回复无效") reply = await reply_crud.create_reply( db, faq_id=item.id, study_id=item.study_id, created_by=current_user.id, reply_in=payload, ) if item.status != "RESOLVED": await faq_crud.set_status(db, item.id, "PROCESSING") await faq_crud.touch_item(db, item.id) question_name = _compact_text(item.question) await audit_crud.log_action( db, study_id=item.study_id, entity_type="faq_reply", entity_id=reply.id, action="CREATE_FAQ_REPLY", detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}”"}, ensure_ascii=False), operator_id=current_user.id, operator_role=await get_operator_role_label(db, item.study_id, current_user), ) data = FaqReplyRead.model_validate(reply) if quote: if quote.is_deleted: data.quote = FaqReplyQuote( id=quote.id, content="回复已删除", created_by=quote.created_by, created_at=quote.created_at, ) else: data.quote = FaqReplyQuote.model_validate(quote) return data @router.delete( "/{item_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除 FAQ", description="删除 FAQ,权限由项目级权限矩阵控制。", dependencies=[ Depends(require_api_permission("faq:delete")), Depends(require_study_not_locked()) ], ) async def delete_faq( item_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") question_name = _compact_text(item.question) await reply_crud.delete_replies_by_faq_id(db, item.id) await db.delete(item) await db.commit() await audit_crud.log_action( db, study_id=item.study_id, entity_type="faq_item", entity_id=item_id, action="DELETE_FAQ_ITEM", detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”"}, ensure_ascii=False), operator_id=current_user.id, operator_role=await get_operator_role_label(db, item.study_id, current_user), ) @router.delete( "/{item_id}/replies/{reply_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除 FAQ 回复", description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。", dependencies=[ Depends(require_api_permission("faq_reply:delete")), Depends(require_study_not_locked()) ], ) async def delete_reply( item_id: uuid.UUID, reply_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") question_name = _compact_text(item.question) reply = await reply_crud.get_reply(db, reply_id) if not reply or reply.faq_id != item.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在") if item.best_reply_id == reply.id: await faq_crud.set_best_reply(db, item.id, None) ref_count = await reply_crud.count_quote_references(db, reply.id) if ref_count > 0: await reply_crud.soft_delete_reply(db, reply) else: await reply_crud.delete_reply(db, reply) if not item.resolved_by_confirm: if item.best_reply_id and item.best_reply_id != reply.id: await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=False) else: remaining = await reply_crud.count_active_replies(db, item.id) await faq_crud.set_status( db, item.id, "PROCESSING" if remaining > 0 else "PENDING", resolved_by_confirm=False, ) await faq_crud.touch_item(db, item.id) await audit_crud.log_action( db, study_id=item.study_id, entity_type="faq_reply", entity_id=reply_id, action="DELETE_FAQ_REPLY", detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False), operator_id=current_user.id, operator_role=await get_operator_role_label(db, item.study_id, current_user), )