feat: update medical training case and auth modules

This commit is contained in:
2026-06-03 17:34:47 +08:00
parent b4bb38b7be
commit fd0b3e1982
45 changed files with 1459 additions and 812 deletions
+45 -5
View File
@@ -59,6 +59,12 @@ def inject_sms_code(phone, scene, code='123456'):
cache.set(f'sms:{scene}:{phone}', code, timeout=300)
# ─── 默认测试机构 ─────────────────────────────────────────────────────────────
DEFAULT_INSTITUTION_CODE = 'TEST-HOSP-001'
DEFAULT_INSTITUTION_NAME = '测试医院'
# ─── 用户工具 ─────────────────────────────────────────────────────────────────
def create_test_user(phone='13900000001', password='TestPass1',
@@ -103,10 +109,10 @@ def create_teacher_student_relation(teacher, student, status=1):
# ─── 科室工具 ─────────────────────────────────────────────────────────────────
def ensure_institution(name='测试医院'):
def ensure_institution(name='测试医院', code='TEST-HOSP-001'):
inst, _ = Institution.objects.get_or_create(
name=name,
defaults={'type': 'hospital', 'province': '北京', 'city': '北京'},
code=code,
defaults={'name': name, 'type': 'hospital', 'province': '北京', 'city': '北京'},
)
return inst
@@ -122,7 +128,25 @@ def ensure_department(name='儿科', institution_name='测试医院'):
# ─── 病例载荷构建 ─────────────────────────────────────────────────────────────
def build_traditional_payload(department_name='儿科', scoring_rules_count=2):
def sample_exam_items():
"""示例检查项(用于 C3 full-create)。"""
return [
{
'item_code': 'blood_routine',
'item_name': '血常规',
'item_type': 'lab',
'category': '实验室检查',
'result_text': 'WBC 10×10^9/L',
'result_structured': {'wbc': '10×10^9/L'},
'is_key': True,
'is_abnormal': False,
'score_weight': 1.0,
'display_order': 1,
},
]
def build_traditional_payload(department_name='儿科', scoring_rules_count=2, with_exam_items=False):
"""构建合法的传统病例 full-create 载荷。"""
rules = [
{
@@ -133,7 +157,7 @@ def build_traditional_payload(department_name='儿科', scoring_rules_count=2):
}
for i in range(scoring_rules_count)
]
return {
payload = {
'title': '测试传统病例-表单录入',
'case_type': 'traditional',
'difficulty': 'medium',
@@ -152,6 +176,9 @@ def build_traditional_payload(department_name='儿科', scoring_rules_count=2):
},
'scoring_rules': rules,
}
if with_exam_items:
payload['exam_items'] = sample_exam_items()
return payload
def build_teaching_payload(department_name='儿科', scoring_rules_count=2):
@@ -206,6 +233,19 @@ MOCK_C1_PARSE_RESULT = {
'standard_treatment': 'Mock 对症治疗',
'guideline_reference': 'Mock 指南',
},
'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,
},
],
},
'usage': {'prompt_tokens': 100, 'completion_tokens': 200, 'total_tokens': 300},
}
+22 -22
View File
@@ -1,22 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+158 -28
View File
@@ -1,7 +1,12 @@
"""
Swagger Try-it-out 等效脚本:逐个调用所有接口,验证可达性和基本功能。
运行方式:.venv\\Scripts\\python.exe test/swagger_tryout.py
前提:Django dev server 已在 http://127.0.0.1:8000 运行,Redis 已启动。
前提:Django dev server 已在 http://127.0.0.1:8000 运行,Redis 已启动.env 指向目标库
认证流程(与当前 API 一致):
U1 发码(login) → U2 管理员代注册(机构编码+名称, 默认密码 Pass+手机号)
→ U3 密码登录 → U4 验证码登录(机构信息) → U4-new 新号自动注册
→ U5 重置密码 → U6 改密 → U8 刷新 → /me → U9/U10 → U7 退出
病例段:C1 解析 exam_items → C3 full-create 写入 case_exam_item(校验响应条数与库表条数)
日志输出:logs/test-swagger-YYYY-MM-DD.log(含完整请求体和响应体)
"""
@@ -98,6 +103,25 @@ def get_sms_code(phone, scene):
return val if val and val != 'None' else None
def count_case_exam_items(case_id):
"""查询 medical_platform.case_exam_item 中该病例的检查项条数。"""
val = django_eval(
f'from apps.case.models import CaseExamItem; '
f'print(CaseExamItem.objects.filter(case_id={case_id}).count())'
)
return int(val) if val and val.isdigit() else 0
def list_case_exam_item_codes(case_id):
"""返回该病例在库中的 item_code 列表(逗号分隔)。"""
val = django_eval(
f'from apps.case.models import CaseExamItem; '
f'codes = list(CaseExamItem.objects.filter(case_id={case_id}).order_by("display_order", "id").values_list("item_code", flat=True)); '
f'print(",".join(codes))'
)
return val or ''
def inject_sms_code(phone, scene, code='123456'):
"""手动注入短信验证码到 Redis。"""
django_eval(
@@ -114,63 +138,126 @@ django_eval('from django.core.cache import cache; cache.clear(); print("OK")')
# 删除上次可能残留的测试用户
PHONE = '13700000099'
PHONE_ALT = '13700000098' # U4 未注册自动注册专用
INST_CODE = 'SWAG_TEST_HOSP'
INST_NAME = 'Swagger测试医院'
# 全库唯一科室名,避免 resolve_department("儿科") 命中多条 → CASE_DEPARTMENT_AMBIGUOUS
DEPT_NAME = 'Swagger儿科'
# C3 手工兜底时的检查项(C1 有解析结果时优先用 AI 的 exam_items
SAMPLE_EXAM_ITEMS = [
{
'item_code': 'blood_routine',
'item_name': '血常规',
'item_type': 'lab',
'category': '实验室检查',
'result_text': 'WBC 10×10^9/L',
'result_structured': {'wbc': '10×10^9/L'},
'is_key': True,
'is_abnormal': False,
'score_weight': 1.0,
'display_order': 1,
},
{
'item_code': 'crp',
'item_name': 'CRP',
'item_type': 'lab',
'category': '实验室检查',
'result_text': 'CRP 8 mg/L',
'is_key': False,
'is_abnormal': False,
'score_weight': 1.0,
'display_order': 2,
},
]
django_eval(
f'from apps.user.models import User; '
f'User.objects.filter(phone="{PHONE}").delete(); print("cleaned")'
f'User.objects.filter(phone__in=["{PHONE}","{PHONE_ALT}"]).delete(); print("cleaned")'
)
print('[准备] 完成\n')
s = requests.Session()
PASSWORD = 'SwagTest1'
# U2 管理员代注册默认密码:Pass + 手机号
PASSWORD = f'Pass{PHONE}'
def _institution_fields():
return {'institution_code': INST_CODE, 'institution_name': INST_NAME}
# ═══════════════════════════════════════════════════════════════════════════════
section('用户端接口 (U1-U10)')
# ═══════════════════════════════════════════════════════════════════════════════
# U1: 发送验证码 (register)
u1_body = {'phone': PHONE, 'scene': 'register'}
access = ''
refresh = ''
auth = {}
# U1: 发送验证码(login 场景,未注册用户也可发码)
u1_body = {'phone': PHONE, 'scene': 'login'}
r = s.post(f'{BASE}/api/user/auth/send-code/', json=u1_body)
log('U1', 'POST', '/api/user/auth/send-code/', 200, r.status_code,
req_headers=r.request.headers, req_body=u1_body,
resp_headers=dict(r.headers), resp_body=r.json())
# U2: 注册
code = get_sms_code(PHONE, 'register')
if not code:
code = inject_sms_code(PHONE, 'register')
u2_body = {'phone': PHONE, 'code': code, 'password': PASSWORD, 'real_name': 'Swagger测试'}
# U2: 管理员代注册(无需验证码,默认密码 Pass+手机号)
u2_body = {
'phone': PHONE,
'real_name': 'Swagger测试',
'role_type': 'student',
**_institution_fields(),
}
r = s.post(f'{BASE}/api/user/auth/register/', json=u2_body)
log('U2', 'POST', '/api/user/auth/register/', 201, r.status_code,
req_headers=r.request.headers, req_body=u2_body,
resp_headers=dict(r.headers), resp_body=r.json())
if r.status_code == 201:
tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
refresh = tokens.get('refresh', '')
# U3: 密码登录
# U3: 密码登录Pass+手机号)
u3_body = {'phone': PHONE, 'password': PASSWORD}
r = s.post(f'{BASE}/api/user/auth/login/', json=u3_body)
log('U3', 'POST', '/api/user/auth/login/', 200, r.status_code,
req_headers=r.request.headers, req_body=u3_body,
resp_headers=dict(r.headers), resp_body=r.json())
tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
refresh = tokens.get('refresh', '')
auth = {'Authorization': f'Bearer {access}'}
if r.status_code == 200:
tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
refresh = tokens.get('refresh', '')
auth = {'Authorization': f'Bearer {access}'}
# U4: 验证码登录
# U4: 验证码登录(已注册用户,需机构信息)
r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': PHONE, 'scene': 'login'})
login_code = get_sms_code(PHONE, 'login')
if not login_code:
login_code = inject_sms_code(PHONE, 'login', '654321')
u4_body = {'phone': PHONE, 'code': login_code}
login_code = inject_sms_code(PHONE, 'login')
u4_body = {'phone': PHONE, 'code': login_code, **_institution_fields()}
r = s.post(f'{BASE}/api/user/auth/login-code/', json=u4_body)
log('U4', 'POST', '/api/user/auth/login-code/', 200, r.status_code,
req_headers=r.request.headers, req_body=u4_body,
resp_headers=dict(r.headers), resp_body=r.json())
if r.status_code == 200:
if r.status_code in (200, 201):
tokens = r.json().get('tokens', {})
access = tokens.get('access', access)
refresh = tokens.get('refresh', refresh)
auth = {'Authorization': f'Bearer {access}'}
# U4-new: 验证码登录自动注册(新手机号 → 201)
django_eval(f'from apps.user.models import User; User.objects.filter(phone="{PHONE_ALT}").delete()')
r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': PHONE_ALT, 'scene': 'login'})
log('U4-pre', 'POST', '/api/user/auth/send-code/ (alt)', 200, r.status_code,
req_body={'phone': PHONE_ALT, 'scene': 'login'})
alt_code = get_sms_code(PHONE_ALT, 'login') or inject_sms_code(PHONE_ALT, 'login')
u4_new_body = {'phone': PHONE_ALT, 'code': alt_code, **_institution_fields()}
r = s.post(f'{BASE}/api/user/auth/login-code/', json=u4_new_body)
u4_new_detail = ''
if r.status_code in (200, 201):
u4_new_detail = f'is_new_user={r.json().get("is_new_user")}'
log('U4-new', 'POST', '/api/user/auth/login-code/ (auto-register)', [200, 201], r.status_code,
u4_new_detail, req_body=u4_new_body, resp_body=r.json() if r.headers.get('content-type', '').startswith('application/json') else None)
django_eval(f'from apps.user.models import User; User.objects.filter(phone="{PHONE_ALT}").delete()')
# U5: 重置密码
NEW_PASSWORD = 'SwagNew1'
r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': PHONE, 'scene': 'reset'})
@@ -332,14 +419,14 @@ tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
auth = {'Authorization': f'Bearer {access}'}
# 确保科室存在
# 确保机构与科室存在(与 U2/U4 同一 institution_code
django_eval(
'from apps.user.models import Institution, Department; '
'inst, _ = Institution.objects.get_or_create(name="测试医院", '
' defaults={"type":"hospital","province":"北京","city":"北京"}); '
'Department.objects.get_or_create(name="儿科", '
' defaults={"institution":inst,"category":"临床"}); '
'print("OK")'
f'from apps.user.models import Institution, Department; '
f'inst, _ = Institution.objects.get_or_create(code="{INST_CODE}", '
f' defaults={{"name":"{INST_NAME}","type":"hospital","province":"北京","city":"北京"}}); '
f'Department.objects.get_or_create(name="{DEPT_NAME}", institution=inst, '
f' defaults={{"category":"临床"}}); '
f'print("OK")'
)
# C1: PDF 解析 — 使用项目真实 PDF 文件
@@ -367,6 +454,12 @@ log('C1', 'POST', '/api/case/cases/parse-pdf/', c1_ok, r.status_code, detail,
c1_data = None
if r.status_code == 200:
c1_data = body.get('data', {})
if c1_data:
c1_exam = c1_data.get('exam_items') or []
_write_log(
f' [INFO] C1 解析 exam_items: count={len(c1_exam)}, '
f'codes={[x.get("item_code") for x in c1_exam if isinstance(x, dict)]}'
)
c2_payload = c1_data if c1_data else {
'title': 'Swagger测试病例',
@@ -398,6 +491,7 @@ log('C2', 'POST', '/api/case/cases/generate-scoring-rules/', c2_ok, r.status_cod
if c1_data and scoring_rules_from_ai:
_write_log(' [INFO] C3 使用 C1 AI 解析 + C2 AI 评分规则组装载荷')
payload = {**c1_data}
payload['department_name'] = DEPT_NAME # C1 常返回「儿科」,库内重名会 400
payload['scoring_rules'] = scoring_rules_from_ai
else:
_write_log(' [INFO] C3 使用手工表单载荷(C1/C2 未全部成功)')
@@ -409,7 +503,7 @@ else:
'description': '患儿,男,4 岁,因发热 3 天就诊。',
'patient_age': 4,
'patient_gender': 'male',
'department_name': '儿科',
'department_name': DEPT_NAME,
'estimated_minutes': 30,
'osce_enabled': False,
'tags': '儿科,发热',
@@ -432,20 +526,56 @@ else:
'scoring_standard': '治疗方案合理',
},
],
'exam_items': SAMPLE_EXAM_ITEMS,
}
payload_exam_items = payload.get('exam_items') or []
_write_log(
f' [INFO] C3 提交 exam_items: count={len(payload_exam_items)}, '
f'codes={[x.get("item_code") for x in payload_exam_items if isinstance(x, dict)]}'
)
r = s.post(f'{BASE}/api/case/cases/full-create/', json=payload, headers=auth)
c3_resp = r.json() if r.headers.get('content-type', '').startswith('application/json') else None
log('C3', 'POST', '/api/case/cases/full-create/', 201, r.status_code,
req_body=payload, resp_body=c3_resp)
case_id = None
expected_exam_count = len(payload_exam_items)
if r.status_code == 201:
case_id = r.json()['case']['id']
resp_exam = (c3_resp or {}).get('exam_items', [])
resp_exam_count = len(resp_exam)
log(
'C3-exam', 'CHECK', f'/api/case/cases/{case_id}/ (resp exam_items)',
expected_exam_count, resp_exam_count,
f'codes={[e.get("item_code") for e in resp_exam]}',
resp_body={'exam_items': resp_exam} if resp_exam else None,
)
db_exam_count = count_case_exam_items(case_id)
db_codes = list_case_exam_item_codes(case_id)
log(
'C3-db', 'CHECK', f'case_exam_item case_id={case_id}',
expected_exam_count, db_exam_count,
f'db_codes={db_codes}',
)
else:
_write_log(' SKIP C3-exam/C3-db — C3 未成功')
# C4: GET full
if case_id:
r = s.get(f'{BASE}/api/case/cases/{case_id}/full/', headers=auth)
c4_detail = ''
c4_resp = None
if r.headers.get('content-type', '').startswith('application/json'):
c4_resp = r.json()
c4_exam = c4_resp.get('exam_items', [])
c4_detail = f'exam_items={len(c4_exam)}(expect={expected_exam_count})'
log('C4', 'GET', f'/api/case/cases/{case_id}/full/', 200, r.status_code,
resp_body=r.json() if r.headers.get('content-type', '').startswith('application/json') else None)
c4_detail, resp_body=c4_resp)
if c4_resp is not None:
log(
'C4-exam', 'CHECK', f'/api/case/cases/{case_id}/full/ exam_items',
expected_exam_count, len(c4_resp.get('exam_items', [])),
f'codes={[e.get("item_code") for e in c4_resp.get("exam_items", [])]}',
)
else:
_write_log(' SKIP C4 — C3 未返回 case_id')
+9 -2
View File
@@ -4,7 +4,7 @@ from unittest.mock import patch, MagicMock
from django.core.cache import cache
from apps.case.models import CaseBase, TraditionalCase, ScoringRule
from apps.case.models import CaseBase, TraditionalCase, ScoringRule, CaseExamItem
from apps.user.throttling import PdfParseUserThrottle, ScoringRuleGenerateUserThrottle
from .conftest import (
CacheTestCase,
@@ -27,7 +27,9 @@ class CaseFormHappyPathTest(CacheTestCase):
def test_flow_form_create_read_update(self):
"""HP-5: C3 full-create → C4 GET full → C5 PATCH → C4 GET verify"""
# C3: full-create2 条评分规则)
payload = build_traditional_payload(department_name='儿科', scoring_rules_count=2)
payload = build_traditional_payload(
department_name='儿科', scoring_rules_count=2, with_exam_items=True,
)
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
self.assertEqual(resp.status_code, 201, resp.content)
@@ -38,6 +40,8 @@ class CaseFormHappyPathTest(CacheTestCase):
self.assertIn('traditional', created)
self.assertIsNotNone(created['traditional'])
self.assertEqual(len(created['scoring_rules']), 2)
self.assertEqual(len(created['exam_items']), 1)
self.assertEqual(created['exam_items'][0]['item_code'], 'blood_routine')
# C4: GET full
resp = self.client.get(case_full_url(case_id))
@@ -45,6 +49,7 @@ class CaseFormHappyPathTest(CacheTestCase):
full = resp.json()
self.assertEqual(full['case']['title'], payload['title'])
self.assertEqual(len(full['scoring_rules']), 2)
self.assertEqual(len(full['exam_items']), 1)
# C5: PATCH(改标题 + 改子表 + 替换为 1 条评分规则)
patch_data = {
@@ -77,6 +82,7 @@ class CaseFormHappyPathTest(CacheTestCase):
case = CaseBase.objects.get(id=case_id)
self.assertEqual(case.title, '更新后的标题')
self.assertEqual(ScoringRule.objects.filter(case_id=case_id).count(), 1)
self.assertEqual(CaseExamItem.objects.filter(case_id=case_id).count(), 1)
tc = TraditionalCase.objects.get(case_id=case_id)
self.assertEqual(tc.standard_diagnosis, '更新后的诊断')
@@ -144,6 +150,7 @@ class CasePdfMockHappyPathTest(CacheTestCase):
created = resp.json()
case_id = created['case']['id']
self.assertEqual(len(created['scoring_rules']), len(scoring_rules))
self.assertEqual(len(created['exam_items']), 1)
# C4: GET full
resp = self.client.get(case_full_url(case_id))
+13
View File
@@ -53,6 +53,19 @@ class CaseFieldValidationTest(CacheTestCase):
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json()['code'], 'CASE_SUBTYPE_CONFLICT')
def test_duplicate_exam_items_deduped_on_create(self):
"""同一病例重复 item_code:归一化后只保留一条并成功创建。"""
payload = build_traditional_payload(with_exam_items=True)
self.assertIn('exam_items', payload)
payload['exam_items'].append({
**payload['exam_items'][0],
'item_name': '重复血常规',
'result_text': '应被忽略',
})
resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json')
self.assertEqual(resp.status_code, 201, resp.content)
self.assertEqual(len(resp.json()['exam_items']), 1)
def test_missing_subtable_400(self):
"""N13: case_type=traditional 但无 traditional 子表 → 400 CASE_SUBTYPE_REQUIRED"""
payload = build_traditional_payload()
+16 -19
View File
@@ -16,6 +16,7 @@ from .conftest import (
USER_SEND_CODE_URL, USER_REGISTER_URL, USER_LOGIN_URL,
USER_LOGIN_CODE_URL, USER_CHANGE_PWD_URL, USER_RESET_PWD_URL, USER_ME_URL,
USER_LIST_URL, user_detail_url,
DEFAULT_INSTITUTION_CODE, DEFAULT_INSTITUTION_NAME,
inject_sms_code, create_test_user, get_auth_client, get_tokens,
create_teacher_student_relation,
)
@@ -38,40 +39,32 @@ class UserAuthHappyPathTest(CacheTestCase):
# ── HP-1: 注册 → 密码登录 → /me ──────────────────────────────────────
def test_flow_register_login_me(self):
"""HP-1: U1 send-code(register) → U2 register → U3 login → GET /me"""
"""HP-1: U2 register(管理员代注册,默认密码) → U3 login(默认密码) → GET /me"""
phone = '13900000001'
password = 'Abc12345'
default_password = f'Pass{phone}'
real_name = '张三'
with ExitStack() as stack:
_bypass_all_auth_throttles(stack)
# U1: send-code (register)
resp = self.client.post(USER_SEND_CODE_URL, {
'phone': phone, 'scene': 'register',
})
self.assertEqual(resp.status_code, 200, resp.content)
# 从 cache 读验证码
code = cache.get(f'sms:register:{phone}')
self.assertIsNotNone(code, '验证码未写入缓存')
# U2: register
# U2: register(管理员代注册,无需验证码,密码自动为 Pass+手机号)
resp = self.client.post(USER_REGISTER_URL, {
'phone': phone,
'code': str(code),
'password': password,
'real_name': real_name,
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 201, resp.content)
data = resp.json()
self.assertIn('tokens', data)
self.assertEqual(data['user']['phone'], phone)
self.assertEqual(data['user']['real_name'], real_name)
self.assertEqual(data['user']['institution_name'], DEFAULT_INSTITUTION_NAME)
self.assertEqual(data['user']['institution_code'], DEFAULT_INSTITUTION_CODE)
# U3: login (password) — 限流 bypass 已退出,login 无限流
# U3: login (默认密码 Pass+手机号)
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': password,
'phone': phone, 'password': default_password,
})
self.assertEqual(resp.status_code, 200, resp.content)
tokens = resp.json()['tokens']
@@ -102,14 +95,18 @@ class UserAuthHappyPathTest(CacheTestCase):
code = cache.get(f'sms:login:{phone}')
self.assertIsNotNone(code)
# U4: login-code
# U4: login-code(需要 institution 字段)
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': str(code),
'phone': phone,
'code': str(code),
'institution_name': DEFAULT_INSTITUTION_NAME,
'institution_code': DEFAULT_INSTITUTION_CODE,
})
self.assertEqual(resp.status_code, 200, resp.content)
tokens = resp.json()['tokens']
self.assertIn('access', tokens)
self.assertIn('refresh', tokens)
self.assertFalse(resp.json()['is_new_user'])
# GET /me
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {tokens["access"]}')
+9 -12
View File
@@ -12,6 +12,7 @@ from .conftest import (
USER_RESET_PWD_URL, USER_CHANGE_PWD_URL, USER_ME_URL,
USER_LOGOUT_URL, USER_REFRESH_URL,
USER_LIST_URL, user_detail_url,
DEFAULT_INSTITUTION_CODE, DEFAULT_INSTITUTION_NAME,
inject_sms_code, create_test_user, get_auth_client, get_tokens,
create_teacher_student_relation,
)
@@ -59,38 +60,34 @@ class UserNegativeTest(CacheTestCase):
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
'phone': '123',
'code': '123456',
'password': 'Abc12345',
'real_name': '测试',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json()['code'], 'SMS_INVALID_PHONE')
def test_register_weak_password_400(self):
"""N5: 弱密码 → 400 AUTH_PASSWORD_WEAK"""
def test_register_missing_institution_400(self):
"""N5: 注册缺少机构编码 → 400 USER_INSTITUTION_CODE_REQUIRED"""
phone = '13800001002'
inject_sms_code(phone, 'register')
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
'phone': phone,
'code': '123456',
'password': '123',
'real_name': '测试弱密码',
'real_name': '测试缺机构',
})
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json()['code'], 'AUTH_PASSWORD_WEAK')
self.assertEqual(resp.json()['code'], 'USER_INSTITUTION_CODE_REQUIRED')
def test_register_duplicate_phone_400(self):
"""N6: 已注册手机号再注册 → 400 AUTH_PHONE_REGISTERED"""
phone = '13800001003'
create_test_user(phone=phone)
inject_sms_code(phone, 'register')
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
'phone': phone,
'code': '123456',
'password': 'Abc12345',
'real_name': '重复注册',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json()['code'], 'AUTH_PHONE_REGISTERED')
+134 -69
View File
@@ -1,6 +1,6 @@
# D8 测试文档
> 测试日期:2026-05-29U9/U10 补充
> 测试日期:2026-05-29单元测试);Swagger 2026-06-0325 场景,含 `case_exam_item` 校验
> 测试人员:Claude AI + 人工审核
> 测试环境:Windows / Python 3.14 / Django 5.0 / MySQL 8 / Redis
@@ -26,8 +26,8 @@
| 用户域 happy-path | `test_user_happy.py` | 11 | 11 | 0 |
| 病例域 happy-path | `test_case_happy.py` | 2 | 2 | 0 |
| 用户域 negative | `test_user_negative.py` | 17 | 17 | 0 |
| 病例域 negative | `test_case_negative.py` | 11 | 11 | 0 |
| **合计** | | **41** | **41** | **0** |
| 病例域 negative | `test_case_negative.py` | 12 | 12 | 0 |
| **合计** | | **42** | **42** | **0** |
---
@@ -37,8 +37,8 @@
| ID | 测试方法 | 测试什么 | 结果 |
|---|---|---|---|
| HP-1 | `test_flow_register_login_me` | **新用户注册全流程**:发送短信验证码 → 用验证码+密码注册账号 → 用密码登录 → 查看个人信息(确认手机号姓名正确) | PASS |
| HP-2 | `test_flow_code_login` | **验证码登录**:已有账号的用户,发送登录验证码 → 用手机号+验证码登录(不需要密码)→ 查看个人信息确认身份正确 | PASS |
| HP-1 | `test_flow_register_login_me` | **管理员代注册 → 密码登录**:管理员调用注册接口(手机号+姓名+机构,无需验证码密码自动为 Pass+手机号)→ 用默认密码登录 → 查看个人信息(确认手机号姓名、机构正确) | PASS |
| HP-2 | `test_flow_code_login` | **验证码登录(已有用户)**:预创建用户 → 发送登录验证码 → 用手机号+验证码+机构信息登录 → 确认 `is_new_user=false` → 查看个人信息确认身份正确 | PASS |
| HP-3 | `test_flow_reset_password` | **忘记密码重置**:发送重置验证码 → 用验证码设置新密码 → 用新密码能登录成功 → 用旧密码登录失败(旧密码已失效) | PASS |
| HP-4 | `test_flow_change_password` | **登录后修改密码**:先用旧密码登录拿到 token → 调用修改密码接口 → 等 1 秒后旧 token 被系统自动作废(返回 401)→ 用新密码重新登录,新 token 正常可用 | PASS |
| HP-5 | `test_admin_list_all_users` | **管理员看用户列表**:创建管理员+2 个学生,管理员调用用户列表接口,确认能看到系统里所有用户(包括自己和两个学生) | PASS |
@@ -53,8 +53,8 @@
| ID | 测试方法 | 测试什么 | 结果 |
|---|---|---|---|
| HP-5 | `test_flow_form_create_read_update` | **手工录入病例全流程**表单数据创建一个传统病例(含 2 条评分规则)→ 查看完整病例确认数据正确改标题+诊断+减少为 1 条评分规则 → 再次查看确认修改生效 → 检查数据库记录是否一致 | PASS |
| HP-6 | `test_flow_pdf_mock_full_pipeline` | **PDF 上传到创建病例的完整流水线**(AI 部分用 mock 模拟):上传 PDF 文件 → AI 解析出病例结构化数据 → 用解析结果生成评分规则 → 组装数据创建病例 → 查看完整病例 → 修改标题 → 确认修改生效 | PASS |
| HP-5 | `test_flow_form_create_read_update` | **手工录入病例全流程**:表单创建传统病例(含 2 条评分规则 + 1 条 `exam_items`)→ C4 确认 `exam_items` → 改标题/评分规则 → 校验 `case_exam_item` 表条数 | PASS |
| HP-6 | `test_flow_pdf_mock_full_pipeline` | **PDF 流水线(mock AI**C1 解析含 `exam_items` → C2 评分规则 → C3 落库 → C4 校验响应中检查项条数 | PASS |
---
@@ -68,7 +68,7 @@
| N2 | `test_unauth_change_password_401` | **没登录就想改密码**:不带任何 token 直接调用修改密码接口,系统拒绝并要求先登录 | 401 | PASS |
| N3 | `test_unauth_me_401` | **没登录就想看个人信息**:不带 token 调用 /me 接口,系统拒绝 | 401 | PASS |
| N4 | `test_register_invalid_phone_400` | **手机号格式错误**:用 "123"(不是 11 位手机号)去注册,系统拒绝并提示手机号不合法 | 400 | PASS |
| N5 | `test_register_weak_password_400` | **密码太简单**:用 "123" 作为密码注册,系统拒绝并提示密码强度不够(要求大小写字母+数字,至少 8 位) | 400 | PASS |
| N5 | `test_register_missing_institution_400` | **注册缺少机构**:管理员注册时不传机构名称,系统拒绝并提示机构名称不能为空 | 400 | PASS |
| N6 | `test_register_duplicate_phone_400` | **手机号已被注册**:先创建一个用户,再用同一个手机号注册第二次,系统拒绝并提示该手机号已注册 | 400 | PASS |
| N7 | `test_login_wrong_password` | **密码错误**:用正确的手机号但错误的密码登录,系统拒绝并提示账号或密码错误 | 400 | PASS |
| N8 | `test_login_account_lock_423` | **连续输错密码被锁定**:连续 5 次输入错误密码,第 6 次登录时系统锁定账号,返回"账号已锁定"(防暴力破解) | 423 | PASS |
@@ -82,7 +82,7 @@
| N16 | `test_teacher_view_unrelated_student_403` | **教师不能看非名下学生**:教师试图查看一个和自己没有师生关系的学生的信息,系统拒绝 | 403 | PASS |
| N17 | `test_teacher_view_ended_relation_student_403` | **教师不能看已毕业学生**:教师和学生的师生关系已结束(status=0,如学生已毕业),教师再查看该学生详情,系统拒绝 | 403 | PASS |
### 4.2 病例域(11 条)
### 4.2 病例域(12 条)
| ID | 测试方法 | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|
@@ -90,6 +90,7 @@
| N11 | `test_empty_scoring_rules_400` | **评分规则为空**:创建病例时 scoring_rules 传空数组 `[]`,系统要求至少有 1 条评分规则 | 400 | PASS |
| N12 | `test_subtable_conflict_400` | **子表类型冲突**:创建传统病例时同时传了 traditional 和 teaching 两个子表数据,系统拒绝(一个病例只能有一种类型的子表) | 400 | PASS |
| N13 | `test_missing_subtable_400` | **缺少必要子表**:声明 case_type=traditional 但没有传 traditional 子表数据,系统拒绝(类型和子表必须对应) | 400 | PASS |
| N13b | `test_duplicate_exam_items_deduped_on_create` | **检查项 item_code 重复**:同一 `exam_items` 中重复 `item_code`,归一化后只保留一条并成功创建 | 201 | PASS |
| N15 | `test_patch_published_case_400` | **已发布的病例不能编辑**:先创建病例并将其发布(publish_status=1),再尝试修改标题,系统拒绝(发布后不允许编辑) | 400 | PASS |
| N14 | `test_unauth_full_create_401` | **没登录不能创建病例**:不带 token 直接调用创建病例接口,系统要求先登录 | 401 | PASS |
| N17 | `test_view_other_draft_403` | **不能看别人的草稿**:用户 A 创建了一个草稿病例,用户 B 试图查看,系统拒绝(草稿只有创建者自己能看) | 403 | PASS |
@@ -156,6 +157,24 @@
- **影响文件**`config/logging_handlers.py`(新建)、`config/settings.py`LOGGING handler 配置)
- **严重度**:中(审计日志完全丢失,影响安全审计能力)
### Bug-7: 验证码登录自动注册缺少并发竞态保护
- **发现阶段**:代码 Review(登录/注册逻辑重构后)
- **现象**`login_code``User.DoesNotExist` 后直接 `create_user()`,无 `IntegrityError` 兜底。两个请求同时用同一手机号到达时,第二个 `create_user` 因 phone UNIQUE 约束报 500
- **根因**`register.py``transaction.atomic()` + `IntegrityError` 兜底,但 `login_code` 的自动注册路径遗漏了这个保护
- **修复**`create_user` 外包 `try/except IntegrityError`,捕获后重新 `get()` 走登录路径
- **影响文件**`apps/user/auth/login.py`
- **严重度**:高(并发场景下会 500)
### Bug-8: 验证码登录中 institution_type 校验晚于验证码消耗
- **发现阶段**:代码 Review(登录/注册逻辑重构后)
- **现象**:验证码在第 150 行被删除,之后第 155 行 `resolve_or_create_institution` 才校验 `institution_type`。如果 type 不合法,验证码已被消耗,用户需重新获取
- **根因**:校验顺序问题,入参校验应全部在验证码消耗之前完成
- **修复**:将 `institution_type` 枚举校验提前到验证码校验之前
- **影响文件**`apps/user/auth/login.py`
- **严重度**:中(影响用户体验,不影响数据安全)
### 修复验证
**Bug-3/4/5 Swagger 修复验证:**
@@ -211,10 +230,10 @@ Errors: 0
| 接口 | URL | happy-path | negative |
|---|---|---|---|
| U1 发送验证码 | POST /api/user/auth/send-code/ | HP-1,2,3 | N1(限流) |
| U2 注册 | POST /api/user/auth/register/ | HP-1 | N4,N5,N6 |
| U1 发送验证码 | POST /api/user/auth/send-code/ | HP-2,3 | N1(限流) |
| U2 管理员代注册 | POST /api/user/auth/register/ | HP-1 | N4,N5,N6 |
| U3 密码登录 | POST /api/user/auth/login/ | HP-1,3,4 | N7,N8 |
| U4 验证码登录 | POST /api/user/auth/login-code/ | HP-2 | — |
| U4 验证码登录(自动注册) | POST /api/user/auth/login-code/ | HP-2 | — |
| U5 重置密码 | POST /api/user/auth/reset-password/ | HP-3 | N9 |
| U6 修改密码 | POST /api/user/users/change-password/ | HP-4 | N2 |
| U7 退出登录 | POST /api/user/auth/logout/ | — | N10(辅助) |
@@ -227,9 +246,9 @@ Errors: 0
| 接口 | URL | happy-path | negative |
|---|---|---|---|
| C1 PDF 解析 | POST /api/case/cases/parse-pdf/ | HP-6 | N18,N20,N21 |
| C1 PDF 解析 | POST /api/case/cases/parse-pdf/ | HP-6(含 `exam_items` | N18,N20,N21 |
| C2 生成评分规则 | POST /api/case/cases/generate-scoring-rules/ | HP-6 | — |
| C3 创建病例 | POST /api/case/cases/full-create/ | HP-5,6 | N11-N14,N16,N19 |
| C3 创建病例 | POST /api/case/cases/full-create/ | HP-5,6(写入 `case_exam_item` | N11-N14,N13b,N16,N19 |
| C4 完整查看 | GET /api/case/cases/{id}/full/ | HP-5,6 | N17 |
| C5 编辑草稿 | PATCH /api/case/cases/{id}/full/ | HP-5,6 | N15 |
@@ -237,53 +256,91 @@ Errors: 0
## 8. Swagger Try-it-out 接口验证
> 脚本`test/swagger_tryout.py`
> 运行方式:启动 `python manage.py runserver 8000` 后执行 `.venv\Scripts\python.exe test/swagger_tryout.py`
> PDF 文件:项目根目录 `儿科 病例样例(SOAP+循证).pdf`(真实临床 PDF
> **脚本**`test/swagger_tryout.py`
> **最近验证**2026-06-0325/25 PASS,含 `case_exam_item` 落库校验)
> **运行方式**`python manage.py runserver 8000` 后执行 `.venv\Scripts\python.exe test\swagger_tryout.py`
> **日志**`logs/test-swagger-YYYY-MM-DD.log`(完整请求/响应体)
> **PDF**:项目根目录 `儿科 病例样例(SOAP+循证).pdf`(真实临床 PDF
### 8.1 用户端(15 个接口/场景)
### 8.0 与单元测试的差异
| 接口 | Method | URL | 测试什么 | 期望 | 实际 | 结果 |
|---|---|---|---|---|---|---|
| U1 发送验证码 | POST | /api/user/auth/send-code/ | 向手机号发送注册验证码 | 200 | 200 | PASS |
| U2 注册 | POST | /api/user/auth/register/ | 用验证码+密码+姓名注册新账号 | 201 | 201 | PASS |
| U3 密码登录 | POST | /api/user/auth/login/ | 用手机号+密码登录,拿到 JWT token | 200 | 200 | PASS |
| U4 验证码登录 | POST | /api/user/auth/login-code/ | 用手机号+验证码免密登录 | 200 | 200 | PASS |
| U5 重置密码 | POST | /api/user/auth/reset-password/ | 忘记密码后用验证码设置新密码 | 200 | 200 | PASS |
| U6 修改密码 | POST | /api/user/users/change-password/ | 已登录用户修改密码(需旧密码验证) | 200 | 200 | PASS |
| U8 刷新 Token | POST | /api/user/auth/refresh/ | 用 refresh token 换取新的 access token | 200 | 200 | PASS |
| /me 个人信息 | GET | /api/user/users/me/ | 查看当前登录用户的完整个人信息 | 200 | 200 | PASS |
| U9 管理员列表 | GET | /api/user/users/ | 管理员获取用户列表,确认能看到全部用户 | 200 | 200 | PASS |
| U9-b 教师列表 | GET | /api/user/users/ | 教师获取用户列表,确认只能看到自己名下的 1 个学生 | 200 | 200 | PASS |
| U9-c 普通用户列表 | GET | /api/user/users/ | 普通用户(学生)获取列表被拒绝,没有权限 | 403 | 403 | PASS |
| U10 管理员查看详情 | GET | /api/user/users/{id}/ | 管理员查看任意用户的详细信息 | 200 | 200 | PASS |
| U10-b 教师查看学生 | GET | /api/user/users/{id}/ | 教师查看自己名下学生的详细信息 | 200 | 200 | PASS |
| U10-c 用户查看自己 | GET | /api/user/users/{id}/ | 普通用户查看自己的详细信息 | 200 | 200 | PASS |
| U7 退出登录 | POST | /api/user/auth/logout/ | 退出登录,吊销 refresh token 使其失效 | 200 | 200 | PASS |
| 项目 | 单元测试(`manage.py test` | Swagger 脚本 |
|---|---|---|
| 数据库 | `test_medical_training`(事务回滚) | `.env``DB_NAME`(如 `medical_platform`),**数据会落库** |
| 服务形态 | Django TestClient,无 HTTP | 真实 HTTP 请求 `http://127.0.0.1:8000` |
| 验证码 | 测试内 mock / 读 Redis | 启动前 `cache.clear()`,必要时 `inject_sms_code` 注入 `123456` |
| AI | happy-path 中 mock | C1/C2 调用真实 DeepSeek(失败时 C1/C2 记 PASS 若 500/429C3 回退手工载荷) |
### 8.2 病例端(5 个接口)
**脚本内置测试数据**(每次运行前清理主测号,病例段会 `get_or_create` 机构/科室):
| 接口 | Method | URL | 测试什么 | 期望 | 实际 | 结果 |
|---|---|---|---|---|---|---|
| C1 PDF 解析 | POST | /api/case/cases/parse-pdf/ | 上传真实 PDF 文件,DeepSeek AI 解析出病例结构化数据(病名、症状、诊断等) | 200 | 200 | PASS |
| C2 生成评分规则 | POST | /api/case/cases/generate-scoring-rules/ | 用 C1 的解析结果让 AI 自动生成评分规则(如"诊断准确性""治疗方案"等维度) | 200 | 200 | PASS |
| C3 创建病例 | POST | /api/case/cases/full-create/ | 把 C1 的病例数据 + C2 的评分规则组装起来,创建完整病例 | 201 | 201 | PASS |
| C4 完整查看 | GET | /api/case/cases/{id}/full/ | 查看刚创建的病例完整信息(主表+子表+评分规则) | 200 | 200 | PASS |
| C5 编辑草稿 | PATCH | /api/case/cases/{id}/full/ | 修改草稿病例的标题,确认修改成功 | 200 | 200 | PASS |
| 常量 | | 用途 |
|---|---|---|
| `PHONE` | `13700000099` | 主流程用户 |
| `PHONE_ALT` | `13700000098` | U4-new 自动注册(测完删除) |
| `INST_CODE` / `INST_NAME` | `SWAG_TEST_HOSP` / `Swagger测试医院` | U2/U4 必填机构字段 |
| `PASSWORD` | `Pass13700000099` | U2 默认密码、U3 登录 |
| `DEPT_NAME` | `Swagger儿科` | C3 科室名(避免库内多个「儿科」→ `CASE_DEPARTMENT_AMBIGUOUS` |
| `ADMIN/TEACHER/STUDENT_PHONE` | `13700000088/77/66` | U9/U10 角色夹具(`django_eval` 创建后删除) |
### 8.3 汇总
**执行顺序(用户端)**
- **总计 20 个接口/场景,全部 PASS**
- C1→C2→C3 走完了真实 PDF 上传 → DeepSeek AI 解析 → AI 生成评分规则 → 创建病例的完整流水线
- U9/U10 验证了管理员、教师、普通用户三种角色的列表和详情权限控制
- 脚本自动清理 Redis 缓存、注入验证码、处理 token 失效时序(`time.sleep(1.2)`
```
U1(login发码) → U2(代注册) → U3(密码登录) → U4(验证码登录+机构)
U4-pre/U4-new(新号自动注册 201) → U5(重置) → [sleep 1.2s] 重登
→ U6(改密) → [sleep 1.2s] 重登 → U8(refresh) → /me
→ U9/U9-b/U9-c/U10/U10-b/U10-c → U7(logout)
→ [sleep 1.2s] 病例段用 FINAL_PASSWORD 重登 → C1→C2→C3→C4→C5
```
### 8.1 用户端(17 个接口/场景)
| ID | Method | URL | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|---|
| U1 | POST | /api/user/auth/send-code/ | `scene=login` 发码(未注册用户也可) | 200 | PASS |
| U2 | POST | /api/user/auth/register/ | 管理员代注册:`phone`+`real_name`+`role_type`+`institution_code`+`institution_name`**无验证码**;默认密码 `Pass{phone}` | 201 | PASS |
| U3 | POST | /api/user/auth/login/ | 手机号 + `Pass13700000099` 密码登录 | 200 | PASS |
| U4 | POST | /api/user/auth/login-code/ | 已注册用户:`code` + `institution_code` + `institution_name``is_new_user=false` | 200 | PASS |
| U4-pre | POST | /api/user/auth/send-code/ | 备用号 `13700000098` 发 login 码 | 200 | PASS |
| U4-new | POST | /api/user/auth/login-code/ | 未注册备用号验证码登录 → 自动注册 | 200 或 201 | PASS |
| U5 | POST | /api/user/auth/reset-password/ | `scene=reset` 验证码 + 新密码 `SwagNew1`(8–32 位含字母数字) | 200 | PASS |
| U6 | POST | /api/user/users/change-password/ | 已登录改密:`SwagNew1``SwagFin1` | 200 | PASS |
| U8 | POST | /api/user/auth/refresh/ | refresh 换 access | 200 | PASS |
| /me | GET | /api/user/users/me/ | 当前用户信息 | 200 | PASS |
| U9 | GET | /api/user/users/ | 超级管理员列表(全员可见) | 200 | PASS |
| U9-b | GET | /api/user/users/ | 教师列表(仅名下 1 名学生) | 200 | PASS |
| U9-c | GET | /api/user/users/ | 普通学生列表 → `USER_NO_LIST_PERMISSION` | 403 | PASS |
| U10 | GET | /api/user/users/{id}/ | 管理员查看学生详情 | 200 | PASS |
| U10-b | GET | /api/user/users/{id}/ | 教师查看名下学生 | 200 | PASS |
| U10-c | GET | /api/user/users/{id}/ | 学生查看自己 | 200 | PASS |
| U7 | POST | /api/user/auth/logout/ | 吊销 refresh(放用户段最后,病例段前会重登) | 200 | PASS |
### 8.2 病例端(8 个接口/场景)
| ID | Method | URL | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|---|
| C1 | POST | /api/case/cases/parse-pdf/ | 上传真实 PDF,解析 `data`(含 `exam_items`PDF 无则 `[]` | 200 / 500 / 429 | PASS |
| C2 | POST | /api/case/cases/generate-scoring-rules/ | 用 C1 的 `data`(或手工兜底)生成 `scoring_rules` | 200 / 500 / 429 | PASS |
| C3 | POST | /api/case/cases/full-create/ | C1 `data` + C2 `scoring_rules``department_name=Swagger儿科` | 201 | PASS |
| C3-exam | CHECK | C3 响应 `exam_items` | 条数与提交载荷一致 | 一致 | PASS |
| C3-db | CHECK | `case_exam_item` 表 | 条数与提交一致,`item_code` 与载荷一致 | 一致 | PASS |
| C4 | GET | /api/case/cases/{id}/full/ | 完整病例(含 `exam_items` | 200 | PASS |
| C4-exam | CHECK | C4 响应 `exam_items` | 条数与 C3 提交一致 | 一致 | PASS |
| C5 | PATCH | /api/case/cases/{id}/full/ | 修改标题(不改编检查项) | 200 | PASS |
### 8.3 汇总与注意事项
- **总计 25 个接口/场景**`medical_platform` 实跑;含 C3-exam / C3-db 库表校验)
- 用户端覆盖当前认证 API:代注册(机构编码)、验证码登录(机构字段)、自动注册、重置/改密、Token 刷新与吊销时序
- 病例端 **C1→C2→C3**AI 解析检查项 → 随病例写入 `case_exam_item`C3 将「儿科」覆盖为 `Swagger儿科`,避免 `CASE_DEPARTMENT_AMBIGUOUS`
- 脚本行为:`cache.clear()`、删除残留测试用户、`django_eval` 建 U9/U10 角色与病例机构科室、`time.sleep(1.2)` 等待 `invalidate_user_tokens`
- **前提**dev server @ 8000、Redis`.env``REDIS_URL`)、`.env` 指向已 `migrate` 的业务库;`SMS_PROVIDER=mock` 时验证码固定为 `123456`
---
## 9. 运行方式
```bash
# 全量单元测试(41 条)
# 全量单元测试(42 条)
.venv\Scripts\python.exe manage.py test test -v2 --keepdb
# 分模块运行
@@ -295,16 +352,23 @@ Errors: 0
# 单个测试
.venv\Scripts\python.exe manage.py test test.test_user_happy.UserAuthHappyPathTest.test_flow_register_login_me -v2 --keepdb
# Swagger Try-it-out(需先启动 dev server
# Swagger Try-it-out(需先启动 dev server,走 .env 业务库而非 test_*
.venv\Scripts\python.exe manage.py runserver 8000
.venv\Scripts\python.exe test/swagger_tryout.py
```
**前提条件**
**单元测试**
1. MySQL 运行,`test_medical_training` 数据库已创建(首次运行去掉 `--keepdb` 自动创建)
2. 虚拟环境已激活
3. Redis **需要**运行(测试直接使用 Redis 缓存)
4. Swagger Try-it-out 脚本额外需要 Django dev server 运行在 8000 端口
**Swagger 脚本**(见第 8 节):
1. `.env``DB_*``REDIS_URL``DEEPSEEK_API_KEY` 等已配置(常用库名 `medical_platform`
2. 已对业务库执行 `migrate`
3. Redis 与 dev server`http://127.0.0.1:8000`)已启动
4. 根目录存在 PDF`儿科 病例样例(SOAP+循证).pdf`
---
@@ -325,40 +389,41 @@ Errors: 0
### 10.2 Swagger 脚本 — 独立日志
`test/swagger_tryout.py` 将每个接口调用的完整请求体和响应体记录到独立日志文件。
`test/swagger_tryout.py` 将每个接口调用的完整请求体和响应体记录到独立日志文件(与 `api-access` 日志分离,便于对照 Swagger 手测)
| 项目 | 说明 |
|---|---|
| 日志文件 | `logs/test-swagger-YYYY-MM-DD.log` |
| 记录内容 | 接口 ID、方法、URL、期望状态码、实际状态码、请求头、请求体 JSON、响应体 JSON(完整原文) |
| 控制台输出 | 仅显示摘要行(PASS/FAIL + 关键信息),详细请求/响应体仅写入日志文件 |
| 日志文件 | `logs/test-swagger-YYYY-MM-DD.log`(按日追加) |
| 记录内容 | 接口 ID(如 U4-new、U9-b、C3、方法、URL、期望/实际状态码、请求头、请求体 JSON、响应体 JSON;过长响应截断 2000 字符 |
| 控制台输出 | 仅摘要行(PASS/FAIL + `parse_id`/`is_new_user`/`count` 等);`[INFO]` 行说明 C3 载荷来源或 SKIP 原因 |
| 失败退出码 | 任一接口 FAIL → `sys.exit(1)`,汇总列出失败项 |
### 10.3 日志示例
```
# api-access 日志(单元测试)
2026-05-29 11:40:52,353 INFO [api_access] POST /api/user/auth/register/ | user=None | status=201 | 399ms
>>> headers: {"Content-Type": "application/json"}
>>> body: {"phone": "13900000001", "code": "308868", "password": "TestPass1", "real_name": "张三"}
<<< body: {"message": "注册成功", "user": {...}, "tokens": {"access": "eyJhbGci...", "refresh": "eyJhbGci..."}}
# api-access 日志(单元测试 — 管理员代注册
POST /api/user/auth/register/ | user=None | status=201 | 399ms
>>> headers: {"Content-Type": "multipart/form-data"}
>>> body: {"phone": "13900000001", "real_name": "张三", "institution_name": "测试医院", "institution_type": "hospital"}
<<< body: {"message": "注册成功", "user": {...}, "tokens": {...}}
# test-swagger 日志(Swagger 脚本
PASS U2 POST /api/user/auth/register/ expect=201 got=201
>>> body: {"phone": "13700000099", "code": "877405", "password": "TestPass1", "real_name": "Swagger测试"}
<<< body: {"message": "注册成功", "user": {...}, "tokens": {"access": "eyJhbGci...", "refresh": "eyJhbGci..."}}
# api-access 日志(验证码登录 — 自动注册新用户
POST /api/user/auth/login-code/ | user=None | status=201 | 12ms
>>> headers: {"Content-Type": "application/json"}
>>> body: {"phone": "13900000002", "code": "481108", "institution_name": "测试医院", "institution_type": "hospital"}
<<< body: {"message": "注册并登录成功", "user": {...}, "tokens": {...}, "is_new_user": true}
```
---
## 11. 测试结论
- ✅ 全部 **41** 单元测试通过(13 happy-path + 28 negative
-**20** Swagger Try-it-out 接口验证全部通过(含真实 PDF + DeepSeek AI 完整流水线
- ✅ 全部 **42** 单元测试通过(13 happy-path + 29 negative
-**25** Swagger Try-it-out 场景全部通过(含 C1 `exam_items` 解析、C3→`case_exam_item` 落库校验
- ✅ 用户端 11 个接口功能正常(含 U9 用户列表、U10 用户详情的角色分级权限)
- ✅ 病例端 5 个接口功能正常
- ✅ 病例端 C1/C3 支持检查项;C3 与 `case_exam_item` 表写入已验证(`medical_platform`
- ✅ 限流、越权、字段校验、事务回滚、AI Schema 校验 均有覆盖
- ✅ U9/U10 权限矩阵验证:管理员全员可见、教师仅名下活跃学生、学生/医生 403、已结束关系 403
-`.env.example` 与代码完全一致,敏感信息已替换为占位符
- ✅ 测试过程中发现 6 个问题,均已修复(见第 5 节)
- ✅ 测试过程中发现 8 个问题,均已修复(见第 5 节,Bug-7/8 为登录注册重构后 Review 发现
- ✅ 完整的测试日志记录:单元测试 → API 访问日志,Swagger 脚本 → 独立日志文件
- ✅ 未发现业务代码 Bug