Files
medical_training/test/测试文档-D8.md
T
2026-06-05 15:36:31 +08:00

472 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D8 测试文档
> 测试日期:2026-05-29(单元测试);Swagger 2026-06-03;登录逻辑 v1.1 复测 2026-06-0528 场景)
> 测试人员:Claude AI + 人工审核
> 测试环境:Windows / Python 3.14 / Django 5.0 / MySQL 8 / Redis
> **v1.1 变更(2026-06-05**:登录拆分为「移动端 U4 / CMS 端 U3」。U3 改为账号(用户名或手机号)+密码+角色;U4 仅试用机构「北大医学部(实验室)试用」可自动注册,其它机构须 CMS 先录入学生且机构需匹配;新增机构列表接口 `GET /api/user/institution_list/`**U2 代注册收紧为仅超级管理员/医院管理员**(超管建所有角色、可任意机构;医院管理员建内容管理员/医生/学生、**仅限本机构**),代注册响应**不再返回 tokens**。新增测试文件 `test_login_mobile_cms.py`11 条);负向测试新增代注册权限/机构范围 6 条(N-REG1~6)。
---
## 1. 测试环境
| 项目 | 值 |
|---|---|
| Python | 3.14 |
| Django | 5.0+ |
| DRF | 3.14+ |
| 数据库 | MySQL 8 (test_medical_training) |
| 缓存 | Redis(与生产环境一致,`django_redis` |
| 运行命令 | `.venv\Scripts\python.exe manage.py test test -v2 --keepdb` |
---
## 2. 测试总览
| 类别 | 测试文件 | 用例数 | 通过 | 失败 |
|---|---|---|---|---|
| 用户域 happy-path | `test_user_happy.py` | 11 | 11 | 0 |
| 登录 v1.1(移动端/CMS | `test_login_mobile_cms.py` | 11 | 11 | 0 |
| 病例域 happy-path | `test_case_happy.py` | 2 | 2 | 0 |
| 用户域 negative | `test_user_negative.py` | 23 | 23 | 0 |
| 病例域 negative | `test_case_negative.py` | 12 | 12 | 0 |
| **合计** | | **59** | **59** | **0** |
---
## 3. Happy-Path 测试结果
### 3.1 用户域(11 条流程)
| ID | 测试方法 | 测试什么 | 结果 |
|---|---|---|---|
| HP-1 | `test_flow_register_login_me` | **管理员代注册 → CMS 密码登录**:管理员注册一个 CMS 角色(doctor)账号(手机号+姓名+机构,无需验证码,密码自动为 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 |
| HP-6 | `test_teacher_list_own_students_only` | **教师只看到自己的学生**:创建教师+自己的学生+别人的学生,教师调用用户列表,确认只能看到与自己有师生关系的学生,看不到别人的学生,也看不到自己 | PASS |
| HP-7 | `test_teacher_list_excludes_ended_relation` | **已结束关系的学生不可见**:教师名下有一个活跃学生和一个已毕业学生(关系状态=已结束),教师调用列表,确认只能看到活跃学生,已毕业的不显示 | PASS |
| HP-8 | `test_admin_retrieve_any_user` | **管理员查看任意用户详情**:管理员可以通过 /users/{id}/ 查看系统中任何用户的详细信息,确认返回的姓名正确 | PASS |
| HP-9 | `test_self_retrieve` | **用户查看自己的详情**:学生通过 /users/{自己的id}/ 查看自己的信息,确认能正常返回 | PASS |
| HP-10 | `test_teacher_retrieve_own_student` | **教师查看名下学生详情**:教师和学生建立师生关系后,教师可以查看该学生的详细信息 | PASS |
| HP-11 | `test_admin_list_filter_and_search` | **列表筛选和搜索**:创建管理员+张同学(学生)+李同学(学生)+张老师(教师),管理员用 `role_type=student&search=张` 筛选,确认只返回张同学(李同学不姓张被排除,张老师不是学生被排除) | PASS |
### 3.1.1 登录 v1.1(移动端 U4 / CMS 端 U3)— `test_login_mobile_cms.py`11 条)
| ID | 测试方法 | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|
| L-1 | `InstitutionListTest.test_list_unpaginated_with_trial_flag` | **机构列表不分页**:创建普通机构 + 试用机构,GET `institution_list` 返回数组,试用机构 `is_trial=true`、普通机构 `is_trial=false` | 200 | PASS |
| L-2 | `MobileLoginCodeTest.test_trial_first_register_then_login` | **试用机构首次注册→再次登录**:新手机号选试用机构,首次 `is_new_user=true`(自动建 student),二次 `is_new_user=false` | 201/200 | PASS |
| L-3 | `MobileLoginCodeTest.test_non_trial_unregistered_403` | **非试用未录入拒绝**:未录入手机号选普通机构登录,拒绝 `AUTH_NOT_REGISTERED` | 403 | PASS |
| L-4 | `MobileLoginCodeTest.test_non_trial_institution_mismatch_403` | **机构不匹配拒绝**:学生录入在机构 A,却选机构 B 登录,拒绝 `AUTH_INSTITUTION_MISMATCH` | 403 | PASS |
| L-5 | `MobileLoginCodeTest.test_non_trial_registered_match_ok` | **非试用已录入且机构匹配**:学生录入在机构 A 选机构 A 登录成功,`is_new_user=false` | 200 | PASS |
| L-6 | `MobileLoginCodeTest.test_unknown_institution_code` | **机构编码不存在**:传不存在的机构编码,`USER_INSTITUTION_NOT_FOUND` | 400 | PASS |
| L-7 | `CmsPasswordLoginTest.test_login_by_phone_ok` | **CMS 手机号登录**:doctor 用手机号+密码+角色登录成功 | 200 | PASS |
| L-8 | `CmsPasswordLoginTest.test_login_by_username_ok` | **CMS 用户名登录**super_admin(无手机号)用用户名+密码+角色登录成功 | 200 | PASS |
| L-9 | `CmsPasswordLoginTest.test_missing_role_400` | **缺少角色**:只传账号+密码不传角色,`AUTH_BAD_CREDENTIALS` | 400 | PASS |
| L-10 | `CmsPasswordLoginTest.test_invalid_role` | **非法角色**role=student(非 CMS 角色),`AUTH_INVALID_ROLE` | 400 | PASS |
| L-11 | `CmsPasswordLoginTest.test_role_mismatch` | **角色不符**doctor 账号传 role=content_admin,通用 `AUTH_BAD_CREDENTIALS`(不暴露真实角色) | 400 | PASS |
### 3.2 病例域(2 条流程)
| ID | 测试方法 | 测试什么 | 结果 |
|---|---|---|---|
| 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 |
---
## 4. Negative 测试结果
### 4.1 用户域(23 条)
| ID | 测试方法 | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|
| N1 | `test_rate_limit_sms_429` | **短信发送频率超限**:模拟 1 分钟内已发过验证码,再次请求发送时系统拒绝,返回"请求太频繁" | 429 | PASS |
| 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_missing_institution_400` | **注册缺少机构**:超管代注册时不传机构编码,系统拒绝并提示机构编码不能为空 | 400 | PASS |
| N6 | `test_register_duplicate_phone_400` | **手机号已被注册**:先创建一个用户,超管再用同一手机号注册,系统拒绝并提示该手机号已注册 | 400 | PASS |
| N7 | `test_login_wrong_password` | **密码错误**:CMS 账号用正确账号+角色但错误密码登录,系统拒绝并提示账号、密码或角色错误 | 400 | PASS |
| N8 | `test_login_account_lock_423` | **连续输错密码被锁定**:CMS 账号连续 5 次输入错误密码(账号+角色正确),第 6 次登录时系统锁定账号,返回"账号已锁定"(防暴力破解) | 423 | PASS |
| N9 | `test_reset_wrong_code` | **重置密码时验证码错误**:真实验证码是 123456,但提交 999999,系统拒绝并提示验证码不匹配 | 400/401 | PASS |
| N10 | `test_refresh_revoked_token_401` | **退出登录后 token 失效**:先退出登录(logout 会吊销 refresh token),再用那个已吊销的 refresh token 去刷新,系统拒绝 | 401 | PASS |
| N11 | `test_student_list_403` | **学生不能看用户列表**:学生角色调用用户列表接口,系统拒绝(只有管理员和教师才能看) | 403 | PASS |
| N12 | `test_doctor_list_403` | **医生不能看用户列表**:医生角色调用用户列表接口,系统同样拒绝(医生也没有列表权限) | 403 | PASS |
| N13 | `test_unauth_list_401` | **没登录不能看用户列表**:不带 token 调用用户列表接口,系统要求先登录 | 401 | PASS |
| N14 | `test_unauth_detail_401` | **没登录不能看用户详情**:不带 token 调用用户详情接口,系统要求先登录 | 401 | PASS |
| N15 | `test_student_view_other_student_403` | **学生不能看别人的详情**:学生 A 试图查看学生 B 的个人信息,系统拒绝(只能看自己的) | 403 | PASS |
| N16 | `test_teacher_view_unrelated_student_403` | **教师不能看非名下学生**:教师试图查看一个和自己没有师生关系的学生的信息,系统拒绝 | 403 | PASS |
| N17 | `test_teacher_view_ended_relation_student_403` | **教师不能看已毕业学生**:教师和学生的师生关系已结束(status=0,如学生已毕业),教师再查看该学生详情,系统拒绝 | 403 | PASS |
| N-REG1 | `test_register_unauth_401` | **未登录代注册**:不带 token 调用代注册接口,系统要求先登录 | 401 | PASS |
| N-REG2 | `test_register_non_admin_403` | **非管理员代注册**:医生(doctor)调用代注册,系统拒绝 `USER_NO_REGISTER_PERMISSION`(仅超管/医院管理员可代注册) | 403 | PASS |
| N-REG3 | `test_register_hospital_admin_cannot_create_super_admin_403` | **医院管理员越权建超管**:医院管理员尝试创建超级管理员,系统拒绝 `USER_NO_REGISTER_ROLE_PERMISSION`(只能建内容管理员/医生/学生) | 403 | PASS |
| N-REG4 | `test_register_hospital_admin_creates_student_ok` | **医院管理员在本机构建学生**:医院管理员创建学生账号成功,且新用户机构=管理员所属机构 | 201 | PASS |
| N-REG5 | `test_register_hospital_admin_cross_institution_403` | **医院管理员跨机构建账号**:医院管理员(属机构A)指定机构B建账号,系统拒绝 `USER_INSTITUTION_SCOPE_FORBIDDEN`(只能在本机构内) | 403 | PASS |
| N-REG6 | `test_register_hospital_admin_no_institution_403` | **无机构的医院管理员代注册**:医院管理员未归属任何机构时代注册,系统拒绝 `USER_NO_REGISTER_INSTITUTION` | 403 | PASS |
### 4.2 病例域(12 条)
| ID | 测试方法 | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|
| N10 | `test_invalid_case_type_400` | **病例类型不合法**:创建病例时 case_type 传 "invalid"(只支持 traditional 和 teaching),系统拒绝 | 400 | PASS |
| 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 |
| N18 | `test_rate_limit_pdf_parse_429` | **PDF 解析频率超限**:模拟用户短时间内已多次调用 PDF 解析,再次调用时系统拒绝并提示"请求太频繁" | 429 | PASS |
| N19 | `test_transaction_rollback` | **数据库事务回滚**:创建病例时模拟评分规则写入数据库失败(IntegrityError),验证病例主表也一起回滚(不会出现"有病例但没有评分规则"的残留数据) | 回滚成功 | PASS |
| N20 | `test_ai_bad_json_500` | **AI 返回乱码**:模拟 DeepSeek AI 返回的不是合法 JSON 格式,系统返回 500 并明确告知错误原因是 AI 输出异常 | 500 | PASS |
| N21 | `test_ai_schema_violation_500` | **AI 返回数据缺字段**:模拟 DeepSeek 返回了合法 JSON 但缺少必填字段(如 title),系统校验后返回 500 并提示 AI 输出不符合预期格式 | 500 | PASS |
---
## 5. Bug 修复记录
### Bug-1: 限流 mock 导致 500 而非 429
- **发现阶段**N1 `test_rate_limit_sms_429`
- **现象**mock `SmsPhoneMinuteThrottle.allow_request` 返回 `False` 后,DRF 调用 `throttle.wait()` 获取重试时间,但因 `allow_request` 被完整 mock`self.history` 未初始化,导致 `AttributeError` → 500
- **根因**DRF 的 `check_throttles``allow_request=False` 后会调用 `wait()` 方法读取 `self.history`mock 只替换了 `allow_request` 未处理 `wait`
- **修复**:同时 mock `wait` 方法返回固定值 `60`
- **影响文件**`test/test_user_negative.py``test/test_case_negative.py`
- **严重度**:低(仅影响测试代码,非业务代码 Bug)
### Bug-2: PDF mock 路径不正确
- **发现阶段**HP-6 `test_flow_pdf_mock_full_pipeline`
- **现象**mock `apps.case.services.pdf_reader.extract_text_from_pdfs` 无效,真实 PDF 解析仍被执行,伪造 PDF 内容触发 `CASE_PDF_EMPTY` 错误
- **根因**`case_importer.py` 使用 `from .pdf_reader import extract_text_from_pdfs` 直接导入函数,mock 必须 patch 导入位置 `apps.case.services.case_importer.extract_text_from_pdfs` 而非定义位置
- **修复**:更改 patch 路径为 `apps.case.services.case_importer.extract_text_from_pdfs`
- **影响文件**`test/test_case_happy.py``test/test_case_negative.py`
- **严重度**:低(仅影响测试代码,非业务代码 Bug)
### Bug-3: drf-spectacular 无法识别自定义认证类
- **发现阶段**:Swagger 文档生成时控制台日志
- **现象**:所有 ViewSet 和函数视图均产生 `Warning: could not resolve authenticator RedisBlacklistJWTAuthentication`,Swagger UI 上缺少认证标识和 Authorize 按钮
- **根因**`RedisBlacklistJWTAuthentication` 继承自 `JWTAuthentication`drf-spectacular 没有注册对应的 `OpenApiAuthenticationExtension`,不知道如何将其映射为 OpenAPI `securitySchemes`
- **修复**:创建 `apps/user/openapi.py`,定义 `RedisBlacklistJWTScheme` 扩展类,将该认证类映射为 `type: http, scheme: bearer, bearerFormat: JWT`;在 `apps/user/apps.py``ready()` 中 import 触发自动注册
- **影响文件**`apps/user/openapi.py`(新建)、`apps/user/apps.py`
- **严重度**:低(不影响接口功能,仅影响 Swagger 文档展示)
### Bug-4: 函数视图缺少 Swagger 请求/响应 Schema
- **发现阶段**:Swagger 文档生成时控制台日志
- **现象**`send_code``register``login_password``login_code``logout``reset_password` 6 个 `@api_view` 函数视图产生 `Error: unable to guess serializer`,Swagger UI 上这些接口没有请求体/响应体描述
- **根因**:函数视图直接读 `request.data`,未声明 `serializer_class`drf-spectacular 无法自动推断 schema
- **修复**:为 6 个函数视图添加 `@extend_schema` 装饰器,通过 `inline_serializer` 声明请求和响应字段;`refresh.py` 的类视图也加了 `@extend_schema(tags=['认证'])`
- **影响文件**`apps/user/auth/send_code.py``register.py``login.py``logout.py``reset_password.py``refresh.py`
- **严重度**:低(不影响接口功能,仅影响 Swagger 文档展示)
### Bug-5: 同名枚举冲突导致 Swagger 命名混乱
- **发现阶段**:Swagger 文档生成时控制台日志
- **现象**`case_type``status` 字段在不同 model/serializer 中有不同 choices 值集,drf-spectacular 自动生成带哈希后缀的名称(如 `CaseType629Enum``StatusDb0Enum`
- **根因**`CaseBase.CASE_TYPE_CHOICES`(4 值)与 C2/C3 内联序列化器 `ChoiceField(choices=['traditional','teaching'])`2 值)同名不同值;`CaseBase.STATUS_CHOICES` / `User.STATUS_CHOICES`(相同值)与 `TrainingRecord.STATUS_CHOICES` / `TeacherStudentRelation.STATUS_CHOICES`(不同值)同名不同值
- **修复**:在 `SPECTACULAR_SETTINGS['ENUM_NAME_OVERRIDES']` 中显式命名:`CaseTypeEnum`4 值)、`CreatableCaseTypeEnum`2 值)、`CommonStatusEnum``TrainingStatusEnum``TeacherStudentStatusEnum`
- **影响文件**`config/settings.py`
- **严重度**:极低(不影响接口功能,仅影响 Swagger 文档中枚举名称的可读性)
### Bug-6: 审计日志文件在 Windows 上写入失败
- **发现阶段**:手动检查 `logs/audit.log` 发现文件为空(0 字节)
- **现象**`TimedRotatingFileHandler` 在首次写入时尝试按日期轮转(rename `audit.log``audit.log.2026-05-27`),但 dev server 进程正占着文件,Windows 文件锁导致 `PermissionError: [WinError 32]`,轮转失败,日志丢失
- **根因**`TimedRotatingFileHandler` 的轮转机制依赖 `os.rename()`,Windows 上文件被其他进程打开时不允许 rename(Linux 无此问题)
- **修复**:自定义 `DailyFileHandler``config/logging_handlers.py`),直接按日期命名文件(`audit-YYYY-MM-DD.log`),日期切换时打开新文件,**不 rename 旧文件**,彻底避免文件锁问题;保留 `backup_count=30` 自动清理超过 30 天的旧日志
- **影响文件**`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 修复验证:**
```bash
# 修复前
Schema generation summary:
Warnings: 104 (20 unique)
Errors: 1 (1 unique)
# 修复后
Schema generation summary:
Warnings: 0
Errors: 0
```
- `python manage.py spectacular --validate --fail-on-warn` 退出码 0
**Bug-6 日志修复验证:**
```bash
# 修复前:audit.log 0 字节,PermissionError: [WinError 32]
# 修复后:audit-2026-05-29.log 正常写入 29 条审计记录
```
- 全部 41 条单元测试通过,业务逻辑零影响
---
## 6. .env.example 审计
对比 `config/settings.py` 中所有 `os.getenv()` 调用,确认 `.env.example` 覆盖完整:
| 环境变量 | 是否覆盖 | 备注 |
|---|---|---|
| DB_NAME / DB_USER / DB_PASSWORD / DB_HOST / DB_PORT | ✅ | |
| REDIS_URL | ✅ | |
| SMS_PROVIDER | ✅ | mock / aliyun |
| ALIYUN_SMS_ACCESS_KEY_ID / SECRET | ✅ | |
| ALIYUN_SMS_SIGN_NAME / TEMPLATE_* | ✅ | |
| DEEPSEEK_API_KEY | ✅ | |
| DEEPSEEK_BASE_URL / MODEL / TIMEOUT / MAX_RETRIES | ✅ | |
**安全修复**:已将 `.env.example` 中的真实密码和 API Key 替换为占位符:
- `DB_PASSWORD=your-db-password`
- `DEEPSEEK_API_KEY=your-deepseek-api-key`
---
## 7. 测试覆盖的接口清单
### 用户端
| 接口 | URL | happy-path | negative |
|---|---|---|---|
| U1 发送验证码 | POST /api/user/auth/send-code/ | HP-2,3 | N1(限流) |
| U2 管理员代注册 | POST /api/user/auth/register/ | HP-1 | N4,N5,N6 / N-REG1~6 |
| U3 密码登录(CMS) | POST /api/user/auth/login/ | HP-1,3,4 / L-7,8 | N7,N8 / L-9,10,11 |
| U4 验证码登录(移动端) | POST /api/user/auth/login-code/ | HP-2 / L-2,5 | L-3,4,6 |
| 机构列表(移动端) | GET /api/user/institution_list/ | L-1 | — |
| 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(辅助) |
| U8 刷新 Token | POST /api/user/auth/refresh/ | — | N10 |
| /me | GET /api/user/users/me/ | HP-1,2,4 | N3 |
| U9 用户列表 | GET /api/user/users/ | HP-5,6,7,11 | N11,N12,N13 |
| U10 用户详情 | GET /api/user/users/{id}/ | HP-8,9,10 | N14,N15,N16,N17 |
### 病例端
| 接口 | URL | happy-path | negative |
|---|---|---|---|
| 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(写入 `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 |
---
## 8. Swagger Try-it-out 接口验证
> **脚本**`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.0 与单元测试的差异
| 项目 | 单元测试(`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 回退手工载荷) |
**脚本内置测试数据**(每次运行前清理主测号,病例段会 `get_or_create` 机构/科室):
| 常量 | 值 | 用途 |
|---|---|---|
| `SUPER_PHONE` | `13700000090` | 超级管理员,执行 U2 代注册(`django_eval` 预置) |
| `PHONE` | `13700000099` | 主流程 CMS 用户(角色 `doctor`,走 U3 |
| `STUDENT_PHONE_U4` | `13700000097` | 已录入学生,走 U4(机构匹配,测完删除) |
| `PHONE_ALT` | `13700000098` | U4-new 试用机构自动注册(测完删除) |
| `INST_CODE` / `INST_NAME` | `SWAG_TEST_HOSP` / `Swagger测试医院` | U2 建机构、U4 所选机构 |
| `TRIAL_INST_CODE` / `TRIAL_INST_NAME` | `PKU_LAB_TRIAL` / `北大医学部(实验室)试用` | 试用机构(脚本预置) |
| `CMS_ROLE` | `doctor` | PHONE 用户的 CMS 登录角色 |
| `PASSWORD` | `Pass13700000099` | U2 默认密码、U3 登录 |
| `DEPT_NAME` | `Swagger儿科` | C3 科室名(避免库内多个「儿科」→ `CASE_DEPARTMENT_AMBIGUOUS` |
| `ADMIN/TEACHER/STUDENT_PHONE` | `13700000088/77/66` | U9/U10 角色夹具(`django_eval` 创建后删除;teacher 直接签发 token |
**执行顺序(用户端)**
```
INST-LIST(机构列表) → U1(login发码)
→ U2(超管代注册 doctor, 不返回 tokens) → U2-tok(校验无 tokens) → U2-neg1(未登录 401)
→ U3(CMS 账号+密码+角色登录) → U3-neg(缺角色 400) → U2-neg2(doctor 越权代注册 403)
→ U2-ha(医院管理员本机构 201) → U2-ha-neg(医院管理员跨机构 403)
→ U4(已录入学生+机构匹配 200) → U4-neg(非试用未录入 403)
→ 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 用户端(25 个接口/场景)
| ID | Method | URL | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|---|
| INST-LIST | GET | /api/user/institution_list/ | 不分页机构列表,含 `is_trial` 标识 | 200 | PASS |
| U1 | POST | /api/user/auth/send-code/ | `scene=login` 发码(未注册用户也可) | 200 | PASS |
| U2 | POST | /api/user/auth/register/ | **超管**代注册 doctor`phone`+`real_name`+`role_type`+机构字段,**无验证码**;默认密码 `Pass{phone}`**不返回 tokens** | 201 | PASS |
| U2-tok | CHECK | register 响应 | 响应体不含 `tokens` 字段 | 不含 | PASS |
| U2-neg1 | POST | /api/user/auth/register/ | 未登录代注册 → `AUTH_UNAUTHORIZED` | 401 | PASS |
| U3 | POST | /api/user/auth/login/ | CMS 登录:`account`+`password`+`role=doctor` | 200 | PASS |
| U3-neg | POST | /api/user/auth/login/ | 缺少 `role``AUTH_BAD_CREDENTIALS` | 400 | PASS |
| U2-neg2 | POST | /api/user/auth/register/ | doctor(非管理员)代注册 → `USER_NO_REGISTER_PERMISSION` | 403 | PASS |
| U2-ha | POST | /api/user/auth/register/ | 医院管理员在**本机构**建学生 → 201 | 201 | PASS |
| U2-ha-neg | POST | /api/user/auth/register/ | 医院管理员**跨机构**建账号 → `USER_INSTITUTION_SCOPE_FORBIDDEN` | 403 | PASS |
| U4 | POST | /api/user/auth/login-code/ | 已录入学生 + 机构匹配:`code` + `institution_code``is_new_user=false` | 200 | PASS |
| U4-neg | POST | /api/user/auth/login-code/ | 非试用机构 + 未录入手机号 → `AUTH_NOT_REGISTERED` | 403 | PASS |
| U4-pre | POST | /api/user/auth/send-code/ | 备用号 `13700000098` 发 login 码 | 200 | PASS |
| U4-new | POST | /api/user/auth/login-code/ | 试用机构 + 未注册备用号 → 自动注册 `is_new_user=true` | 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 病例端(5个接口+3个场景)
| 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 汇总与注意事项
- **总计 33个接口/场景**`medical_platform` 实跑;含 C3-exam / C3-db 库表校验),全部 PASS
- 用户端覆盖 v1.1 认证 API:机构列表、CMS 登录(账号+密码+角色、缺角色拒绝)、代注册权限与机构范围(超管 201 且无 tokens / 未登录 401 / doctor 越权 403 / 医院管理员本机构 201、跨机构 403)、移动端验证码登录(已录入学生+机构匹配、非试用未录入拒绝、试用机构自动注册)、重置/改密、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
# 全量单元测试(42 条)
.venv\Scripts\python.exe manage.py test test -v2 --keepdb
# 分模块运行
.venv\Scripts\python.exe manage.py test test.test_user_happy -v2 --keepdb
.venv\Scripts\python.exe manage.py test test.test_case_happy -v2 --keepdb
.venv\Scripts\python.exe manage.py test test.test_user_negative -v2 --keepdb
.venv\Scripts\python.exe manage.py test test.test_case_negative -v2 --keepdb
# 单个测试
.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,走 .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 缓存)
**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`
---
## 10. 测试日志记录
### 10.1 单元测试 — API 访问日志
通过 `APIAccessLogMiddleware``config/middleware.py`)自动记录所有 `/api/` 请求和响应到日志文件。
| 项目 | 说明 |
|---|---|
| 日志文件 | `logs/api-access-YYYY-MM-DD.log` |
| 记录内容 | 请求方法、路径、请求头、Content-Type、用户 ID、查询参数、状态码、耗时、请求体、响应体(完整原文,含 token、密码等) |
| 截断阈值 | 请求体/响应体超过 2000 字符自动截断 |
| Multipart 处理 | 提取表单字段+ 文件名和大小,不记录原始二进制内容 |
**注意**:单元测试使用 Django TestCase,每个测试结束后事务自动回滚,测试数据不会持久化到数据库。但中间件在视图执行过程中已将日志写入文件,因此日志是完整的。
### 10.2 Swagger 脚本 — 独立日志
`test/swagger_tryout.py` 将每个接口调用的完整请求体和响应体记录到独立日志文件(与 `api-access` 日志分离,便于对照 Swagger 手测)。
| 项目 | 说明 |
|---|---|
| 日志文件 | `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 日志(单元测试 — 管理员代注册)
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": {...}}
# 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. 测试结论
- ✅ 全部 **42 条** 单元测试通过(13 happy-path + 29 negative
-**25 个** Swagger Try-it-out 场景全部通过(含 C1 `exam_items` 解析、C3→`case_exam_item` 落库校验)
- ✅ 用户端 11 个接口功能正常(含 U9 用户列表、U10 用户详情的角色分级权限)
- ✅ 病例端 C1/C3 支持检查项;C3 与 `case_exam_item` 表写入已验证(`medical_platform`
- ✅ 限流、越权、字段校验、事务回滚、AI Schema 校验 均有覆盖
- ✅ U9/U10 权限矩阵验证:管理员全员可见、教师仅名下活跃学生、学生/医生 403、已结束关系 403
-`.env.example` 与代码完全一致,敏感信息已替换为占位符
- ✅ 测试过程中发现 8 个问题,均已修复(见第 5 节,Bug-7/8 为登录注册重构后 Review 发现)
- ✅ 完整的测试日志记录:单元测试 → API 访问日志,Swagger 脚本 → 独立日志文件