from sqlalchemy.orm import Session from app.core.context import UserContext from app.core.exceptions import AppError from app.models.source_case import CaseBase from app.repositories.case_repository import CaseRepository from app.repositories.source_case_repository import SourceCaseRepository from app.schemas.case import ( CaseDeletePreviewResponse, CaseDeleteRequest, CaseDeleteResponse, CaseDetailResponse, CaseListItem, CaseListResponse, CasePatientInfo, ) from app.services.audit_service import AuditService class CaseService: """病例服务:基于 case_base 新表体系提供病例列表和训练入口详情。""" def __init__(self, db: Session) -> None: self.db = db self.repo = CaseRepository(db) self.source_repo = SourceCaseRepository(db) def list_cases( self, department_id: int | None = None, training_type: str | None = None, mode: str | None = None, ) -> CaseListResponse: """病例列表:从 case_base 读取已发布病例,并按模式匹配传统/教学互动扩展表。""" cases = self.repo.list_active_cases(department_id=department_id, training_type=training_type, mode=mode) return CaseListResponse(items=[self._to_list_item(case) for case in cases]) def get_case_detail(self, case_id: int) -> CaseDetailResponse: """病例详情:展示训练入口信息,不返回标准答案、隐藏病情和评分细则。""" case = self.repo.get_active_case(case_id) if not case: raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) order_items = self.repo.get_exam_items(case.id) return CaseDetailResponse( id=case.id, case_code=f"SRC_{case.id}", title=case.title, department=self.source_repo.get_department_name(case.department_id), difficulty=case.difficulty, patient=CasePatientInfo( name=None, age=case.patient_age, gender=case.patient_gender, occupation=None, ), chief_complaint=case.chief_complaint, supported_training_type=self._training_type(case.case_type), supported_mode=self._supported_mode(case), has_teaching_video=self._has_video(case), has_knowledge_points=bool(case.knowledge_points), has_quiz=bool(case.teaching_case and case.teaching_case.discussion_questions), order_item_types=sorted({item.item_type for item in order_items}), ) def get_delete_preview(self, case_id: int) -> CaseDeletePreviewResponse: """病例删除预览:返回删除病例前端需要展示的影响范围。""" case = self.repo.get_case_by_id(case_id) if not case: raise AppError("CASE_NOT_FOUND", "case not found", 404) return CaseDeletePreviewResponse( case_id=case.id, case_title=case.title, can_delete=True, affected=self.repo.get_delete_preview_counts(case.id), ) def delete_case(self, case_id: int, payload: CaseDeleteRequest, ctx: UserContext) -> CaseDeleteResponse: """病例删除:级联删除病例业务数据,并保留审计日志用于追踪操作。""" case = self.repo.get_case_by_id(case_id) if not case: raise AppError("CASE_NOT_FOUND", "case not found", 404) if not payload.confirm: raise AppError("CASE_DELETE_CONFIRM_REQUIRED", "case delete confirmation is required", 400) preview = self.repo.get_delete_preview_counts(case_id) training_rows = ( preview.get("training_session", 0) + preview.get("training_order", 0) + preview.get("training_submission", 0) + preview.get("training_record", 0) ) if training_rows and not payload.delete_training_data: raise AppError("CASE_DELETE_TRAINING_DATA_EXISTS", "case has training data; delete_training_data must be true", 400) try: deleted_counts = self.repo.delete_case_cascade(case_id) AuditService(self.db).log( ctx, action="case.delete", resource_type="case", resource_id=str(case_id), metadata={"case_title": case.title, "deleted_counts": deleted_counts}, ) self.db.commit() except Exception: self.db.rollback() raise return CaseDeleteResponse(deleted=True, case_id=case_id, deleted_counts=deleted_counts) def _to_list_item(self, case: CaseBase) -> CaseListItem: """病例卡片转换:把 case_base 映射为当前前端病例列表结构。""" return CaseListItem( id=case.id, case_code=f"SRC_{case.id}", department_id=case.department_id or 0, title=case.title, difficulty=case.difficulty, chief_complaint=case.chief_complaint, supported_training_type=self._training_type(case.case_type), supported_mode=self._supported_mode(case), has_teaching_video=self._has_video(case), has_knowledge_points=bool(case.knowledge_points), has_quiz=bool(case.teaching_case and case.teaching_case.discussion_questions), ) @staticmethod def _supported_mode(case: CaseBase) -> str: """模式标识:教学互动病例显示 interactive,其余显示 free_chat。""" return "interactive" if case.teaching_case else "free_chat" @staticmethod def _has_video(case: CaseBase) -> bool: """资源标识:根据 source 表 multimodal_assets 判断是否存在视频资源。""" assets = case.multimodal_assets or [] return any(isinstance(item, dict) and item.get("type") == "video" for item in assets) @staticmethod def _training_type(case_type: str) -> str: """训练类别兼容:源库 case_type 不在当前枚举内时按诊断治疗训练处理。""" return case_type if case_type in {"case_analysis", "diagnosis_treatment", "consultation"} else "diagnosis_treatment"