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, 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: role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role return role == "ADMIN" @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") await audit_crud.log_action( db, study_id=payload.study_id, entity_type="faq_item", entity_id=item.id, action="CREATE_FAQ_ITEM", detail="FAQ 已创建", operator_id=current_user.id, operator_role=current_user.role, ) 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, is_active: bool | 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") role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if role_value != "ADMIN": 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="不是项目成员") if is_active is False: allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False) if not allowed: 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, is_active=is_active, 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 if not it.is_active and not _is_system_admin(current_user): allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False) if not allowed: 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 不存在") role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if not item.is_active and role_value != "ADMIN": member = await member_crud.get_member(db, item.study_id, current_user.id) if not member or not member.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False) if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") 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 不存在") old_active = item.is_active updated = await faq_crud.update_item(db, item, payload) action = "UPDATE_FAQ_ITEM" detail = "FAQ updated" if payload.is_active is not None and payload.is_active != old_active: action = "FAQ_STATUS_CHANGE" detail = "FAQ disabled" if not payload.is_active else "FAQ enabled" 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=current_user.role, ) 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) await audit_crud.log_action( db, study_id=item.study_id, entity_type="faq_reply", entity_id=reply.id, action="CREATE_FAQ_REPLY", detail="FAQ 已回复", operator_id=current_user.id, operator_role=current_user.role, ) 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 不存在") 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="FAQ 已删除", operator_id=current_user.id, operator_role=current_user.role, ) @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 不存在") 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="FAQ 回复已删除", operator_id=current_user.id, operator_role=current_user.role, )