From ba9fb3306205c8b483d0e094dc2ffb6a0ba71380 Mon Sep 17 00:00:00 2001 From: shihan11 Date: Fri, 5 Jun 2026 15:36:31 +0800 Subject: [PATCH] feat: update login api --- apps/user/auth/__init__.py | 15 ++ apps/user/auth/login.py | 96 +++++---- apps/user/auth/register.py | 62 +++--- .../commands/init_trial_institution.py | 30 +++ apps/user/management/commands/init_users.py | 9 - apps/user/permissions.py | 11 ++ apps/user/urls.py | 2 + apps/user/views.py | 36 +++- config/settings.py | 3 +- test/conftest.py | 5 +- test/swagger_tryout.py | 186 ++++++++++++++---- test/test_login_mobile_cms.py | 170 ++++++++++++++++ test/test_user_happy.py | 36 ++-- test/test_user_negative.py | 118 ++++++++++- test/测试文档-D8.md | 98 ++++++--- 15 files changed, 714 insertions(+), 163 deletions(-) create mode 100644 apps/user/management/commands/init_trial_institution.py create mode 100644 test/test_login_mobile_cms.py diff --git a/apps/user/auth/__init__.py b/apps/user/auth/__init__.py index 6f3630d..e7751ff 100644 --- a/apps/user/auth/__init__.py +++ b/apps/user/auth/__init__.py @@ -4,6 +4,21 @@ from config.exceptions import AppError ALLOWED_ROLE_TYPES = ('student', 'doctor', 'teacher') +# CMS 端可登录的角色(U3 密码登录):超级管理员 / 医院管理员 / 内容管理员 / 医生(带教老师) +CMS_ROLE_TYPES = ('super_admin', 'hospital_admin', 'content_admin', 'doctor') + +# U2 代注册:仅以下角色可代注册 +REGISTER_ADMIN_ROLES = ('super_admin', 'hospital_admin') +# 各管理员可代注册创建的目标角色(超管可建所有角色;医院管理员可建内容管理员/医生/学生) +REGISTERABLE_ROLES = { + 'super_admin': ('super_admin', 'hospital_admin', 'content_admin', 'doctor', 'student'), + 'hospital_admin': ('content_admin', 'doctor', 'student'), +} + +# 预留试用机构:移动端选择该机构时手机号+验证码首次即注册、后续即登录。识别以名称为准。 +TRIAL_INSTITUTION_NAME = '北大医学部(实验室)试用' +TRIAL_INSTITUTION_CODE = 'PKU_LAB_TRIAL' + def get_tokens_for_user(user): refresh = RefreshToken.for_user(user) diff --git a/apps/user/auth/login.py b/apps/user/auth/login.py index 962195f..0a679a8 100644 --- a/apps/user/auth/login.py +++ b/apps/user/auth/login.py @@ -1,6 +1,7 @@ import re from django.core.cache import cache +from django.db.models import Q from django.utils import timezone from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny @@ -9,11 +10,11 @@ from rest_framework import serializers as drf_serializers from drf_spectacular.utils import extend_schema, inline_serializer from config.exceptions import AppError -from apps.user.models import User +from apps.user.models import User, Institution from apps.user.audit import log_login_success, log_login_fail, log_register from apps.user.auth import ( get_tokens_for_user, build_user_response, get_client_ip, get_user_agent, - resolve_or_create_institution, + CMS_ROLE_TYPES, TRIAL_INSTITUTION_NAME, ) LOGIN_FAIL_MAX = 5 @@ -29,10 +30,15 @@ _LOGIN_RESPONSE = inline_serializer('LoginResponse', fields={ # ── U3 密码登录 ────────────────────────────────────────────────────────────── @extend_schema( - summary='U3 密码登录', + summary='U3 密码登录(CMS 端)', + description='CMS 端登录:用户名或手机号 + 密码 + 角色,三者必填。角色须为 ' + 'super_admin / hospital_admin / content_admin / doctor 之一,且与账号实际角色一致。', request=inline_serializer('LoginPasswordRequest', fields={ - 'phone': drf_serializers.CharField(help_text='手机号'), + 'account': drf_serializers.CharField(help_text='用户名或手机号'), 'password': drf_serializers.CharField(help_text='密码'), + 'role': drf_serializers.ChoiceField( + choices=list(CMS_ROLE_TYPES), + help_text='角色:super_admin/hospital_admin/content_admin/doctor'), }), responses={200: _LOGIN_RESPONSE}, tags=['认证'], @@ -40,42 +46,50 @@ _LOGIN_RESPONSE = inline_serializer('LoginResponse', fields={ @api_view(['POST']) @permission_classes([AllowAny]) def login_password(request): - """U3 密码登录""" + """U3 密码登录(CMS 端:用户名/手机号 + 密码 + 角色)""" data = request.data - phone = str(data.get('phone', '')) + account = str(data.get('account', '')).strip() password = str(data.get('password', '')) + role = str(data.get('role', '')).strip() - if not phone or not password: - raise AppError('AUTH_BAD_CREDENTIALS', '手机号和密码不能为空') + if not account or not password or not role: + raise AppError('AUTH_BAD_CREDENTIALS', '用户名、密码、角色不能为空') + + if role not in CMS_ROLE_TYPES: + raise AppError('AUTH_INVALID_ROLE', + '角色无效,仅允许 super_admin / hospital_admin / content_admin / doctor') ip = get_client_ip(request) ua = get_user_agent(request) # 检查账号锁定 - fail_key = f'login_fail:{phone}' + fail_key = f'login_fail:{account}' fail_count = cache.get(fail_key) if fail_count is not None and int(fail_count) >= LOGIN_FAIL_MAX: raise AppError('AUTH_ACCOUNT_LOCKED', '登录失败次数过多,请 15 分钟后再试', status_code=423) - # 查找用户(不区分"未注册"和"密码错",防用户名枚举) + # 查找用户(用户名或手机号;不区分"未注册"和"密码错",防用户名枚举) try: - user = User.objects.select_related('institution', 'department').get(phone=phone) + user = User.objects.select_related('institution', 'department').get( + Q(username=account) | Q(phone=account) + ) except User.DoesNotExist: - log_login_fail(phone, ip=ip, reason='phone_not_found') - raise AppError('AUTH_BAD_CREDENTIALS', '手机号或密码错误') + log_login_fail(account, ip=ip, reason='account_not_found') + raise AppError('AUTH_BAD_CREDENTIALS', '账号、密码或角色错误') # 账号禁用检查 if user.status == 0: - log_login_fail(phone, ip=ip, reason='account_disabled') + log_login_fail(account, ip=ip, reason='account_disabled') raise AppError('AUTH_ACCOUNT_DISABLED', '账号已被禁用,请联系管理员', status_code=403) - # 校验密码 - if not user.check_password(password): + # 校验密码 + 角色(角色不一致同样返回通用错误,避免暴露真实角色) + if not user.check_password(password) or user.role_type != role: current = cache.get(fail_key) new_count = (int(current) + 1) if current is not None else 1 cache.set(fail_key, new_count, timeout=LOGIN_FAIL_LOCK_SECONDS) - log_login_fail(phone, ip=ip, reason='wrong_password') - raise AppError('AUTH_BAD_CREDENTIALS', '手机号或密码错误') + reason = 'wrong_password' if user.role_type == role else 'role_mismatch' + log_login_fail(account, ip=ip, reason=reason) + raise AppError('AUTH_BAD_CREDENTIALS', '账号、密码或角色错误') # 登录成功 cache.delete(fail_key) @@ -83,7 +97,7 @@ def login_password(request): user.save(update_fields=['last_login_time']) tokens = get_tokens_for_user(user) - log_login_success(user.id, phone, ip=ip, ua=ua) + log_login_success(user.id, user.phone, ip=ip, ua=ua) return Response({ 'message': '登录成功', @@ -103,14 +117,14 @@ _LOGIN_CODE_RESPONSE = inline_serializer('LoginCodeResponse', fields={ @extend_schema( - summary='U4 验证码登录(未注册自动注册)', - description='前端一键登录/注册:验证码校验通过后,若手机号未注册则自动创建账号(角色=student,无密码)。' - '机构不存在会自动创建。', + summary='U4 验证码登录(移动端)', + description='移动端学生登录:手机号 + 验证码 + 所选机构编码。' + f'仅当所选机构为试用机构({TRIAL_INSTITUTION_NAME})时,未注册手机号首次即自动注册(角色=student);' + '其它机构必须由 CMS 先录入学生,且所选机构需与录入机构一致,否则拒绝登录。机构不会自动创建。', request=inline_serializer('LoginCodeRequest', fields={ 'phone': drf_serializers.CharField(help_text='手机号'), 'code': drf_serializers.CharField(help_text='6 位短信验证码'), - 'institution_code': drf_serializers.CharField(help_text='机构编码(必填,唯一标识)'), - 'institution_name': drf_serializers.CharField(help_text='机构名称(必填)'), + 'institution_code': drf_serializers.CharField(help_text='机构编码(来自机构列表接口,必填)'), }), responses={200: _LOGIN_CODE_RESPONSE, 201: _LOGIN_CODE_RESPONSE}, tags=['认证'], @@ -118,12 +132,11 @@ _LOGIN_CODE_RESPONSE = inline_serializer('LoginCodeResponse', fields={ @api_view(['POST']) @permission_classes([AllowAny]) def login_code(request): - """U4 验证码登录 — 未注册用户自动注册""" + """U4 验证码登录(移动端:仅试用机构可自动注册)""" data = request.data phone = str(data.get('phone', '')) code = str(data.get('code', '')) institution_code = str(data.get('institution_code', '')).strip() - institution_name = str(data.get('institution_name', '')).strip() # ── 入参校验 ────────────────────────────────────────────────────────────── @@ -136,9 +149,6 @@ def login_code(request): if not institution_code: raise AppError('USER_INSTITUTION_CODE_REQUIRED', '机构编码不能为空') - if not institution_name: - raise AppError('USER_INSTITUTION_REQUIRED', '机构名称不能为空') - # ── 校验验证码(先校验再查用户,避免未注册用户也暴露手机号状态)─────────── cache_key = f'sms:login:{phone}' @@ -152,11 +162,16 @@ def login_code(request): cache.delete(cache_key) cache.delete(f'login_fail:{phone}') - # ── 解析 / 创建机构 ────────────────────────────────────────────────────── + # ── 解析机构(仅按编码查询,不创建)────────────────────────────────────── - institution = resolve_or_create_institution(institution_code, institution_name) + try: + institution = Institution.objects.get(code=institution_code) + except Institution.DoesNotExist: + raise AppError('USER_INSTITUTION_NOT_FOUND', '机构不存在,请重新选择') - # ── 查找用户 or 自动注册 ──────────────────────────────────────────────── + is_trial = institution.name == TRIAL_INSTITUTION_NAME + + # ── 查找用户 ────────────────────────────────────────────────────────────── ip = get_client_ip(request) ua = get_user_agent(request) @@ -167,6 +182,13 @@ def login_code(request): user = User.objects.select_related('institution', 'department').get(phone=phone) is_new = False except User.DoesNotExist: + user = None + is_new = False + + if user is None: + # 仅试用机构允许首次自动注册;其它机构必须先由 CMS 录入 + if not is_trial: + raise AppError('AUTH_NOT_REGISTERED', '账号未录入,请联系管理员', status_code=403) try: user = User.objects.create_user( username=phone, @@ -186,11 +208,15 @@ def login_code(request): else: if user.status == 0: raise AppError('AUTH_ACCOUNT_DISABLED', '账号已被禁用,请联系管理员', status_code=403) - if user.institution_id != institution.id: - user.institution = institution + if not is_trial: + # 移动端只有学生;非试用机构需校验角色与所选机构一致(不暴露 CMS 账号是否存在) + if user.role_type != 'student': + raise AppError('AUTH_NOT_REGISTERED', '账号未录入,请联系管理员', status_code=403) + if user.institution_id != institution.id: + raise AppError('AUTH_INSTITUTION_MISMATCH', '所选机构与账号不匹配', status_code=403) user.last_login_time = timezone.now() - user.save(update_fields=['institution', 'last_login_time']) + user.save(update_fields=['last_login_time']) tokens = get_tokens_for_user(user) log_login_success(user.id, phone, ip=ip, ua=ua) diff --git a/apps/user/auth/register.py b/apps/user/auth/register.py index b3bbcf9..bb2b3e9 100644 --- a/apps/user/auth/register.py +++ b/apps/user/auth/register.py @@ -3,17 +3,18 @@ import re from django.db import transaction, IntegrityError from rest_framework import status from rest_framework.decorators import api_view, permission_classes, throttle_classes -from rest_framework.permissions import AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import serializers as drf_serializers from drf_spectacular.utils import extend_schema, inline_serializer from config.exceptions import AppError from apps.user.models import User, Department +from apps.user.permissions import IsRegisterPermitted from apps.user.throttling import RegisterIpThrottle from apps.user.audit import log_register from apps.user.auth import ( - get_tokens_for_user, build_user_response, ALLOWED_ROLE_TYPES, + build_user_response, REGISTERABLE_ROLES, resolve_or_create_institution, ) @@ -22,30 +23,33 @@ DEFAULT_PASSWORD_PREFIX = 'Pass' @extend_schema( summary='U2 管理员代注册', - description='CMS 管理员为他人注册账号,无需验证码。默认密码为 Pass+手机号(如 Pass13800001001),用户可自行修改。' - '机构不存在会自动创建。', + description='CMS 管理员为他人注册账号,无需验证码。**仅超级管理员 / 医院管理员可调用**:' + '超级管理员可创建所有角色、可指定或新建任意机构;' + '医院管理员可创建内容管理员 / 医生 / 学生,且**只能在本机构内**建账号。' + '默认密码为 Pass+手机号(如 Pass13800001001),用户可自行修改。' + '**不返回 tokens**,新用户需自行登录(CMS 用 U3、移动端学生用 U4)。', request=inline_serializer('RegisterRequest', fields={ 'phone': drf_serializers.CharField(help_text='手机号'), 'real_name': drf_serializers.CharField(help_text='真实姓名'), 'role_type': drf_serializers.ChoiceField( - choices=['student', 'doctor', 'teacher'], - required=False, default='student', help_text='角色类型'), - 'institution_code': drf_serializers.CharField(help_text='机构编码(必填,唯一标识)'), - 'institution_name': drf_serializers.CharField(help_text='机构名称(必填)'), + choices=['student', 'doctor', 'content_admin', 'hospital_admin', 'super_admin'], + required=False, default='student', help_text='角色类型(受调用者角色限制)'), + 'institution_code': drf_serializers.CharField( + help_text='机构编码(超管必填;医院管理员可省略,默认本机构,若填须与本机构一致)'), + 'institution_name': drf_serializers.CharField(help_text='机构名称(超管新建机构时必填)'), 'department_name': drf_serializers.CharField(required=False, help_text='科室名称'), }), responses={201: inline_serializer('RegisterResponse', fields={ 'message': drf_serializers.CharField(), 'user': drf_serializers.DictField(help_text='用户基本信息'), - 'tokens': drf_serializers.DictField(help_text='access + refresh'), })}, tags=['认证'], ) @api_view(['POST']) -@permission_classes([AllowAny]) # TODO: 上线前改为管理员权限 +@permission_classes([IsAuthenticated, IsRegisterPermitted]) @throttle_classes([RegisterIpThrottle]) def register(request): - """U2 管理员代注册(手机号 + 密码,无需验证码)""" + """U2 管理员代注册(仅超级管理员 / 医院管理员,无需验证码)""" data = request.data phone = str(data.get('phone', '')) @@ -63,20 +67,36 @@ def register(request): if not real_name or len(real_name) < 2 or len(real_name) > 20: raise AppError('USER_INVALID_NAME', '姓名长度应在 2-20 字符之间') - if role_type not in ALLOWED_ROLE_TYPES: - raise AppError('AUTH_INVALID_ROLE', '角色类型无效,仅允许 student / doctor / teacher') + actor = request.user - if not institution_code: - raise AppError('USER_INSTITUTION_CODE_REQUIRED', '机构编码不能为空') - - if not institution_name: - raise AppError('USER_INSTITUTION_REQUIRED', '机构名称不能为空') + # 目标角色须在调用者可创建的范围内(超管可建全部;医院管理员仅内容管理员/医生/学生) + allowed_roles = REGISTERABLE_ROLES.get(actor.role_type, ()) + if role_type not in allowed_roles: + raise AppError('USER_NO_REGISTER_ROLE_PERMISSION', + '您无权创建该角色账号', status_code=403) password = f'{DEFAULT_PASSWORD_PREFIX}{phone}' - # ── 机构 / 科室解析 ────────────────────────────────────────────────────── + # ── 机构解析:超管任意;医院管理员仅限本机构 ───────────────────────────── - institution = resolve_or_create_institution(institution_code, institution_name) + if actor.role_type == 'hospital_admin': + if not actor.institution_id: + raise AppError('USER_NO_REGISTER_INSTITUTION', + '您未归属任何机构,无法代注册', status_code=403) + institution = actor.institution + # 若显式指定机构且与本机构不一致 → 拒绝(医院管理员不能跨机构建账号) + if institution_code and institution_code != institution.code: + raise AppError('USER_INSTITUTION_SCOPE_FORBIDDEN', + '医院管理员只能在本机构内建账号', status_code=403) + else: + # super_admin:机构编码 + 名称必填,按 code 查找或新建 + if not institution_code: + raise AppError('USER_INSTITUTION_CODE_REQUIRED', '机构编码不能为空') + if not institution_name: + raise AppError('USER_INSTITUTION_REQUIRED', '机构名称不能为空') + institution = resolve_or_create_institution(institution_code, institution_name) + + # ── 科室解析(可选)────────────────────────────────────────────────────── department = None if department_name: @@ -113,11 +133,9 @@ def register(request): # ── 善后 ────────────────────────────────────────────────────────────────── - tokens = get_tokens_for_user(user) log_register(user.id, phone) return Response({ 'message': '注册成功', 'user': build_user_response(user), - 'tokens': tokens, }, status=status.HTTP_201_CREATED) diff --git a/apps/user/management/commands/init_trial_institution.py b/apps/user/management/commands/init_trial_institution.py new file mode 100644 index 0000000..787289d --- /dev/null +++ b/apps/user/management/commands/init_trial_institution.py @@ -0,0 +1,30 @@ +from django.core.management.base import BaseCommand + +from apps.user.models import Institution +from apps.user.auth import TRIAL_INSTITUTION_NAME, TRIAL_INSTITUTION_CODE + + +class Command(BaseCommand): + help = '初始化预留试用机构「北大医学部(实验室)试用」' + + def handle(self, *args, **options): + inst, created = Institution.objects.get_or_create( + code=TRIAL_INSTITUTION_CODE, + defaults={'name': TRIAL_INSTITUTION_NAME, 'type': 'hospital'}, + ) + + if created: + self.stdout.write(self.style.SUCCESS( + f'[创建] 试用机构: {inst.code} / {inst.name}' + )) + elif inst.name != TRIAL_INSTITUTION_NAME: + old_name = inst.name + inst.name = TRIAL_INSTITUTION_NAME + inst.save(update_fields=['name']) + self.stdout.write(self.style.WARNING( + f'[更新] 试用机构名称: {old_name} -> {inst.name}' + )) + else: + self.stdout.write(self.style.WARNING( + f'[已存在] 试用机构: {inst.code} / {inst.name}' + )) diff --git a/apps/user/management/commands/init_users.py b/apps/user/management/commands/init_users.py index 4354e5e..2266031 100644 --- a/apps/user/management/commands/init_users.py +++ b/apps/user/management/commands/init_users.py @@ -73,15 +73,6 @@ class Command(BaseCommand): 'training_stage': '规培', 'learning_target': '掌握常见病诊断', }, - { - 'username': 'teacher1', - 'password': 'teacher123', - 'real_name': '王老师', - 'role_type': 'teacher', - 'phone': '13800138003', - 'title_name': '副主任医师', - 'major': '呼吸内科', - }, { 'username': 'content_admin', 'password': 'content123', diff --git a/apps/user/permissions.py b/apps/user/permissions.py index 31c4f1f..9364f4a 100644 --- a/apps/user/permissions.py +++ b/apps/user/permissions.py @@ -41,6 +41,17 @@ class IsUserDetailPermitted(BasePermission): raise AppError('USER_NO_VIEW_PERMISSION', '您没有查看该用户信息的权限', status_code=403) +class IsRegisterPermitted(BasePermission): + """U2 代注册权限:仅超级管理员 / 医院管理员""" + + def has_permission(self, request, view): + user = request.user + if user and user.is_authenticated and user.role_type in ('super_admin', 'hospital_admin'): + return True + raise AppError('USER_NO_REGISTER_PERMISSION', + '仅超级管理员或医院管理员可代注册用户', status_code=403) + + class IsCaseOperationPermitted(BasePermission): """病例操作权限:所有已登录用户均可操作""" diff --git a/apps/user/urls.py b/apps/user/urls.py index 7c5f2dd..663356d 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -17,6 +17,8 @@ router.register(r'departments', views.DepartmentViewSet, basename='department') urlpatterns = [ path('', include(router.urls)), + # 移动端机构列表(不分页,登录前可调用) + path('institution_list/', views.institution_list, name='institution-list'), # 认证相关 path('auth/send-code/', send_code, name='send-code'), path('auth/register/', register, name='register'), diff --git a/apps/user/views.py b/apps/user/views.py index 05268b6..d2d6040 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -1,10 +1,12 @@ from rest_framework import viewsets, filters, status -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from config.exceptions import AppError +from .auth import TRIAL_INSTITUTION_NAME from .models import User, Role, TeacherStudentRelation, Institution, Department from .serializers import ( UserSerializer, UserCreateSerializer, UserUpdateSerializer, @@ -196,3 +198,33 @@ class DepartmentViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ['institution', 'category'] search_fields = ['name'] + + +# ── 移动端机构列表(不分页,登录前可调用)───────────────────────────────────── + +@extend_schema( + summary='移动端机构列表(不分页)', + description='返回当前可选的全部机构,供移动端学生登录时选择所属机构。' + f'is_trial=true 标识预留试用机构({TRIAL_INSTITUTION_NAME})。', + responses={200: None}, + tags=['机构'], +) +@api_view(['GET']) +@permission_classes([AllowAny]) +def institution_list(request): + """移动端机构列表 — 全部机构、不分页""" + institutions = Institution.objects.all().order_by('name') + data = [ + { + 'id': inst.id, + 'code': inst.code, + 'name': inst.name, + 'type': inst.type, + 'level': inst.level, + 'province': inst.province, + 'city': inst.city, + 'is_trial': inst.name == TRIAL_INSTITUTION_NAME, + } + for inst in institutions + ] + return Response(data) diff --git a/config/settings.py b/config/settings.py index da83cde..cec9014 100644 --- a/config/settings.py +++ b/config/settings.py @@ -14,7 +14,8 @@ ALLOWED_HOSTS = [ "127.0.0.1", "localhost", "192.168.2.76", - "8.160.178.88" + "8.160.178.88", + "django" ] diff --git a/test/conftest.py b/test/conftest.py index 0f7bcdc..3b398d8 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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 diff --git a/test/swagger_tryout.py b/test/swagger_tryout.py index f30e207..7293787 100644 --- a/test/swagger_tryout.py +++ b/test/swagger_tryout.py @@ -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}) +# 管理员登录(CMS:super_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}'} diff --git a/test/test_login_mobile_cms.py b/test/test_login_mobile_cms.py new file mode 100644 index 0000000..66601ab --- /dev/null +++ b/test/test_login_mobile_cms.py @@ -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') diff --git a/test/test_user_happy.py b/test/test_user_happy.py index 32005f9..8637544 100644 --- a/test/test_user_happy.py +++ b/test/test_user_happy.py @@ -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'] diff --git a/test/test_user_negative.py b/test/test_user_negative.py index 0045da6..52f6799 100644 --- a/test/test_user_negative.py +++ b/test/test_user_negative.py @@ -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') diff --git a/test/测试文档-D8.md b/test/测试文档-D8.md index 86f739b..46e5a7d 100644 --- a/test/测试文档-D8.md +++ b/test/测试文档-D8.md @@ -1,9 +1,11 @@ # D8 测试文档 -> 测试日期:2026-05-29(单元测试);Swagger 2026-06-03(25 场景,含 `case_exam_item` 校验) +> 测试日期:2026-05-29(单元测试);Swagger 2026-06-03;登录逻辑 v1.1 复测 2026-06-05(28 场景) > 测试人员: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`