feat: update login api

This commit is contained in:
2026-06-05 15:36:31 +08:00
parent fd0b3e1982
commit ba9fb33062
15 changed files with 714 additions and 163 deletions
+4 -1
View File
@@ -37,6 +37,7 @@ USER_RESET_PWD_URL = '/api/user/auth/reset-password/'
USER_CHANGE_PWD_URL = '/api/user/users/change-password/'
USER_ME_URL = '/api/user/users/me/'
USER_LIST_URL = '/api/user/users/'
USER_INSTITUTION_LIST_URL = '/api/user/institution_list/'
# 病例
CASE_PARSE_URL = '/api/case/cases/parse-pdf/'
@@ -68,7 +69,8 @@ DEFAULT_INSTITUTION_NAME = '测试医院'
# ─── 用户工具 ─────────────────────────────────────────────────────────────────
def create_test_user(phone='13900000001', password='TestPass1',
real_name='测试用户', role_type='student', status=1):
real_name='测试用户', role_type='student', status=1,
institution=None):
"""创建测试用户(已知密码),返回 User 实例。"""
user = User.objects.create_user(
username=phone,
@@ -77,6 +79,7 @@ def create_test_user(phone='13900000001', password='TestPass1',
real_name=real_name,
role_type=role_type,
status=status,
institution=institution,
)
return user
+145 -41
View File
@@ -137,10 +137,17 @@ print('\n[准备] 清理 Redis 缓存...')
django_eval('from django.core.cache import cache; cache.clear(); print("OK")')
# 删除上次可能残留的测试用户
PHONE = '13700000099'
PHONE_ALT = '13700000098' # U4 未注册自动注册专用
SUPER_PHONE = '13700000090' # 超级管理员,执行 U2 代注册
SUPER_PWD = 'Super123'
PHONE = '13700000099' # CMS 用户(doctor),走 U3 密码登录
STUDENT_PHONE_U4 = '13700000097' # 已录入学生,走 U4 验证码登录(非试用机构需机构匹配)
PHONE_ALT = '13700000098' # U4 试用机构自动注册专用
INST_CODE = 'SWAG_TEST_HOSP'
INST_NAME = 'Swagger测试医院'
# 预留试用机构(与 apps.user.auth 常量保持一致)
TRIAL_INST_CODE = 'PKU_LAB_TRIAL'
TRIAL_INST_NAME = '北大医学部(实验室)试用'
CMS_ROLE = 'doctor' # PHONE 用户的 CMS 角色
# 全库唯一科室名,避免 resolve_department("儿科") 命中多条 → CASE_DEPARTMENT_AMBIGUOUS
DEPT_NAME = 'Swagger儿科'
# C3 手工兜底时的检查项(C1 有解析结果时优先用 AI 的 exam_items
@@ -171,7 +178,20 @@ SAMPLE_EXAM_ITEMS = [
]
django_eval(
f'from apps.user.models import User; '
f'User.objects.filter(phone__in=["{PHONE}","{PHONE_ALT}"]).delete(); print("cleaned")'
f'User.objects.filter(phone__in=["{SUPER_PHONE}","{PHONE}","{STUDENT_PHONE_U4}","{PHONE_ALT}"]).delete(); print("cleaned")'
)
# 预置超级管理员(U2 代注册需管理员身份)
django_eval(
f'from apps.user.models import User; '
f'User.objects.create_user(username="{SUPER_PHONE}", password="{SUPER_PWD}", '
f' phone="{SUPER_PHONE}", real_name="Swagger超管", role_type="super_admin", status=1); '
f'print("super ok")'
)
# 预置试用机构(仅增数据,不改表)
django_eval(
f'from apps.user.models import Institution; '
f'Institution.objects.get_or_create(code="{TRIAL_INST_CODE}", '
f' defaults={{"name":"{TRIAL_INST_NAME}","type":"hospital"}}); print("trial ok")'
)
print('[准备] 完成\n')
@@ -184,6 +204,16 @@ def _institution_fields():
return {'institution_code': INST_CODE, 'institution_name': INST_NAME}
def get_user_access_token(phone):
"""直接为指定用户签发 access token(用于非 CMS 角色,如 teacher 无法走 U3)。"""
return django_eval(
f'from apps.user.models import User; '
f'from rest_framework_simplejwt.tokens import RefreshToken; '
f'u=User.objects.get(phone="{phone}"); '
f'print(str(RefreshToken.for_user(u).access_token))'
)
# ═══════════════════════════════════════════════════════════════════════════════
section('用户端接口 (U1-U10)')
# ═══════════════════════════════════════════════════════════════════════════════
@@ -192,31 +222,51 @@ access = ''
refresh = ''
auth = {}
# U1: 发送验证码(login 场景,未注册用户也可发码
# INST-LIST: 移动端机构列表(不分页,登录前可调用
r = s.get(f'{BASE}/api/user/institution_list/')
inst_list_detail = ''
if r.status_code == 200:
items = r.json()
trial_flags = [i for i in items if i.get('is_trial')]
inst_list_detail = f'count={len(items)}, trial={len(trial_flags)}'
log('INST-LIST', 'GET', '/api/user/institution_list/', 200, r.status_code, inst_list_detail,
req_headers=r.request.headers, resp_headers=dict(r.headers),
resp_body=r.json() if r.headers.get('content-type', '').startswith('application/json') else None)
# 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: 管理员代注册(无需验证码,默认密码 Pass+手机号)
# U2: 管理员代注册(仅超管/医院管理员)。超管创建 CMS 角色 doctor,并自动创建机构
super_access = get_user_access_token(SUPER_PHONE)
super_auth = {'Authorization': f'Bearer {super_access}'}
u2_body = {
'phone': PHONE,
'real_name': 'Swagger测试',
'role_type': 'student',
'role_type': CMS_ROLE,
**_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,
r = s.post(f'{BASE}/api/user/auth/register/', json=u2_body, headers=super_auth)
u2_detail = 'has_tokens=%s' % ('tokens' in (r.json() if r.headers.get('content-type','').startswith('application/json') else {}))
log('U2', 'POST', '/api/user/auth/register/ (super_admin)', 201, r.status_code, u2_detail,
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: 密码登录(Pass+手机号
u3_body = {'phone': PHONE, 'password': PASSWORD}
# U2-tok: 代注册不返回 tokens(按管理员代注册语义
u2_has_tokens = 'tokens' in (r.json() if r.headers.get('content-type','').startswith('application/json') else {})
log('U2-tok', 'CHECK', 'register response has no tokens', False, u2_has_tokens)
# U2-neg1: 未登录代注册 → 401
r = s.post(f'{BASE}/api/user/auth/register/', json=u2_body)
log('U2-neg1', 'POST', '/api/user/auth/register/ (anon)', 401, r.status_code,
f'code={r.json().get("code","")}' if r.headers.get("content-type","").startswith("application/json") else "",
req_body={'phone': PHONE, '...': '...'}, resp_body=r.json() if r.headers.get('content-type','').startswith('application/json') else None)
# U3: CMS 密码登录(账号 + 密码 + 角色,三者必填)
u3_body = {'account': PHONE, 'password': PASSWORD, 'role': CMS_ROLE}
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,
@@ -227,36 +277,90 @@ if r.status_code == 200:
refresh = tokens.get('refresh', '')
auth = {'Authorization': f'Bearer {access}'}
# 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')
u4_body = {'phone': PHONE, 'code': login_code, **_institution_fields()}
# U3-neg: 缺少角色 → 400 AUTH_BAD_CREDENTIALS
r = s.post(f'{BASE}/api/user/auth/login/', json={'account': PHONE, 'password': PASSWORD})
log('U3-neg', 'POST', '/api/user/auth/login/ (no role)', 400, r.status_code,
f'code={r.json().get("code","")}', req_body={'account': PHONE, 'password': '***'},
resp_body=r.json())
# U2-neg2: 非管理员(doctor)代注册 → 403 USER_NO_REGISTER_PERMISSION
r = s.post(f'{BASE}/api/user/auth/register/',
json={'phone': '13700000095', 'real_name': '越权注册', 'role_type': 'student',
**_institution_fields()}, headers=auth)
log('U2-neg2', 'POST', '/api/user/auth/register/ (doctor)', 403, r.status_code,
f'code={r.json().get("code","")}' if r.headers.get("content-type","").startswith("application/json") else "",
resp_body=r.json() if r.headers.get('content-type','').startswith('application/json') else None)
# U2-ha / U2-ha-neg: 医院管理员仅能在本机构内代注册
HA_PHONE = '13700000094'
HA_STUDENT = '13700000093'
django_eval(
f'from apps.user.models import User, Institution; '
f'inst=Institution.objects.get(code="{INST_CODE}"); '
f'User.objects.filter(phone__in=["{HA_PHONE}","{HA_STUDENT}"]).delete(); '
f'User.objects.create_user(username="{HA_PHONE}", password="Hosp1234", phone="{HA_PHONE}", '
f' real_name="Swagger医院管理员", role_type="hospital_admin", institution=inst, status=1); '
f'print("hadmin ok")'
)
ha_auth = {'Authorization': f'Bearer {get_user_access_token(HA_PHONE)}'}
# U2-ha: 本机构建学生 → 201
ha_body = {'phone': HA_STUDENT, 'real_name': '本院学生', 'role_type': 'student', **_institution_fields()}
r = s.post(f'{BASE}/api/user/auth/register/', json=ha_body, headers=ha_auth)
log('U2-ha', 'POST', '/api/user/auth/register/ (hospital_admin own inst)', 201, r.status_code,
req_body=ha_body, resp_body=r.json() if r.headers.get('content-type','').startswith('application/json') else None)
# U2-ha-neg: 跨机构建账号 → 403 USER_INSTITUTION_SCOPE_FORBIDDEN
ha_neg_body = {'phone': '13700000092', 'real_name': '跨院学生', 'role_type': 'student',
'institution_code': 'SWAG_OTHER_HOSP', 'institution_name': 'Swagger其它医院'}
r = s.post(f'{BASE}/api/user/auth/register/', json=ha_neg_body, headers=ha_auth)
log('U2-ha-neg', 'POST', '/api/user/auth/register/ (hospital_admin cross inst)', 403, r.status_code,
f'code={r.json().get("code","")}' if r.headers.get("content-type","").startswith("application/json") else "",
req_body=ha_neg_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__in=["{HA_PHONE}","{HA_STUDENT}"]).delete()')
# U4: 验证码登录(移动端,已录入学生 + 机构匹配 → 200)
# 预创建学生,institution 与所选机构一致
django_eval(
f'from apps.user.models import User, Institution; '
f'inst=Institution.objects.get(code="{INST_CODE}"); '
f'User.objects.filter(phone="{STUDENT_PHONE_U4}").delete(); '
f'User.objects.create_user(username="{STUDENT_PHONE_U4}", password=None, '
f' phone="{STUDENT_PHONE_U4}", real_name="Swagger学生U4", role_type="student", '
f' institution=inst, status=1); print("student ok")'
)
r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': STUDENT_PHONE_U4, 'scene': 'login'})
login_code = get_sms_code(STUDENT_PHONE_U4, 'login') or inject_sms_code(STUDENT_PHONE_U4, 'login')
u4_body = {'phone': STUDENT_PHONE_U4, 'code': login_code, 'institution_code': INST_CODE}
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,
u4_detail = f'is_new_user={r.json().get("is_new_user")}' if r.status_code in (200, 201) else f'code={r.json().get("code","")}'
log('U4', 'POST', '/api/user/auth/login-code/ (registered student)', 200, r.status_code, u4_detail,
req_headers=r.request.headers, req_body=u4_body,
resp_headers=dict(r.headers), resp_body=r.json())
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)
# U4-neg: 非试用机构 + 未录入手机号 → 403 AUTH_NOT_REGISTERED
unreg_phone = '13700000096'
django_eval(f'from apps.user.models import User; User.objects.filter(phone="{unreg_phone}").delete()')
inject_sms_code(unreg_phone, 'login')
u4_neg_body = {'phone': unreg_phone, 'code': '123456', 'institution_code': INST_CODE}
r = s.post(f'{BASE}/api/user/auth/login-code/', json=u4_neg_body)
log('U4-neg', 'POST', '/api/user/auth/login-code/ (unregistered, non-trial)', 403, r.status_code,
f'code={r.json().get("code","")}', req_body=u4_neg_body, resp_body=r.json())
# U4-new: 试用机构验证码登录自动注册(新手机号 → 201 is_new_user=true
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()}
u4_new_body = {'phone': PHONE_ALT, 'code': alt_code, 'institution_code': TRIAL_INST_CODE}
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,
log('U4-new', 'POST', '/api/user/auth/login-code/ (trial auto-register)', 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()')
django_eval(f'from apps.user.models import User; User.objects.filter(phone__in=["{PHONE_ALT}","{STUDENT_PHONE_U4}","{unreg_phone}"]).delete()')
# U5: 重置密码
NEW_PASSWORD = 'SwagNew1'
@@ -273,8 +377,8 @@ log('U5', 'POST', '/api/user/auth/reset-password/', 200, r.status_code,
# reset-password 调用 invalidate_user_tokens(time()+1),必须等 1s 再登录
time.sleep(1.2)
# 用新密码重新登录
r = s.post(f'{BASE}/api/user/auth/login/', json={'phone': PHONE, 'password': NEW_PASSWORD})
# 用新密码重新登录CMS
r = s.post(f'{BASE}/api/user/auth/login/', json={'account': PHONE, 'password': NEW_PASSWORD, 'role': CMS_ROLE})
tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
refresh = tokens.get('refresh', '')
@@ -291,8 +395,8 @@ log('U6', 'POST', '/api/user/users/change-password/', 200, r.status_code,
# change-password 同样 invalidate_user_tokens(time()+1)
time.sleep(1.2)
# 用最终密码重新登录
r = s.post(f'{BASE}/api/user/auth/login/', json={'phone': PHONE, 'password': FINAL_PASSWORD})
# 用最终密码重新登录CMS
r = s.post(f'{BASE}/api/user/auth/login/', json={'account': PHONE, 'password': FINAL_PASSWORD, 'role': CMS_ROLE})
tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
refresh = tokens.get('refresh', '')
@@ -336,8 +440,9 @@ django_eval(
f'print(f"admin={{admin.id}} teacher={{teacher.id}} student={{student.id}}")'
)
# 管理员登录
r = s.post(f'{BASE}/api/user/auth/login/', json={'phone': ADMIN_PHONE, 'password': ROLE_PWD})
# 管理员登录CMSsuper_admin
r = s.post(f'{BASE}/api/user/auth/login/',
json={'account': ADMIN_PHONE, 'password': ROLE_PWD, 'role': 'super_admin'})
admin_tokens = r.json().get('tokens', {})
admin_auth = {'Authorization': f'Bearer {admin_tokens.get("access", "")}'}
admin_refresh = admin_tokens.get('refresh', '')
@@ -353,10 +458,9 @@ log('U9', 'GET', '/api/user/users/', 200, r.status_code, u9_detail,
req_headers=r.request.headers, resp_headers=dict(r.headers), resp_body=r.json())
# U9-b: 教师获取用户列表(仅名下学生)
r_teacher_login = s.post(f'{BASE}/api/user/auth/login/',
json={'phone': TEACHER_PHONE, 'password': ROLE_PWD})
teacher_tokens = r_teacher_login.json().get('tokens', {})
teacher_auth = {'Authorization': f'Bearer {teacher_tokens.get("access", "")}'}
# teacher 角色不在 CMS 登录角色内(U3 仅 CMS 角色),直接签发 token 用于权限演示
teacher_access = get_user_access_token(TEACHER_PHONE)
teacher_auth = {'Authorization': f'Bearer {teacher_access}'}
r = s.get(f'{BASE}/api/user/users/', headers=teacher_auth)
u9b_detail = ''
@@ -414,7 +518,7 @@ section('病例端接口 (C1-C5)')
# 重新登录(logout 吊销了上一个 refresh
time.sleep(1.2)
r = s.post(f'{BASE}/api/user/auth/login/', json={'phone': PHONE, 'password': FINAL_PASSWORD})
r = s.post(f'{BASE}/api/user/auth/login/', json={'account': PHONE, 'password': FINAL_PASSWORD, 'role': CMS_ROLE})
tokens = r.json().get('tokens', {})
access = tokens.get('access', '')
auth = {'Authorization': f'Bearer {access}'}
+170
View File
@@ -0,0 +1,170 @@
"""移动端 U4 / CMS 端 U3 新登录语义 + 机构列表接口测试。"""
from rest_framework.test import APIClient
from apps.user.models import User, Institution
from apps.user.auth import TRIAL_INSTITUTION_NAME, TRIAL_INSTITUTION_CODE
from .conftest import (
CacheTestCase,
USER_LOGIN_URL, USER_LOGIN_CODE_URL, USER_INSTITUTION_LIST_URL,
inject_sms_code, create_test_user, ensure_institution,
)
def _trial_institution():
inst, _ = Institution.objects.get_or_create(
code=TRIAL_INSTITUTION_CODE,
defaults={'name': TRIAL_INSTITUTION_NAME, 'type': 'hospital'},
)
return inst
class InstitutionListTest(CacheTestCase):
"""机构列表接口(不分页、登录前可调用)。"""
def setUp(self):
super().setUp()
self.client = APIClient()
def test_list_unpaginated_with_trial_flag(self):
ensure_institution(name='测试医院', code='TEST-HOSP-001')
_trial_institution()
resp = self.client.get(USER_INSTITUTION_LIST_URL)
self.assertEqual(resp.status_code, 200, resp.content)
data = resp.json()
# 不分页:直接返回数组
self.assertIsInstance(data, list)
by_code = {item['code']: item for item in data}
self.assertIn('TEST-HOSP-001', by_code)
self.assertIn(TRIAL_INSTITUTION_CODE, by_code)
self.assertFalse(by_code['TEST-HOSP-001']['is_trial'])
self.assertTrue(by_code[TRIAL_INSTITUTION_CODE]['is_trial'])
class MobileLoginCodeTest(CacheTestCase):
"""U4 验证码登录(移动端)。"""
def setUp(self):
super().setUp()
self.client = APIClient()
def test_trial_first_register_then_login(self):
_trial_institution()
phone = '13900200001'
# 首次:自动注册
inject_sms_code(phone, 'login', code='123456')
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': '123456', 'institution_code': TRIAL_INSTITUTION_CODE,
})
self.assertEqual(resp.status_code, 201, resp.content)
self.assertTrue(resp.json()['is_new_user'])
user = User.objects.get(phone=phone)
self.assertEqual(user.role_type, 'student')
# 再次:登录
inject_sms_code(phone, 'login', code='123456')
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': '123456', 'institution_code': TRIAL_INSTITUTION_CODE,
})
self.assertEqual(resp.status_code, 200, resp.content)
self.assertFalse(resp.json()['is_new_user'])
def test_non_trial_unregistered_403(self):
ensure_institution(name='测试医院', code='TEST-HOSP-001')
phone = '13900200002'
inject_sms_code(phone, 'login', code='123456')
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': '123456', 'institution_code': 'TEST-HOSP-001',
})
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'AUTH_NOT_REGISTERED')
def test_non_trial_institution_mismatch_403(self):
inst_a = ensure_institution(name='医院A', code='HOSP-A')
ensure_institution(name='医院B', code='HOSP-B')
phone = '13900200003'
create_test_user(phone=phone, password='x', role_type='student', institution=inst_a)
inject_sms_code(phone, 'login', code='123456')
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': '123456', 'institution_code': 'HOSP-B',
})
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'AUTH_INSTITUTION_MISMATCH')
def test_non_trial_registered_match_ok(self):
inst_a = ensure_institution(name='医院A', code='HOSP-A')
phone = '13900200004'
create_test_user(phone=phone, password='x', role_type='student', institution=inst_a)
inject_sms_code(phone, 'login', code='123456')
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': '123456', 'institution_code': 'HOSP-A',
})
self.assertEqual(resp.status_code, 200, resp.content)
self.assertFalse(resp.json()['is_new_user'])
def test_unknown_institution_code(self):
phone = '13900200005'
inject_sms_code(phone, 'login', code='123456')
resp = self.client.post(USER_LOGIN_CODE_URL, {
'phone': phone, 'code': '123456', 'institution_code': 'NOPE',
})
self.assertEqual(resp.json()['code'], 'USER_INSTITUTION_NOT_FOUND')
class CmsPasswordLoginTest(CacheTestCase):
"""U3 密码登录(CMS 端:账号 + 密码 + 角色)。"""
def setUp(self):
super().setUp()
self.client = APIClient()
def test_login_by_phone_ok(self):
phone = '13900300001'
create_test_user(phone=phone, password='Doc12345', role_type='doctor')
resp = self.client.post(USER_LOGIN_URL, {
'account': phone, 'password': 'Doc12345', 'role': 'doctor',
})
self.assertEqual(resp.status_code, 200, resp.content)
def test_login_by_username_ok(self):
user = User.objects.create_user(
username='cms_admin', password='Admin123',
role_type='super_admin', status=1,
)
resp = self.client.post(USER_LOGIN_URL, {
'account': 'cms_admin', 'password': 'Admin123', 'role': 'super_admin',
})
self.assertEqual(resp.status_code, 200, resp.content)
self.assertEqual(resp.json()['user']['id'], user.id)
def test_missing_role_400(self):
phone = '13900300002'
create_test_user(phone=phone, password='Doc12345', role_type='doctor')
resp = self.client.post(USER_LOGIN_URL, {
'account': phone, 'password': 'Doc12345',
})
self.assertIn(resp.status_code, (400, 401))
self.assertEqual(resp.json()['code'], 'AUTH_BAD_CREDENTIALS')
def test_invalid_role(self):
phone = '13900300003'
create_test_user(phone=phone, password='Stu12345', role_type='student')
resp = self.client.post(USER_LOGIN_URL, {
'account': phone, 'password': 'Stu12345', 'role': 'student',
})
self.assertEqual(resp.json()['code'], 'AUTH_INVALID_ROLE')
def test_role_mismatch(self):
phone = '13900300004'
create_test_user(phone=phone, password='Doc12345', role_type='doctor')
resp = self.client.post(USER_LOGIN_URL, {
'account': phone, 'password': 'Doc12345', 'role': 'content_admin',
})
self.assertIn(resp.status_code, (400, 401))
self.assertEqual(resp.json()['code'], 'AUTH_BAD_CREDENTIALS')
+21 -15
View File
@@ -18,7 +18,7 @@ from .conftest import (
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,
create_teacher_student_relation, ensure_institution,
)
@@ -39,32 +39,37 @@ class UserAuthHappyPathTest(CacheTestCase):
# ── HP-1: 注册 → 密码登录 → /me ──────────────────────────────────────
def test_flow_register_login_me(self):
"""HP-1: U2 register(管理员代注册,默认密码) → U3 login(默认密码) → GET /me"""
"""HP-1: U2 register(管理员代注册,默认密码) → U3 login(CMS:账号+密码+角色) → GET /me"""
phone = '13900000001'
default_password = f'Pass{phone}'
real_name = '张三'
# 代注册需超级管理员 / 医院管理员身份
admin = create_test_user(phone='13900000009', password='Admin123', role_type='super_admin')
admin_client = get_auth_client(admin)
with ExitStack() as stack:
_bypass_all_auth_throttles(stack)
# U2: register(管理员代注册,无需验证码,密码自动为 Pass+手机号)
resp = self.client.post(USER_REGISTER_URL, {
# U2: register管代注册,CMS 角色 doctor,密码自动为 Pass+手机号)
resp = admin_client.post(USER_REGISTER_URL, {
'phone': phone,
'real_name': real_name,
'role_type': 'doctor',
'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.assertNotIn('tokens', data) # 代注册不返回 tokens
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 (默认密码 Pass+手机号)
# U3: CMS 登录(用户名或手机号 + 密码 + 角色)
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': default_password,
'account': phone, 'password': default_password, 'role': 'doctor',
})
self.assertEqual(resp.status_code, 200, resp.content)
tokens = resp.json()['tokens']
@@ -79,9 +84,10 @@ class UserAuthHappyPathTest(CacheTestCase):
# ── HP-2: 验证码登录 ─────────────────────────────────────────────────
def test_flow_code_login(self):
"""HP-2: 预创建用户 → U1 send-code(login) → U4 login-code → /me"""
"""HP-2: 预创建学生(已录入机构) → U1 send-code(login) → U4 login-code → /me"""
phone = '13900000002'
user = create_test_user(phone=phone, password='TestPass1')
inst = ensure_institution(name=DEFAULT_INSTITUTION_NAME, code=DEFAULT_INSTITUTION_CODE)
user = create_test_user(phone=phone, password='TestPass1', institution=inst)
with ExitStack() as stack:
_bypass_all_auth_throttles(stack)
@@ -121,7 +127,7 @@ class UserAuthHappyPathTest(CacheTestCase):
phone = '13900000003'
old_pwd = 'OldPass1'
new_pwd = 'NewPass1'
create_test_user(phone=phone, password=old_pwd)
create_test_user(phone=phone, password=old_pwd, role_type='doctor')
with ExitStack() as stack:
_bypass_all_auth_throttles(stack)
@@ -145,13 +151,13 @@ class UserAuthHappyPathTest(CacheTestCase):
# 新密码登录成功
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': new_pwd,
'account': phone, 'password': new_pwd, 'role': 'doctor',
})
self.assertEqual(resp.status_code, 200, resp.content)
# 旧密码登录失败
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': old_pwd,
'account': phone, 'password': old_pwd, 'role': 'doctor',
})
self.assertIn(resp.status_code, (400, 401))
@@ -162,11 +168,11 @@ class UserAuthHappyPathTest(CacheTestCase):
phone = '13900000004'
old_pwd = 'OldPass1'
new_pwd = 'NewPass1'
user = create_test_user(phone=phone, password=old_pwd)
user = create_test_user(phone=phone, password=old_pwd, role_type='doctor')
# U3: login
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': old_pwd,
'account': phone, 'password': old_pwd, 'role': 'doctor',
})
self.assertEqual(resp.status_code, 200, resp.content)
old_access = resp.json()['tokens']['access']
@@ -190,7 +196,7 @@ class UserAuthHappyPathTest(CacheTestCase):
# 新密码登录
self.client.credentials() # 清除旧 auth
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': new_pwd,
'account': phone, 'password': new_pwd, 'role': 'doctor',
})
self.assertEqual(resp.status_code, 200, resp.content)
new_access = resp.json()['tokens']['access']
+109 -9
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
from django.core.cache import cache
from rest_framework.test import APIClient
from apps.user.models import User
from apps.user.throttling import SmsPhoneMinuteThrottle, RegisterIpThrottle
from .conftest import (
CacheTestCase,
@@ -14,7 +15,7 @@ from .conftest import (
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,
create_teacher_student_relation, ensure_institution,
)
@@ -55,10 +56,16 @@ class UserNegativeTest(CacheTestCase):
# ── 字段校验 ─────────────────────────────────────────────────────────
def _admin_client(self, phone='13800000001'):
"""返回携带超级管理员 JWT 的客户端(U2 代注册需管理员身份)。"""
admin = create_test_user(phone=phone, password='Admin123', role_type='super_admin')
return get_auth_client(admin)
def test_register_invalid_phone_400(self):
"""N4: 手机号格式不合法 → 400 SMS_INVALID_PHONE"""
client = self._admin_client()
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
resp = client.post(USER_REGISTER_URL, {
'phone': '123',
'real_name': '测试',
'institution_code': DEFAULT_INSTITUTION_CODE,
@@ -70,10 +77,12 @@ class UserNegativeTest(CacheTestCase):
def test_register_missing_institution_400(self):
"""N5: 注册缺少机构编码 → 400 USER_INSTITUTION_CODE_REQUIRED"""
phone = '13800001002'
client = self._admin_client()
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
resp = client.post(USER_REGISTER_URL, {
'phone': phone,
'real_name': '测试缺机构',
'role_type': 'student',
})
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json()['code'], 'USER_INSTITUTION_CODE_REQUIRED')
@@ -82,8 +91,9 @@ class UserNegativeTest(CacheTestCase):
"""N6: 已注册手机号再注册 → 400 AUTH_PHONE_REGISTERED"""
phone = '13800001003'
create_test_user(phone=phone)
client = self._admin_client()
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
resp = client.post(USER_REGISTER_URL, {
'phone': phone,
'real_name': '重复注册',
'institution_code': DEFAULT_INSTITUTION_CODE,
@@ -92,12 +102,102 @@ class UserNegativeTest(CacheTestCase):
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json()['code'], 'AUTH_PHONE_REGISTERED')
# ── 代注册权限 ───────────────────────────────────────────────────────
def test_register_unauth_401(self):
"""N-REG1: 未登录调用代注册 → 401"""
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = self.client.post(USER_REGISTER_URL, {
'phone': '13800001007', 'real_name': '匿名',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 401, resp.content)
def test_register_non_admin_403(self):
"""N-REG2: 非管理员(doctor)调用代注册 → 403 USER_NO_REGISTER_PERMISSION"""
doctor = create_test_user(phone='13800001008', password='Doc12345', role_type='doctor')
client = get_auth_client(doctor)
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = client.post(USER_REGISTER_URL, {
'phone': '13800001009', 'real_name': '被注册',
'role_type': 'student',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'USER_NO_REGISTER_PERMISSION')
def test_register_hospital_admin_cannot_create_super_admin_403(self):
"""N-REG3: 医院管理员创建超级管理员 → 403 USER_NO_REGISTER_ROLE_PERMISSION"""
hadmin = create_test_user(phone='13800001010', password='Hosp1234', role_type='hospital_admin')
client = get_auth_client(hadmin)
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = client.post(USER_REGISTER_URL, {
'phone': '13800001011', 'real_name': '超管',
'role_type': 'super_admin',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'USER_NO_REGISTER_ROLE_PERMISSION')
def test_register_hospital_admin_creates_student_ok(self):
"""N-REG4: 医院管理员在本机构创建学生 → 201(新用户机构=管理员机构)"""
inst = ensure_institution(name=DEFAULT_INSTITUTION_NAME, code=DEFAULT_INSTITUTION_CODE)
hadmin = create_test_user(phone='13800001012', password='Hosp1234',
role_type='hospital_admin', institution=inst)
client = get_auth_client(hadmin)
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = client.post(USER_REGISTER_URL, {
'phone': '13800001013', 'real_name': '新学生',
'role_type': 'student',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 201, resp.content)
# 新用户机构应等于医院管理员所属机构
new_user = User.objects.get(phone='13800001013')
self.assertEqual(new_user.institution_id, inst.id)
def test_register_hospital_admin_cross_institution_403(self):
"""N-REG5: 医院管理员跨机构建账号 → 403 USER_INSTITUTION_SCOPE_FORBIDDEN"""
inst_a = ensure_institution(name='医院A', code='HOSP-A')
ensure_institution(name='医院B', code='HOSP-B')
hadmin = create_test_user(phone='13800001014', password='Hosp1234',
role_type='hospital_admin', institution=inst_a)
client = get_auth_client(hadmin)
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = client.post(USER_REGISTER_URL, {
'phone': '13800001015', 'real_name': '跨机构学生',
'role_type': 'student',
'institution_code': 'HOSP-B',
'institution_name': '医院B',
})
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'USER_INSTITUTION_SCOPE_FORBIDDEN')
def test_register_hospital_admin_no_institution_403(self):
"""N-REG6: 无所属机构的医院管理员代注册 → 403 USER_NO_REGISTER_INSTITUTION"""
hadmin = create_test_user(phone='13800001016', password='Hosp1234',
role_type='hospital_admin') # institution=None
client = get_auth_client(hadmin)
with patch.object(RegisterIpThrottle, 'allow_request', return_value=True):
resp = client.post(USER_REGISTER_URL, {
'phone': '13800001017', 'real_name': '无机构学生',
'role_type': 'student',
'institution_code': DEFAULT_INSTITUTION_CODE,
'institution_name': DEFAULT_INSTITUTION_NAME,
})
self.assertEqual(resp.status_code, 403, resp.content)
self.assertEqual(resp.json()['code'], 'USER_NO_REGISTER_INSTITUTION')
def test_login_wrong_password(self):
"""N7: 错误密码 → 400 AUTH_BAD_CREDENTIALS"""
phone = '13800001004'
create_test_user(phone=phone, password='RealPass1')
create_test_user(phone=phone, password='RealPass1', role_type='doctor')
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': 'WrongPass1',
'account': phone, 'password': 'WrongPass1', 'role': 'doctor',
})
self.assertIn(resp.status_code, (400, 401))
self.assertEqual(resp.json()['code'], 'AUTH_BAD_CREDENTIALS')
@@ -105,17 +205,17 @@ class UserNegativeTest(CacheTestCase):
def test_login_account_lock_423(self):
"""N8: 连续 5 次错误后第 6 次 → 423 AUTH_ACCOUNT_LOCKED"""
phone = '13800001005'
create_test_user(phone=phone, password='RealPass1')
create_test_user(phone=phone, password='RealPass1', role_type='doctor')
# 连续 5 次错误密码
for _ in range(5):
self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': 'Wrong!!!!',
'account': phone, 'password': 'Wrong!!!!', 'role': 'doctor',
})
# 第 6 次
resp = self.client.post(USER_LOGIN_URL, {
'phone': phone, 'password': 'Wrong!!!!',
'account': phone, 'password': 'Wrong!!!!', 'role': 'doctor',
})
self.assertEqual(resp.status_code, 423, resp.content)
self.assertEqual(resp.json()['code'], 'AUTH_ACCOUNT_LOCKED')
+70 -28
View File
@@ -1,9 +1,11 @@
# D8 测试文档
> 测试日期:2026-05-29(单元测试);Swagger 2026-06-0325 场景,含 `case_exam_item` 校验
> 测试日期: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. 测试环境
@@ -24,10 +26,11 @@
| 类别 | 测试文件 | 用例数 | 通过 | 失败 |
|---|---|---|---|---|
| 用户域 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` | 17 | 17 | 0 |
| 用户域 negative | `test_user_negative.py` | 23 | 23 | 0 |
| 病例域 negative | `test_case_negative.py` | 12 | 12 | 0 |
| **合计** | | **42** | **42** | **0** |
| **合计** | | **59** | **59** | **0** |
---
@@ -37,8 +40,8 @@
| ID | 测试方法 | 测试什么 | 结果 |
|---|---|---|---|
| HP-1 | `test_flow_register_login_me` | **管理员代注册 → 密码登录**:管理员调用注册接口(手机号+姓名+机构,无需验证码,密码自动为 Pass+手机号)→ 用默认密码登录 → 查看个人信息(确认手机号、姓名、机构正确) | PASS |
| HP-2 | `test_flow_code_login` | **验证码登录(已有用户**:预创建用户 → 发送登录验证码 → 用手机号+验证码+机构信息登录 → 确认 `is_new_user=false` → 查看个人信息确认身份正确 | PASS |
| 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 |
@@ -49,6 +52,22 @@
| 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 | 测试方法 | 测试什么 | 结果 |
@@ -60,18 +79,18 @@
## 4. Negative 测试结果
### 4.1 用户域(17 条)
### 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` | **密码错误**用正确的手机号但错误密码登录,系统拒绝并提示账号密码错误 | 400 | PASS |
| N8 | `test_login_account_lock_423` | **连续输错密码被锁定**:连续 5 次输入错误密码,第 6 次登录时系统锁定账号,返回"账号已锁定"(防暴力破解) | 423 | 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 |
@@ -81,6 +100,12 @@
| 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 条)
@@ -231,9 +256,10 @@ Errors: 0
| 接口 | 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 |
| U3 密码登录 | POST /api/user/auth/login/ | HP-1,3,4 | N7,N8 |
| U4 验证码登录(自动注册) | POST /api/user/auth/login-code/ | HP-2 | — |
| 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(辅助) |
@@ -275,33 +301,49 @@ Errors: 0
| 常量 | 值 | 用途 |
|---|---|---|
| `PHONE` | `13700000099` | 主流程用户 |
| `PHONE_ALT` | `13700000098` | U4-new 自动注册(测完删除 |
| `INST_CODE` / `INST_NAME` | `SWAG_TEST_HOSP` / `Swagger测试医院` | U2/U4 必填机构字段 |
| `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` 创建后删除) |
| `ADMIN/TEACHER/STUDENT_PHONE` | `13700000088/77/66` | U9/U10 角色夹具(`django_eval` 创建后删除teacher 直接签发 token |
**执行顺序(用户端)**
```
U1(login发码) → U2(代注册) → U3(密码登录) → U4(验证码登录+机构)
→ U4-pre/U4-new(新号自动注册 201) → U5(重置) → [sleep 1.2s] 重登
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 用户端(17 个接口/场景)
### 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/ | 管理员代注册:`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 |
| 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/ | 未注册备用号验证码登录 → 自动注册 | 200 或 201 | 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 |
@@ -314,7 +356,7 @@ U1(login发码) → U2(代注册) → U3(密码登录) → U4(验证码登录+
| U10-c | GET | /api/user/users/{id}/ | 学生查看自己 | 200 | PASS |
| U7 | POST | /api/user/auth/logout/ | 吊销 refresh(放用户段最后,病例段前会重登) | 200 | PASS |
### 8.2 病例端(8 个接口/场景)
### 8.2 病例端(5个接口+3个场景)
| ID | Method | URL | 测试什么 | 期望 | 结果 |
|---|---|---|---|---|---|
@@ -329,8 +371,8 @@ U1(login发码) → U2(代注册) → U3(密码登录) → U4(验证码登录+
### 8.3 汇总与注意事项
- **总计 25 个接口/场景**`medical_platform` 实跑;含 C3-exam / C3-db 库表校验)
- 用户端覆盖当前认证 API代注册(机构编码)、验证码登录(机构字段)、自动注册、重置/改密、Token 刷新与吊销时序
- **总计 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`