Files
fastapi/backend/app/services/case_service.py
T
2026-06-01 09:25:26 +08:00

141 lines
6.1 KiB
Python

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"