516 lines
25 KiB
Python
516 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
CMS 病例库 + AI 病例生成 + 病例审核 — Swagger Try-it-out 等效脚本(真实 HTTP)。
|
||
|
||
覆盖 CSV 行 80~98(超级管理员/内容管理员/医院管理员 各角色拆分):
|
||
CMS-CASE-1~7、CMS-CASE-AI-1、CMS-AUDIT-1~3
|
||
|
||
- 详细日志:logs/test-swagger-cms-case-YYYY-MM-DD.log
|
||
- 样例 JSON:logs/cms-case-swagger-examples.json(供回填 docx/API - Sheet1.csv)
|
||
- PDF/AI 需本地 Django + Redis + .env 中 DEEPSEEK_API_KEY;无 Key 时加 --skip-ai 跳过
|
||
|
||
运行:
|
||
.venv\\Scripts\\python.exe test/swagger_cms_case.py
|
||
.venv\\Scripts\\python.exe test/swagger_cms_case.py --update-csv
|
||
.venv\\Scripts\\python.exe test/swagger_cms_case.py --skip-ai --update-csv
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import io
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
import requests
|
||
|
||
sys.stdout.reconfigure(encoding='utf-8')
|
||
sys.stderr.reconfigure(encoding='utf-8')
|
||
|
||
BASE = os.environ.get('SWAGGER_BASE', 'http://127.0.0.1:8000')
|
||
PYTHON = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.venv', 'Scripts', 'python.exe')
|
||
if not os.path.isfile(PYTHON):
|
||
PYTHON = sys.executable
|
||
CWD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
LOG_DIR = os.path.join(CWD, 'logs')
|
||
CSV_PATH = os.path.join(CWD, 'docx', 'API - Sheet1.csv')
|
||
os.makedirs(LOG_DIR, exist_ok=True)
|
||
|
||
LOG_FILE = os.path.join(LOG_DIR, f'test-swagger-cms-case-{datetime.now():%Y-%m-%d}.log')
|
||
EXAMPLES_FILE = os.path.join(LOG_DIR, 'cms-case-swagger-examples.json')
|
||
|
||
SUPER_PHONE = '13700009001'
|
||
CONTENT_PHONE = '13700009002'
|
||
HOSPITAL_PHONE = '13700009004'
|
||
|
||
_fh = open(LOG_FILE, 'a', encoding='utf-8')
|
||
examples: dict[str, dict] = {}
|
||
results: list[tuple[str, str, int]] = []
|
||
|
||
|
||
def w(text=''):
|
||
line = str(text)
|
||
_fh.write(line + '\n')
|
||
_fh.flush()
|
||
print(line)
|
||
|
||
|
||
def django_eval(code: str) -> str:
|
||
pre = (
|
||
'import django,os;'
|
||
'os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings");'
|
||
'django.setup();'
|
||
)
|
||
p = subprocess.run(
|
||
[PYTHON, '-c', pre + code],
|
||
capture_output=True, text=True, cwd=CWD, encoding='utf-8',
|
||
)
|
||
if p.returncode != 0:
|
||
w(f'[django_eval stderr] {p.stderr}')
|
||
return (p.stdout or '').strip()
|
||
|
||
|
||
def compact_json(obj: Any, max_len: int = 2800) -> str:
|
||
s = json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
|
||
if len(s) > max_len:
|
||
return s[:max_len] + '...(truncated)'
|
||
return s
|
||
|
||
|
||
def case_payload(title: str, institution_id=None, with_exam=False):
|
||
p = {
|
||
'title': title,
|
||
'case_type': 'traditional',
|
||
'difficulty': 'medium',
|
||
'chief_complaint': '发热 3 天',
|
||
'description': '患儿,男,4 岁,因发热 3 天就诊。',
|
||
'patient_age': 4,
|
||
'patient_gender': 'male',
|
||
'department_name': '儿科',
|
||
'estimated_minutes': 30,
|
||
'osce_enabled': False,
|
||
'tags': '儿科,发热',
|
||
'traditional': {
|
||
'standard_diagnosis': '上呼吸道感染',
|
||
'standard_treatment': '对症治疗,退热处理',
|
||
'guideline_reference': '《儿科学》第 9 版',
|
||
},
|
||
'scoring_rules': [
|
||
{'dimension': '诊断准确性', 'score_weight': 0.5, 'ai_auto_score': True, 'scoring_standard': '能准确诊断'},
|
||
{'dimension': '医患沟通', 'score_weight': 0.5, 'ai_auto_score': False, 'scoring_standard': '沟通到位'},
|
||
],
|
||
}
|
||
if institution_id is not None:
|
||
p['institution_id'] = institution_id
|
||
if with_exam:
|
||
p['exam_items'] = [{
|
||
'item_code': 'blood_routine',
|
||
'item_name': '血常规',
|
||
'item_type': 'lab',
|
||
'category': '实验室检查',
|
||
'result_text': 'WBC 10×10^9/L',
|
||
'is_key': True,
|
||
'is_abnormal': False,
|
||
'score_weight': 1.0,
|
||
'display_order': 1,
|
||
}]
|
||
return p
|
||
|
||
|
||
def shrink_list_resp(resp: dict) -> dict:
|
||
"""列表响应保留 1 条 results 样例,便于 CSV。"""
|
||
if not isinstance(resp, dict):
|
||
return resp
|
||
out = dict(resp)
|
||
if isinstance(out.get('results'), list) and out['results']:
|
||
out['results'] = [out['results'][0]]
|
||
return out
|
||
|
||
|
||
def shrink_full_resp(resp: dict) -> dict:
|
||
"""完整病例响应保留关键字段。"""
|
||
if not isinstance(resp, dict):
|
||
return resp
|
||
c = resp.get('case') or {}
|
||
return {
|
||
'case': {k: c.get(k) for k in (
|
||
'id', 'title', 'case_type', 'institution', 'institution_name',
|
||
'department', 'department_name', 'publish_status', 'created_by_name', 'created_at',
|
||
)},
|
||
'traditional': resp.get('traditional'),
|
||
'scoring_rules': (resp.get('scoring_rules') or [])[:2],
|
||
'exam_items': (resp.get('exam_items') or [])[:1],
|
||
}
|
||
|
||
|
||
def shrink_ai_resp(resp: dict) -> dict:
|
||
if not isinstance(resp, dict):
|
||
return resp
|
||
data = resp.get('data') or {}
|
||
return {
|
||
'parse_id': resp.get('parse_id'),
|
||
'case_type': resp.get('case_type'),
|
||
'ai_usage': resp.get('ai_usage'),
|
||
'prompt_version': resp.get('prompt_version'),
|
||
'parsing_seconds': resp.get('parsing_seconds'),
|
||
'generating_seconds': resp.get('generating_seconds'),
|
||
'data': {k: data.get(k) for k in (
|
||
'title', 'case_type', 'chief_complaint', 'department_name', 'patient_age', 'patient_gender',
|
||
) if k in data},
|
||
}
|
||
|
||
|
||
def call(
|
||
csv_key: str,
|
||
name: str,
|
||
method: str,
|
||
path: str,
|
||
token: str | None = None,
|
||
json_body=None,
|
||
params=None,
|
||
files=None,
|
||
data=None,
|
||
expect=200,
|
||
shrink=None,
|
||
):
|
||
"""发起 HTTP 请求,记录日志与 CSV 样例。"""
|
||
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
||
url = BASE + path
|
||
r = requests.request(
|
||
method, url, headers=headers, json=json_body, params=params,
|
||
files=files, data=data, timeout=120,
|
||
)
|
||
ct = r.headers.get('content-type', '')
|
||
is_json = ct.startswith('application/json')
|
||
resp_raw = r.json() if is_json else r.text
|
||
resp_store = resp_raw
|
||
if shrink == 'list':
|
||
resp_store = shrink_list_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||
elif shrink == 'full':
|
||
resp_store = shrink_full_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||
elif shrink == 'ai':
|
||
resp_store = shrink_ai_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||
elif shrink == 'create':
|
||
resp_store = shrink_full_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||
|
||
if json_body is not None:
|
||
req_example = json.dumps(json_body, ensure_ascii=False)
|
||
elif files is not None:
|
||
fn = list(files.values())[0][0] if files else 'file.pdf'
|
||
extra = f', case_type={data.get("case_type")}' if data else ''
|
||
req_example = f'multipart/form-data: files={fn}{extra}'
|
||
elif params:
|
||
req_example = '?' + '&'.join(f'{k}={v}' for k, v in params.items())
|
||
elif method == 'GET' and '{' in path:
|
||
req_example = f'路径参数 + 请求头 Authorization: Bearer <token>'
|
||
elif method in ('POST',) and json_body is None and files is None:
|
||
req_example = '路径参数 id + 请求头 Authorization(无 Body)'
|
||
else:
|
||
req_example = '请求头 Authorization: Bearer <token>'
|
||
|
||
exp_list = expect if isinstance(expect, (list, tuple)) else [expect]
|
||
ok = 'PASS' if r.status_code in exp_list else 'FAIL'
|
||
results.append((csv_key, ok, r.status_code))
|
||
examples[csv_key] = {
|
||
'name': name,
|
||
'method': method,
|
||
'path': path.lstrip('/'),
|
||
'status': r.status_code,
|
||
'req': req_example,
|
||
'resp': resp_store,
|
||
}
|
||
|
||
w(f'\n[{ok}] {csv_key} {name} -> {method} {path} (status={r.status_code})')
|
||
w(f' 请求: {req_example}')
|
||
body_str = json.dumps(resp_store, ensure_ascii=False, indent=2) if is_json else str(resp_store)
|
||
if len(body_str) > 1800:
|
||
body_str = body_str[:1800] + f' ...(截断, 共{len(body_str)}字符)'
|
||
w(f' 响应: {body_str}')
|
||
return r
|
||
|
||
|
||
def cleanup():
|
||
django_eval(
|
||
'from apps.case.models import CaseBase; from apps.user.models import User; '
|
||
f'CaseBase.all_objects.filter(created_by__phone__in=["{SUPER_PHONE}","{CONTENT_PHONE}"]).hard_delete(); '
|
||
f'User.objects.filter(phone__in=["{SUPER_PHONE}","{CONTENT_PHONE}","{HOSPITAL_PHONE}"]).delete(); '
|
||
'from apps.user.models import Institution; '
|
||
'Institution.all_objects.filter(code__in=["SWG_CASE_A","SWG_CASE_B"]).hard_delete(); '
|
||
'print("cleaned")'
|
||
)
|
||
|
||
|
||
def setup_tokens():
|
||
w('\n[准备] 建临时机构/用户并签发 token ...')
|
||
setup = django_eval(
|
||
'from apps.user.models import User, Institution, Department; '
|
||
'from rest_framework_simplejwt.tokens import RefreshToken; '
|
||
'iA,_=Institution.objects.get_or_create(code="SWG_CASE_A", defaults={"name":"病例联调A院","type":"hospital"}); '
|
||
'iB,_=Institution.objects.get_or_create(code="SWG_CASE_B", defaults={"name":"病例联调B院","type":"hospital"}); '
|
||
'd1,_=Department.objects.get_or_create(name="儿科", defaults={"category":"临床"}); '
|
||
'd2,_=Department.objects.get_or_create(name="内科", defaults={"category":"临床"}); '
|
||
f'[User.objects.filter(phone=p).delete() for p in ["{SUPER_PHONE}","{CONTENT_PHONE}","{HOSPITAL_PHONE}"]]; '
|
||
f'su=User.objects.create_user(username="{SUPER_PHONE}",password=None,phone="{SUPER_PHONE}",'
|
||
f'real_name="病例超管",role_type="super_admin",institution=iA,status=1); '
|
||
f'cu=User.objects.create_user(username="{CONTENT_PHONE}",password=None,phone="{CONTENT_PHONE}",'
|
||
f'real_name="病例内容员",role_type="content_admin",institution=iA,status=1); '
|
||
f'hu=User.objects.create_user(username="{HOSPITAL_PHONE}",password=None,phone="{HOSPITAL_PHONE}",'
|
||
f'real_name="病例院管",role_type="hospital_admin",institution=iA,status=1); '
|
||
'print("|".join([str(RefreshToken.for_user(su).access_token),'
|
||
'str(RefreshToken.for_user(cu).access_token),str(RefreshToken.for_user(hu).access_token),'
|
||
'str(d1.id),str(d2.id),str(iA.id),str(iB.id)]))'
|
||
)
|
||
su_tok, cu_tok, hu_tok, d1_id, d2_id, iA_id, iB_id = setup.split('|')
|
||
w(f'[准备] 完成 instA={iA_id} instB={iB_id} dept儿科={d1_id} dept内科={d2_id}')
|
||
return su_tok, cu_tok, hu_tok, int(d1_id), int(d2_id), int(iA_id), int(iB_id)
|
||
|
||
|
||
def run_tests(skip_ai: bool):
|
||
su_tok, cu_tok, hu_tok, d1_id, d2_id, iA_id, iB_id = setup_tokens()
|
||
|
||
w('\n' + '=' * 90)
|
||
w(' 超级管理员 - 病例库')
|
||
w('=' * 90)
|
||
|
||
# 先建病例供后续列表/查看
|
||
body_super = case_payload('Swagger超管病例(联调)', with_exam=True)
|
||
r = call('82', 'CMS-CASE-3 表单新增(超管)', 'POST', '/api/cms/cases/',
|
||
su_tok, json_body=body_super, expect=201, shrink='create')
|
||
super_case_id = (r.json().get('case') or {}).get('id')
|
||
r_b = call('_b', 'CMS-CASE-3 指定B院(流程用)', 'POST', '/api/cms/cases/',
|
||
su_tok, json_body=case_payload('Swagger B院病例', institution_id=iB_id),
|
||
expect=201, shrink='create')
|
||
b_case_id = (r_b.json().get('case') or {}).get('id')
|
||
|
||
call('80', 'CMS-CASE-1 病例列表(超管)', 'GET', '/api/cms/cases/',
|
||
su_tok, params={'page': 1}, expect=200, shrink='list')
|
||
|
||
pdf_bytes = (
|
||
b'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
|
||
b'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
|
||
b'3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R>>endobj\n'
|
||
b'4 0 obj<</Length 44>>stream\nBT /F1 12 Tf 100 700 Td (fever 3d) Tj ET\nendstream\nendobj\n'
|
||
b'xref\n0 5\ntrailer<</Root 1 0 R>>\nstartxref\n0\n%%EOF'
|
||
)
|
||
if not skip_ai:
|
||
w('\n[AI] PDF 导入 / AI 生成(需 DEEPSEEK_API_KEY)...')
|
||
call('81', 'CMS-CASE-2 PDF导入(超管)', 'POST', '/api/cms/cases/import-pdf/',
|
||
su_tok, files={'files': ('case.pdf', pdf_bytes, 'application/pdf')},
|
||
data={'case_type': 'traditional'}, expect=[200, 429, 502, 504], shrink='ai')
|
||
call('87', 'CMS-CASE-AI-1 AI生成(超管)', 'POST', '/api/cms/cases/ai-generate/',
|
||
su_tok, json_body={
|
||
'case_type': 'traditional',
|
||
'prompt': '请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。',
|
||
}, expect=[200, 429, 502, 504], shrink='ai')
|
||
else:
|
||
w('\n[跳过] PDF/AI 接口(--skip-ai)')
|
||
|
||
call('83', 'CMS-CASE-4 编辑关联(超管)', 'POST', f'/api/cms/cases/{super_case_id}/relations/',
|
||
su_tok, json_body={'institution_id': iB_id, 'department_id': d2_id}, expect=200)
|
||
|
||
# 单独草稿用于 submit
|
||
r = call('84', 'CMS-CASE-5 提交(超管)', 'POST', f'/api/cms/cases/{super_case_id}/submit/',
|
||
su_tok, expect=200)
|
||
# 再建一条用于 disable
|
||
r2 = call('85', 'CMS-CASE-6 停用(超管)', 'POST', f'/api/cms/cases/{b_case_id}/disable/',
|
||
su_tok, expect=200)
|
||
|
||
call('86', 'CMS-CASE-7 查看(超管)', 'GET', f'/api/cms/cases/{super_case_id}/full/',
|
||
su_tok, expect=200, shrink='full')
|
||
|
||
w('\n' + '=' * 90)
|
||
w(' 内容管理员 - 病例库')
|
||
w('=' * 90)
|
||
|
||
body_content = case_payload('Swagger内容员病例(联调)')
|
||
r = call('90', 'CMS-CASE-3 表单新增(内容员)', 'POST', '/api/cms/cases/',
|
||
cu_tok, json_body=body_content, expect=201, shrink='create')
|
||
content_case_id = (r.json().get('case') or {}).get('id')
|
||
|
||
call('88', 'CMS-CASE-1 病例列表(内容员)', 'GET', '/api/cms/cases/',
|
||
cu_tok, params={'page': 1}, expect=200, shrink='list')
|
||
|
||
if not skip_ai:
|
||
call('89', 'CMS-CASE-2 PDF导入(内容员)', 'POST', '/api/cms/cases/import-pdf/',
|
||
cu_tok, files={'files': ('case.pdf', pdf_bytes, 'application/pdf')},
|
||
data={'case_type': 'traditional'}, expect=[200, 429, 502, 504], shrink='ai')
|
||
call('95', 'CMS-CASE-AI-1 AI生成(内容员)', 'POST', '/api/cms/cases/ai-generate/',
|
||
cu_tok, json_body={
|
||
'case_type': 'traditional',
|
||
'prompt': '请生成一个儿科发热传统病例,4岁男孩,发热3天。',
|
||
}, expect=[200, 429, 502, 504], shrink='ai')
|
||
else:
|
||
w('\n[跳过] 内容员 PDF/AI')
|
||
|
||
call('91', 'CMS-CASE-4 编辑关联(内容员)', 'POST', f'/api/cms/cases/{content_case_id}/relations/',
|
||
cu_tok, json_body={'department_id': d2_id}, expect=200)
|
||
|
||
call('92', 'CMS-CASE-5 提交(内容员)', 'POST', f'/api/cms/cases/{content_case_id}/submit/',
|
||
cu_tok, expect=200)
|
||
|
||
r_dis = call('_dis', '待停用(流程用)', 'POST', '/api/cms/cases/',
|
||
cu_tok, json_body=case_payload('Swagger待停用病例'), expect=201, shrink='create')
|
||
disable_id = (r_dis.json().get('case') or {}).get('id')
|
||
call('93', 'CMS-CASE-6 停用(内容员)', 'POST', f'/api/cms/cases/{disable_id}/disable/',
|
||
cu_tok, expect=200)
|
||
|
||
call('94', 'CMS-CASE-7 查看(内容员)', 'GET', f'/api/cms/cases/{content_case_id}/full/',
|
||
cu_tok, expect=200, shrink='full')
|
||
|
||
w('\n' + '=' * 90)
|
||
w(' 医院管理员 - 病例审核')
|
||
w('=' * 90)
|
||
|
||
call('96', 'CMS-AUDIT-1 待审核列表', 'GET', '/api/cms/cases/',
|
||
hu_tok, params={'publish_status': 1, 'page': 1}, expect=200, shrink='list')
|
||
|
||
call('97', 'CMS-AUDIT-2 查看病例', 'GET', f'/api/cms/cases/{content_case_id}/full/',
|
||
hu_tok, expect=200, shrink='full')
|
||
|
||
call('98', 'CMS-AUDIT-3 发布', 'POST', f'/api/cms/cases/{content_case_id}/publish/',
|
||
hu_tok, expect=200)
|
||
|
||
|
||
# 各行「说明」原文(CSV 第 9 列,update 时保持不变)
|
||
ROW_NOTES = {
|
||
'80': '仅超级管理员。返回全平台病例(可用?institution=按机构过滤)。已软删(下架)不返回。不可发布(归医院管理员 CMS-AUDIT-3)。未登录401;非超管403',
|
||
'81': '仅超级管理员。解析预览不落库,前端审核后调 CMS-CASE-3 入库时可传 institution_id 指定任意机构。case_type非法→400;非multipart→415;限流429;AI异常502/504;非超管403',
|
||
'82': '仅超级管理员。落库为草稿(publish_status=0),可指定任意 institution_id。错误:CASE_TYPE_NOT_SUPPORTED/CASE_SUBTYPE_REQUIRED/CASE_SUBTYPE_CONFLICT/CASE_VALIDATION_ERROR/CASE_FIELD_NOT_ALLOWED/CASE_INSTITUTION_NOT_FOUND/CASE_DEPARTMENT_NOT_FOUND/CASE_EXAM_ITEM_DUPLICATE;非超管403',
|
||
'83': '仅超级管理员。可改全平台任意病例的所属机构/科室,不改病例内容。三者都没传→400 CASE_VALIDATION_ERROR;机构/科室不存在→400;病例不存在/已软删→404;非超管403',
|
||
'84': '仅超级管理员。全平台任意病例:草稿(0)→正常(1),进入对应机构医院管理员审核队列。非草稿→400 CASE_NOT_SUBMITTABLE;病例不存在/已软删→404;非超管403',
|
||
'85': '仅超级管理员。全平台任意病例停用=下架=软删除(is_deleted=1,不物删)。下架后默认列表/查看不返回;病例不存在/已软删→404;非超管403',
|
||
'86': '仅超级管理员。可查看全平台任意病例完整结构。病例不存在/已软删→404;非超管403',
|
||
'87': '仅超级管理员。DeepSeek按病例模板(prompts/case_{type}_full.md)生成,不落库;前端审核后走 CMS-CASE-3 入库时可指定 institution_id。prompt空→400;限流429;AI异常500/502/504;非超管403',
|
||
'88': '仅内容管理员。仅返回本院(institution=登录用户所属机构)病例;他院不可见。?institution 参数无效(后端强制本院)。已软删不返回。不可审核发布(归医院管理员)。未登录401;非内容管理员403',
|
||
'89': '仅内容管理员。解析预览不落库,前端审核后调 CMS-CASE-3 入库时 institution 强制本院(忽略 institution_id)。case_type非法→400;非multipart→415;限流429;AI异常502/504;非内容管理员403',
|
||
'90': '仅内容管理员。新建强制 institution=本院(传 institution_id 亦忽略)。落库为草稿(publish_status=0)。错误同 CMS-CASE-3;非内容管理员403',
|
||
'91': '仅内容管理员。仅能操作本院病例;可改科室,机构锁定本院(传 institution_id 无效或不可改他院)。不改病例内容。他院病例→404;非内容管理员403',
|
||
'92': '仅内容管理员。仅本院病例:草稿(0)→正常(1),进入本院医院管理员审核队列。非草稿→400 CASE_NOT_SUBMITTABLE;他院/不存在/已软删→404;非内容管理员403',
|
||
'93': '仅内容管理员。仅本院病例停用=下架=软删除(is_deleted=1)。下架后默认列表/查看不返回;他院/不存在/已软删→404;非内容管理员403',
|
||
'94': '仅内容管理员。仅可查看本院病例完整结构。他院/不存在/已软删→404;非内容管理员403',
|
||
'95': '仅内容管理员。DeepSeek按病例模板生成,不落库;前端审核后走 CMS-CASE-3 入库时强制落本院。prompt空→400;限流429;AI异常500/502/504;非内容管理员403',
|
||
'96': '仅医院管理员。本院 publish_status=1(正常/待审核)病例列表,即 CMS-CASE-1 加 publish_status=1 且后端强制本院。超管/内容管理员请用各自病例列表接口。未登录401;非医院管理员403',
|
||
'97': '仅医院管理员。审核前查看本院待审核病例完整内容。他院/不存在/已软删→404;非医院管理员403',
|
||
'98': '仅医院管理员(超管/内容管理员均403)。本院病例:正常(1)→已发布(2),发布后对本院移动端医学生可见。非正常→400 CASE_NOT_PUBLISHABLE;他院/不存在/已软删→404',
|
||
}
|
||
|
||
# 各行 params 字段说明前缀(不含「实际示例」)
|
||
ROW_PARAMS_BASE = {
|
||
'80': '查询参数(均可选):search(标题/主诉/标签/ICD)、case_type、publish_status(0草稿/1正常/2已发布)、institution(机构ID,可按任意机构过滤)、department(科室ID)、status、osce_enabled、ordering、page;请求头:Authorization',
|
||
'81': '请求体(multipart/form-data):files=1~5份.pdf、case_type=traditional|teaching;请求头:Authorization',
|
||
'82': '请求体(JSON):title*、case_type*(traditional|teaching)、institution_id(可选,指定落库机构;缺省落创建者机构)、department_name、子表traditional|teaching*、scoring_rules*(≥1,dimension必填、score_weight∈(0,1])、exam_items(可选,item_code不重复)、difficulty/chief_complaint/description/patient_age/patient_gender/tags/icd_codes/estimated_minutes/osce_enabled…;不接收stages;请求头:Authorization',
|
||
'83': '路径参数:id;请求体(JSON,至少一项):institution_id(机构ID,null清空,可改任意机构)、department_id(科室ID,null清空) 或 department_name;请求头:Authorization',
|
||
'84': '路径参数:id(无Body);请求头:Authorization',
|
||
'85': '路径参数:id(无Body);请求头:Authorization',
|
||
'86': '路径参数:id;请求头:Authorization',
|
||
'87': '请求体(JSON):prompt*(病例长描述)、case_type*(traditional|teaching);请求头:Authorization',
|
||
'88': '查询参数(均可选):search、case_type、publish_status(0草稿/1正常/2已发布)、department(科室ID)、status、osce_enabled、ordering、page;请求头:Authorization',
|
||
'89': '请求体(multipart/form-data):files=1~5份.pdf、case_type=traditional|teaching;请求头:Authorization',
|
||
'90': '请求体(JSON):title*、case_type*(traditional|teaching)、department_name、子表traditional|teaching*、scoring_rules*(≥1,dimension必填、score_weight∈(0,1])、exam_items(可选,item_code不重复)、difficulty/chief_complaint/description/patient_age/patient_gender/tags/icd_codes/estimated_minutes/osce_enabled…;不接收institution_id/stages;请求头:Authorization',
|
||
'91': '路径参数:id;请求体(JSON,至少一项):department_id(科室ID,null清空) 或 department_name;请求头:Authorization',
|
||
'92': '路径参数:id(无Body);请求头:Authorization',
|
||
'93': '路径参数:id(无Body);请求头:Authorization',
|
||
'94': '路径参数:id;请求头:Authorization',
|
||
'95': '请求体(JSON):prompt*(病例长描述)、case_type*(traditional|teaching);请求头:Authorization',
|
||
'96': '查询参数:publish_status=1(正常=待审核)、search、department、page;请求头:Authorization',
|
||
'97': '路径参数:id;请求头:Authorization',
|
||
'98': '路径参数:id(无Body);请求头:Authorization',
|
||
}
|
||
|
||
|
||
def update_csv():
|
||
"""用 examples 回填 CSV 第 80~98 行的 params / response 列。"""
|
||
if not os.path.isfile(EXAMPLES_FILE):
|
||
w(f'样例文件不存在: {EXAMPLES_FILE}')
|
||
return False
|
||
with open(EXAMPLES_FILE, encoding='utf-8') as f:
|
||
ex = json.load(f)
|
||
|
||
rows = []
|
||
with open(CSV_PATH, encoding='utf-8', newline='') as f:
|
||
rows = list(csv.reader(f))
|
||
|
||
updated = 0
|
||
for i, row in enumerate(rows):
|
||
if not row or row[0] not in ex:
|
||
continue
|
||
row_num = row[0]
|
||
e = ex[row_num]
|
||
method = e.get('method', row[6] if len(row) > 6 else 'GET')
|
||
base_params = ROW_PARAMS_BASE.get(row_num, row[7] if len(row) > 7 else '')
|
||
req = e.get('req', '')
|
||
|
||
if method == 'GET' and req.startswith('?'):
|
||
new_params = f'{base_params} | 实际示例:{method} /{e["path"]}{req}'
|
||
elif req.startswith('multipart'):
|
||
new_params = f'{base_params} | 实际示例:{req}'
|
||
elif req.startswith('{') or req.startswith('['):
|
||
new_params = f'{base_params} | 实际示例:{req}'
|
||
elif '路径参数' in req or '无 Body' in req or 'Bearer' in req:
|
||
new_params = f'{base_params} | 实际示例:{method} /{e["path"]}'
|
||
else:
|
||
new_params = f'{base_params} | 实际示例:{req}'
|
||
|
||
new_resp = f'实际返回:{compact_json(e.get("resp", ""))}'
|
||
note = ROW_NOTES.get(row_num, row[9] if len(row) > 9 else '')
|
||
|
||
rows[i] = row[:7] + [new_params, new_resp, note, '1', row[11] if len(row) > 11 else '0']
|
||
updated += 1
|
||
w(f' CSV 行 #{row_num} 已更新 ({e.get("name")})')
|
||
|
||
with open(CSV_PATH, 'w', encoding='utf-8', newline='') as f:
|
||
csv.writer(f, quoting=csv.QUOTE_MINIMAL).writerows(rows)
|
||
w(f'\nCSV 更新完成:{updated} 行 -> {CSV_PATH}')
|
||
return updated > 0
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--skip-ai', action='store_true', help='跳过 PDF/AI(无 DeepSeek Key 时)')
|
||
parser.add_argument('--update-csv', action='store_true', help='测试后回填 docx/API - Sheet1.csv')
|
||
parser.add_argument('--csv-only', action='store_true', help='仅从已有 examples JSON 更新 CSV')
|
||
args = parser.parse_args()
|
||
|
||
w('=' * 90)
|
||
w(f' CMS 病例 Swagger 测试 {datetime.now():%Y-%m-%d %H:%M:%S}')
|
||
w(f' BASE={BASE}')
|
||
w('=' * 90)
|
||
|
||
if args.csv_only:
|
||
update_csv()
|
||
_fh.close()
|
||
return 0
|
||
|
||
try:
|
||
run_tests(skip_ai=args.skip_ai)
|
||
finally:
|
||
w('\n[清理] 删除测试病例与用户 ...')
|
||
cleanup()
|
||
|
||
with open(EXAMPLES_FILE, 'w', encoding='utf-8') as f:
|
||
json.dump(examples, f, ensure_ascii=False, indent=2)
|
||
|
||
w('\n' + '=' * 90)
|
||
total = len(results)
|
||
passed = sum(1 for _, ok, _ in results if ok == 'PASS')
|
||
w(f' 总计 {total} | 通过 {passed} | 失败 {total - passed}')
|
||
fails = [(c, s) for c, ok, s in results if ok == 'FAIL']
|
||
if fails:
|
||
w(' 失败: ' + ', '.join(f'{c}({s})' for c, s in fails))
|
||
w(f' 日志: {LOG_FILE}')
|
||
w(f' 样例: {EXAMPLES_FILE}')
|
||
|
||
if args.update_csv:
|
||
update_csv()
|
||
|
||
_fh.close()
|
||
return 0 if not fails else 1
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|