make case catalog read-only

This commit is contained in:
刘金宝
2026-06-04 17:50:22 +08:00
parent b46e43aadc
commit 7f1803f9fa
15 changed files with 35 additions and 1268 deletions
+1 -56
View File
@@ -1,20 +1,10 @@
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
from app.schemas.case import CaseDetailResponse, CaseListItem, CaseListResponse, CasePatientInfo
class CaseService:
@@ -62,51 +52,6 @@ class CaseService:
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(
-101
View File
@@ -1,101 +0,0 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from fastapi import UploadFile
from app.core.exceptions import AppError
from app.schemas.imports import CaseSqlImportApplyResponse, CaseSqlImportPreviewResponse, CaseSqlPreviewCase
from scripts.import_source_case_sql import ImportValidationError, import_source_sql, parse_source_dump
MAX_SQL_UPLOAD_BYTES = 5 * 1024 * 1024
ALLOWED_TABLES = {"case_base", "traditional_case", "teaching_case", "scoring_rule"}
class CaseSqlImportService:
"""病例 SQL 导入服务:解析接口 SQL 文件并安全映射到当前病例表。"""
async def preview(self, file: UploadFile) -> CaseSqlImportPreviewResponse:
"""导入预检:上传 SQL 后只解析结构和数据,不写入数据库。"""
temp_path = await self._save_upload_to_temp(file)
try:
parsed, warnings, encoding = parse_source_dump(temp_path)
table_rows = self._allowed_table_counts(parsed)
preview_cases = [
CaseSqlPreviewCase(
id=int(row["id"]),
title=str(row.get("title") or ""),
case_type=str(row.get("case_type") or ""),
difficulty=str(row.get("difficulty") or ""),
)
for row in parsed.get("case_base", [])
]
return CaseSqlImportPreviewResponse(
file_name=file.filename or "case.sql",
encoding=encoding,
tables=table_rows,
can_import=True,
warnings=warnings,
errors=[],
preview_cases=preview_cases,
)
except ImportValidationError as exc:
return CaseSqlImportPreviewResponse(
file_name=file.filename or "case.sql",
can_import=False,
errors=[str(exc)],
)
finally:
self._remove_temp_file(temp_path)
async def apply(self, file: UploadFile) -> CaseSqlImportApplyResponse:
"""确认导入:解析通过后以事务方式写入 case_base/traditional_case/teaching_case/scoring_rule。"""
temp_path = await self._save_upload_to_temp(file)
try:
report = import_source_sql(temp_path, apply=True)
return CaseSqlImportApplyResponse(
imported=True,
file_name=file.filename or "case.sql",
encoding=report.encoding,
inserted_or_updated_cases=report.upserted_cases,
imported_traditional_cases=report.upserted_traditional_cases,
imported_teaching_cases=report.upserted_teaching_cases,
imported_scoring_rules=report.replaced_scoring_rules,
generated_exam_items=report.generated_exam_items,
warnings=report.warnings,
)
except ImportValidationError as exc:
raise AppError("CASE_SQL_IMPORT_INVALID", str(exc), 400) from exc
finally:
self._remove_temp_file(temp_path)
async def _save_upload_to_temp(self, file: UploadFile) -> Path:
"""上传落盘:校验 SQL 后缀和大小后保存到临时文件,供解析器读取。"""
filename = file.filename or ""
if not filename.lower().endswith(".sql"):
raise AppError("CASE_SQL_FILE_INVALID", "only .sql files are supported", 400)
content = await file.read()
if not content:
raise AppError("CASE_SQL_FILE_EMPTY", "uploaded SQL file is empty", 400)
if len(content) > MAX_SQL_UPLOAD_BYTES:
raise AppError("CASE_SQL_FILE_TOO_LARGE", "SQL file is larger than 5MB", 400)
handle = tempfile.NamedTemporaryFile(delete=False, suffix=".sql")
try:
handle.write(content)
return Path(handle.name)
finally:
handle.close()
def _allowed_table_counts(self, parsed: dict[str, list[dict]]) -> dict[str, int]:
"""表计数:只暴露当前业务允许导入的病例表。"""
return {table: len(rows) for table, rows in parsed.items() if table in ALLOWED_TABLES}
def _remove_temp_file(self, path: Path) -> None:
"""临时文件清理:解析或导入完成后删除上传副本。"""
try:
path.unlink(missing_ok=True)
except OSError:
pass