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 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 _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None): if current_user.role != "ADMIN" and member_role != "PM": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") def _check_create_permission(current_user, is_member: bool): if current_user.role != "ADMIN" and not is_member: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") @router.post( "/", response_model=FaqRead, status_code=status.HTTP_201_CREATED, summary="创建 FAQ", description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。", dependencies=[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="分类范围不匹配") member_role = None is_member = False member = await member_crud.get_member(db, payload.study_id, current_user.id) member_role = member.role_in_study if member else None is_member = member is not None _check_create_permission(current_user, is_member) 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,可按关键词、分类过滤。", ) 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") if current_user.role != "ADMIN": role = await _get_member_role(study_id) if not role: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") if is_active is False and current_user.role != "ADMIN": role = await _get_member_role(study_id) if role != "PM": 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 current_user.role != "ADMIN": role = await _get_member_role(it.study_id) if not role: continue if not it.is_active and current_user.role != "ADMIN": role = await _get_member_role(it.study_id) if role != "PM": continue visible.append(FaqRead.model_validate(it)) return paginate(visible, total=len(visible)) @router.get( "/{item_id}", response_model=FaqRead, summary="FAQ 详情", description="获取单条 FAQ,非 PM 不能查看停用 FAQ。", ) 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 不存在") if current_user.role != "ADMIN": member = await member_crud.get_member(db, item.study_id, current_user.id) if not member: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") if not item.is_active and current_user.role not in {"ADMIN"}: member = await member_crud.get_member(db, item.study_id, current_user.id) member_role = member.role_in_study if member else None if member_role != "PM": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FAQ 已停用") return FaqRead.model_validate(item) @router.patch( "/{item_id}", response_model=FaqRead, summary="更新 FAQ", description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。", dependencies=[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 不存在") if item.created_by != current_user.id: member_role = None if item.study_id: member = await member_crud.get_member(db, item.study_id, current_user.id) member_role = member.role_in_study if member else None _check_write_permission(item.study_id, current_user, member_role) 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="提问者或项目 PM/ADMIN 可确认已解决。", dependencies=[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="仅允许设置为已解决") if item.created_by != current_user.id and current_user.role != "ADMIN": member_role = None if item.study_id: member = await member_crud.get_member(db, item.study_id, current_user.id) member_role = member.role_in_study if member else None if member_role != "PM": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 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_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 不存在") is_member = False member = await member_crud.get_member(db, item.study_id, current_user.id) is_member = member is not None _check_create_permission(current_user, is_member) 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 的回复列表。", ) 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 不存在") if item.study_id and current_user.role != "ADMIN": member = await member_crud.get_member(db, item.study_id, current_user.id) if not member: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") 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_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="回复内容不能为空") is_member = False member = await member_crud.get_member(db, item.study_id, current_user.id) is_member = member is not None _check_create_permission(current_user, is_member) 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,项目内需 PM/ADMIN 权限,全局仅 ADMIN。", dependencies=[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 不存在") if item.created_by != current_user.id: member_role = None if item.study_id: member = await member_crud.get_member(db, item.study_id, current_user.id) member_role = member.role_in_study if member else None _check_write_permission(item.study_id, current_user, member_role) 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 回复,管理员、项目 PM 或回复者可删除。", dependencies=[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 reply.created_by != current_user.id and current_user.role != "ADMIN": member_role = None if item.study_id: member = await member_crud.get_member(db, item.study_id, current_user.id) member_role = member.role_in_study if member else None if member_role != "PM": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 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, )