From fd0b3e19828aad10509d7d718c4dfc632d08fd81 Mon Sep 17 00:00:00 2001 From: shihan11 Date: Wed, 3 Jun 2026 17:34:47 +0800 Subject: [PATCH] feat: update medical training case and auth modules --- apps/case/migrations/0001_initial.py | 292 +++++++++--------- apps/case/migrations/0002_initial.py | 108 +++---- apps/case/migrations/0003_case_exam_item.py | 40 +++ apps/case/migrations/__init__.py | 2 +- apps/case/models.py | 34 ++ apps/case/schemas/case_full.json | 22 ++ apps/case/services/case_importer.py | 7 +- apps/case/services/exam_items.py | 83 +++++ apps/case/views.py | 53 +++- apps/common/migrations/__init__.py | 2 +- apps/training/migrations/0001_initial.py | 142 ++++----- apps/training/migrations/0002_initial.py | 78 ++--- apps/training/migrations/__init__.py | 2 +- apps/user/auth/__init__.py | 29 +- apps/user/auth/login.py | 93 ++++-- apps/user/auth/register.py | 66 ++-- apps/user/auth/send_code.py | 3 +- apps/user/management/__init__.py | 2 +- apps/user/management/commands/__init__.py | 2 +- apps/user/migrations/0001_initial.py | 256 +++++++-------- .../user/migrations/0002_user_phone_unique.py | 36 +-- .../0003_institution_add_code_field.py | 23 ++ .../0004_institution_code_unique.py | 25 ++ apps/user/migrations/__init__.py | 2 +- apps/user/models.py | 9 +- apps/user/utils/__init__.py | 2 +- apps/user/utils/sms.py | 2 + config/asgi.py | 32 +- config/health.py | 27 ++ config/middleware.py | 5 +- config/settings.py | 8 +- config/urls.py | 82 ++--- config/wsgi.py | 32 +- manage.py | 44 +-- prompts/case_teaching_full.md | 6 +- prompts/case_traditional_full.md | 19 +- readme.md | 38 +-- test/conftest.py | 50 ++- test/manage.py | 44 +-- test/swagger_tryout.py | 186 +++++++++-- test/test_case_happy.py | 11 +- test/test_case_negative.py | 13 + test/test_user_happy.py | 35 +-- test/test_user_negative.py | 21 +- test/测试文档-D8.md | 203 +++++++----- 45 files changed, 1459 insertions(+), 812 deletions(-) create mode 100644 apps/case/migrations/0003_case_exam_item.py create mode 100644 apps/case/services/exam_items.py create mode 100644 apps/user/migrations/0003_institution_add_code_field.py create mode 100644 apps/user/migrations/0004_institution_code_unique.py create mode 100644 config/health.py diff --git a/apps/case/migrations/0001_initial.py b/apps/case/migrations/0001_initial.py index 920a3c3..2390456 100644 --- a/apps/case/migrations/0001_initial.py +++ b/apps/case/migrations/0001_initial.py @@ -1,146 +1,146 @@ -# Generated by Django 6.0.5 on 2026-05-26 07:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='CaseBase', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('title', models.CharField(max_length=255, verbose_name='病例标题')), - ('case_type', models.CharField(choices=[('traditional', '传统病例'), ('script', '剧本病例'), ('teaching', '教学互动病例'), ('osce', 'OSCE')], max_length=30, verbose_name='病例类型')), - ('difficulty', models.CharField(blank=True, max_length=20, verbose_name='难度')), - ('difficulty_score', models.IntegerField(blank=True, null=True, verbose_name='AI难度评分')), - ('chief_complaint', models.TextField(blank=True, verbose_name='主诉')), - ('description', models.TextField(blank=True, verbose_name='病例简介')), - ('patient_age', models.IntegerField(blank=True, null=True, verbose_name='患者年龄')), - ('patient_gender', models.CharField(blank=True, max_length=10, verbose_name='患者性别')), - ('tags', models.CharField(blank=True, max_length=500, verbose_name='标签')), - ('symptom_tags', models.JSONField(blank=True, default=list, verbose_name='症状标签')), - ('disease_tags', models.JSONField(blank=True, default=list, verbose_name='疾病标签')), - ('competency_tags', models.JSONField(blank=True, default=list, verbose_name='能力标签')), - ('guideline_tags', models.JSONField(blank=True, default=list, verbose_name='指南标签')), - ('knowledge_points', models.JSONField(blank=True, default=list, verbose_name='知识点')), - ('icd_codes', models.CharField(blank=True, max_length=500, verbose_name='ICD编码')), - ('estimated_minutes', models.IntegerField(blank=True, null=True, verbose_name='预计训练时长')), - ('osce_enabled', models.BooleanField(default=False, verbose_name='是否OSCE')), - ('rag_enabled', models.BooleanField(default=False, verbose_name='是否启用知识增强')), - ('ai_prompt_template', models.TextField(blank=True, verbose_name='AI角色Prompt')), - ('multimodal_assets', models.JSONField(blank=True, default=dict, verbose_name='图片/影像/附件')), - ('vector_status', models.SmallIntegerField(default=0, verbose_name='是否向量化')), - ('publish_status', models.SmallIntegerField(choices=[(0, '草稿'), (1, '已发布'), (2, '已下架')], default=0, verbose_name='发布状态')), - ('status', models.SmallIntegerField(choices=[(0, '禁用'), (1, '正常')], default=1, verbose_name='状态')), - ], - options={ - 'verbose_name': '病例', - 'verbose_name_plural': '病例', - 'db_table': 'case_base', - }, - ), - migrations.CreateModel( - name='CaseStage', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('stage_type', models.CharField(blank=True, max_length=50, verbose_name='阶段类型')), - ('stage_name', models.CharField(max_length=100, verbose_name='阶段名称')), - ('stage_mode', models.CharField(choices=[('dialogue', '对话'), ('osce', 'OSCE'), ('choice', '选择')], default='dialogue', max_length=30, verbose_name='阶段模式')), - ('stage_goal', models.TextField(blank=True, verbose_name='阶段目标')), - ('ai_role_prompt', models.TextField(blank=True, verbose_name='AI阶段Prompt')), - ('standard_action', models.TextField(blank=True, verbose_name='标准动作')), - ('expected_questions', models.TextField(blank=True, verbose_name='期望问题')), - ('scoring_points', models.TextField(blank=True, verbose_name='评分点')), - ('timeout_seconds', models.IntegerField(blank=True, null=True, verbose_name='超时时间')), - ('unlock_condition', models.CharField(blank=True, max_length=255, verbose_name='解锁条件')), - ('sort_order', models.IntegerField(default=0, verbose_name='排序')), - ], - options={ - 'verbose_name': '病例阶段', - 'verbose_name_plural': '病例阶段', - 'db_table': 'case_stage', - 'ordering': ['sort_order', 'id'], - }, - ), - migrations.CreateModel( - name='ScoringRule', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('dimension', models.CharField(max_length=50, verbose_name='评分维度')), - ('competency_dimension', models.CharField(blank=True, max_length=50, verbose_name='能力维度')), - ('score_weight', models.DecimalField(decimal_places=2, default=1.0, max_digits=5, verbose_name='权重')), - ('ai_auto_score', models.BooleanField(default=False, verbose_name='AI自动评分')), - ('osce_dimension', models.BooleanField(default=False, verbose_name='是否OSCE')), - ('scoring_standard', models.TextField(blank=True, verbose_name='评分标准')), - ('rubric_json', models.JSONField(blank=True, default=dict, verbose_name='评分Rubric')), - ], - options={ - 'verbose_name': '评分规则', - 'verbose_name_plural': '评分规则', - 'db_table': 'scoring_rule', - }, - ), - migrations.CreateModel( - name='ScriptCase', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('scenario_setting', models.TextField(blank=True, verbose_name='场景设定')), - ('emotional_state', models.CharField(blank=True, max_length=50, verbose_name='情绪状态')), - ('cultural_level', models.CharField(blank=True, max_length=50, verbose_name='文化水平')), - ('branch_logic', models.TextField(blank=True, verbose_name='分支逻辑')), - ('hidden_clues', models.TextField(blank=True, verbose_name='隐藏线索')), - ], - options={ - 'verbose_name': '剧本病例', - 'verbose_name_plural': '剧本病例', - 'db_table': 'script_case', - }, - ), - migrations.CreateModel( - name='TeachingCase', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('teaching_goal', models.TextField(blank=True, verbose_name='教学目标')), - ('discussion_questions', models.TextField(blank=True, verbose_name='讨论问题')), - ('teacher_guide', models.TextField(blank=True, verbose_name='教师指南')), - ('scoring_focus', models.TextField(blank=True, verbose_name='评分重点')), - ], - options={ - 'verbose_name': '教学互动病例', - 'verbose_name_plural': '教学互动病例', - 'db_table': 'teaching_case', - }, - ), - migrations.CreateModel( - name='TraditionalCase', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('standard_diagnosis', models.TextField(blank=True, verbose_name='标准诊断')), - ('standard_treatment', models.TextField(blank=True, verbose_name='标准治疗')), - ('guideline_reference', models.TextField(blank=True, verbose_name='指南参考')), - ], - options={ - 'verbose_name': '传统病例', - 'verbose_name_plural': '传统病例', - 'db_table': 'traditional_case', - }, - ), - ] +# Generated by Django 6.0.5 on 2026-05-26 07:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CaseBase', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('title', models.CharField(max_length=255, verbose_name='病例标题')), + ('case_type', models.CharField(choices=[('traditional', '传统病例'), ('script', '剧本病例'), ('teaching', '教学互动病例'), ('osce', 'OSCE')], max_length=30, verbose_name='病例类型')), + ('difficulty', models.CharField(blank=True, max_length=20, verbose_name='难度')), + ('difficulty_score', models.IntegerField(blank=True, null=True, verbose_name='AI难度评分')), + ('chief_complaint', models.TextField(blank=True, verbose_name='主诉')), + ('description', models.TextField(blank=True, verbose_name='病例简介')), + ('patient_age', models.IntegerField(blank=True, null=True, verbose_name='患者年龄')), + ('patient_gender', models.CharField(blank=True, max_length=10, verbose_name='患者性别')), + ('tags', models.CharField(blank=True, max_length=500, verbose_name='标签')), + ('symptom_tags', models.JSONField(blank=True, default=list, verbose_name='症状标签')), + ('disease_tags', models.JSONField(blank=True, default=list, verbose_name='疾病标签')), + ('competency_tags', models.JSONField(blank=True, default=list, verbose_name='能力标签')), + ('guideline_tags', models.JSONField(blank=True, default=list, verbose_name='指南标签')), + ('knowledge_points', models.JSONField(blank=True, default=list, verbose_name='知识点')), + ('icd_codes', models.CharField(blank=True, max_length=500, verbose_name='ICD编码')), + ('estimated_minutes', models.IntegerField(blank=True, null=True, verbose_name='预计训练时长')), + ('osce_enabled', models.BooleanField(default=False, verbose_name='是否OSCE')), + ('rag_enabled', models.BooleanField(default=False, verbose_name='是否启用知识增强')), + ('ai_prompt_template', models.TextField(blank=True, verbose_name='AI角色Prompt')), + ('multimodal_assets', models.JSONField(blank=True, default=dict, verbose_name='图片/影像/附件')), + ('vector_status', models.SmallIntegerField(default=0, verbose_name='是否向量化')), + ('publish_status', models.SmallIntegerField(choices=[(0, '草稿'), (1, '已发布'), (2, '已下架')], default=0, verbose_name='发布状态')), + ('status', models.SmallIntegerField(choices=[(0, '禁用'), (1, '正常')], default=1, verbose_name='状态')), + ], + options={ + 'verbose_name': '病例', + 'verbose_name_plural': '病例', + 'db_table': 'case_base', + }, + ), + migrations.CreateModel( + name='CaseStage', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('stage_type', models.CharField(blank=True, max_length=50, verbose_name='阶段类型')), + ('stage_name', models.CharField(max_length=100, verbose_name='阶段名称')), + ('stage_mode', models.CharField(choices=[('dialogue', '对话'), ('osce', 'OSCE'), ('choice', '选择')], default='dialogue', max_length=30, verbose_name='阶段模式')), + ('stage_goal', models.TextField(blank=True, verbose_name='阶段目标')), + ('ai_role_prompt', models.TextField(blank=True, verbose_name='AI阶段Prompt')), + ('standard_action', models.TextField(blank=True, verbose_name='标准动作')), + ('expected_questions', models.TextField(blank=True, verbose_name='期望问题')), + ('scoring_points', models.TextField(blank=True, verbose_name='评分点')), + ('timeout_seconds', models.IntegerField(blank=True, null=True, verbose_name='超时时间')), + ('unlock_condition', models.CharField(blank=True, max_length=255, verbose_name='解锁条件')), + ('sort_order', models.IntegerField(default=0, verbose_name='排序')), + ], + options={ + 'verbose_name': '病例阶段', + 'verbose_name_plural': '病例阶段', + 'db_table': 'case_stage', + 'ordering': ['sort_order', 'id'], + }, + ), + migrations.CreateModel( + name='ScoringRule', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('dimension', models.CharField(max_length=50, verbose_name='评分维度')), + ('competency_dimension', models.CharField(blank=True, max_length=50, verbose_name='能力维度')), + ('score_weight', models.DecimalField(decimal_places=2, default=1.0, max_digits=5, verbose_name='权重')), + ('ai_auto_score', models.BooleanField(default=False, verbose_name='AI自动评分')), + ('osce_dimension', models.BooleanField(default=False, verbose_name='是否OSCE')), + ('scoring_standard', models.TextField(blank=True, verbose_name='评分标准')), + ('rubric_json', models.JSONField(blank=True, default=dict, verbose_name='评分Rubric')), + ], + options={ + 'verbose_name': '评分规则', + 'verbose_name_plural': '评分规则', + 'db_table': 'scoring_rule', + }, + ), + migrations.CreateModel( + name='ScriptCase', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('scenario_setting', models.TextField(blank=True, verbose_name='场景设定')), + ('emotional_state', models.CharField(blank=True, max_length=50, verbose_name='情绪状态')), + ('cultural_level', models.CharField(blank=True, max_length=50, verbose_name='文化水平')), + ('branch_logic', models.TextField(blank=True, verbose_name='分支逻辑')), + ('hidden_clues', models.TextField(blank=True, verbose_name='隐藏线索')), + ], + options={ + 'verbose_name': '剧本病例', + 'verbose_name_plural': '剧本病例', + 'db_table': 'script_case', + }, + ), + migrations.CreateModel( + name='TeachingCase', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('teaching_goal', models.TextField(blank=True, verbose_name='教学目标')), + ('discussion_questions', models.TextField(blank=True, verbose_name='讨论问题')), + ('teacher_guide', models.TextField(blank=True, verbose_name='教师指南')), + ('scoring_focus', models.TextField(blank=True, verbose_name='评分重点')), + ], + options={ + 'verbose_name': '教学互动病例', + 'verbose_name_plural': '教学互动病例', + 'db_table': 'teaching_case', + }, + ), + migrations.CreateModel( + name='TraditionalCase', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('standard_diagnosis', models.TextField(blank=True, verbose_name='标准诊断')), + ('standard_treatment', models.TextField(blank=True, verbose_name='标准治疗')), + ('guideline_reference', models.TextField(blank=True, verbose_name='指南参考')), + ], + options={ + 'verbose_name': '传统病例', + 'verbose_name_plural': '传统病例', + 'db_table': 'traditional_case', + }, + ), + ] diff --git a/apps/case/migrations/0002_initial.py b/apps/case/migrations/0002_initial.py index 0126a2d..19ec75b 100644 --- a/apps/case/migrations/0002_initial.py +++ b/apps/case/migrations/0002_initial.py @@ -1,54 +1,54 @@ -# Generated by Django 6.0.5 on 2026-05-26 07:02 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('case', '0001_initial'), - ('user', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='casebase', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建人'), - ), - migrations.AddField( - model_name='casebase', - name='department', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user.department', verbose_name='所属科室'), - ), - migrations.AddField( - model_name='casestage', - name='case', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='case.casebase', verbose_name='病例'), - ), - migrations.AddField( - model_name='scoringrule', - name='case', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scoring_rules', to='case.casebase', verbose_name='病例'), - ), - migrations.AddField( - model_name='scriptcase', - name='case', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='case.casebase', verbose_name='病例'), - ), - migrations.AddField( - model_name='teachingcase', - name='case', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='case.casebase', verbose_name='病例'), - ), - migrations.AddField( - model_name='traditionalcase', - name='case', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='case.casebase', verbose_name='病例'), - ), - ] +# Generated by Django 6.0.5 on 2026-05-26 07:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('case', '0001_initial'), + ('user', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='casebase', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AddField( + model_name='casebase', + name='department', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user.department', verbose_name='所属科室'), + ), + migrations.AddField( + model_name='casestage', + name='case', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='case.casebase', verbose_name='病例'), + ), + migrations.AddField( + model_name='scoringrule', + name='case', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scoring_rules', to='case.casebase', verbose_name='病例'), + ), + migrations.AddField( + model_name='scriptcase', + name='case', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='case.casebase', verbose_name='病例'), + ), + migrations.AddField( + model_name='teachingcase', + name='case', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='case.casebase', verbose_name='病例'), + ), + migrations.AddField( + model_name='traditionalcase', + name='case', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='case.casebase', verbose_name='病例'), + ), + ] diff --git a/apps/case/migrations/0003_case_exam_item.py b/apps/case/migrations/0003_case_exam_item.py new file mode 100644 index 0000000..3d7d080 --- /dev/null +++ b/apps/case/migrations/0003_case_exam_item.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.14 on 2026-06-03 07:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('case', '0002_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CaseExamItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('item_code', models.CharField(max_length=64, verbose_name='检查项目编码')), + ('item_name', models.CharField(max_length=128, verbose_name='检查项目名称')), + ('item_type', models.CharField(max_length=32, verbose_name='项目类型')), + ('category', models.CharField(blank=True, default='', max_length=64, verbose_name='项目分类')), + ('result_text', models.TextField(verbose_name='结果文本')), + ('result_structured', models.JSONField(blank=True, default=None, null=True, verbose_name='结构化结果')), + ('is_key', models.BooleanField(default=False, verbose_name='是否关键检查')), + ('is_abnormal', models.BooleanField(default=False, verbose_name='是否异常')), + ('score_weight', models.DecimalField(decimal_places=2, default=1.0, max_digits=5, verbose_name='评分权重')), + ('display_order', models.IntegerField(default=0, verbose_name='展示顺序')), + ('case', models.ForeignKey(db_column='case_id', on_delete=django.db.models.deletion.CASCADE, related_name='exam_items', to='case.casebase', verbose_name='病例')), + ], + options={ + 'verbose_name': '病例检查项', + 'verbose_name_plural': '病例检查项', + 'db_table': 'case_exam_item', + 'ordering': ['display_order', 'id'], + 'constraints': [models.UniqueConstraint(fields=('case', 'item_code'), name='uk_case_exam_item_code')], + }, + ), + ] diff --git a/apps/case/migrations/__init__.py b/apps/case/migrations/__init__.py index c65238f..6eab173 100644 --- a/apps/case/migrations/__init__.py +++ b/apps/case/migrations/__init__.py @@ -1,2 +1,2 @@  - + diff --git a/apps/case/models.py b/apps/case/models.py index 6226432..de40287 100644 --- a/apps/case/models.py +++ b/apps/case/models.py @@ -183,3 +183,37 @@ class ScoringRule(BaseModel): def __str__(self): return f"{self.case.title} - {self.dimension}" + + +class CaseExamItem(BaseModel): + """病例检查/检验项目表(与 consultation 库 case_exam_item 同构)""" + case = models.ForeignKey( + CaseBase, on_delete=models.CASCADE, + related_name='exam_items', db_column='case_id', + verbose_name='病例', + ) + item_code = models.CharField('检查项目编码', max_length=64) + item_name = models.CharField('检查项目名称', max_length=128) + item_type = models.CharField('项目类型', max_length=32) + category = models.CharField('项目分类', max_length=64, blank=True, default='') + result_text = models.TextField('结果文本') + result_structured = models.JSONField('结构化结果', null=True, blank=True, default=None) + is_key = models.BooleanField('是否关键检查', default=False) + is_abnormal = models.BooleanField('是否异常', default=False) + score_weight = models.DecimalField('评分权重', max_digits=5, decimal_places=2, default=1.00) + display_order = models.IntegerField('展示顺序', default=0) + + class Meta: + db_table = 'case_exam_item' + verbose_name = '病例检查项' + verbose_name_plural = '病例检查项' + ordering = ['display_order', 'id'] + constraints = [ + models.UniqueConstraint( + fields=['case', 'item_code'], + name='uk_case_exam_item_code', + ), + ] + + def __str__(self): + return f"{self.case_id}:{self.item_code}" diff --git a/apps/case/schemas/case_full.json b/apps/case/schemas/case_full.json index aff2eed..14438ed 100644 --- a/apps/case/schemas/case_full.json +++ b/apps/case/schemas/case_full.json @@ -60,6 +60,28 @@ }, "osce_enabled": { "type": "boolean" }, "department_name": { "type": "string" }, + "exam_items": { + "type": "array", + "items": { + "type": "object", + "required": ["item_code", "item_name", "item_type", "result_text"], + "properties": { + "item_code": { "type": "string", "minLength": 1, "maxLength": 64 }, + "item_name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "item_type": { "type": "string", "minLength": 1, "maxLength": 32 }, + "category": { "type": "string", "maxLength": 64 }, + "result_text": { "type": "string", "minLength": 1 }, + "result_structured": { + "oneOf": [{ "type": "object" }, { "type": "null" }] + }, + "is_key": { "type": "boolean" }, + "is_abnormal": { "type": "boolean" }, + "score_weight": { "type": "number", "exclusiveMinimum": 0 }, + "display_order": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, "traditional": { "type": "object", "properties": { diff --git a/apps/case/services/case_importer.py b/apps/case/services/case_importer.py index c817cca..6c4cf22 100644 --- a/apps/case/services/case_importer.py +++ b/apps/case/services/case_importer.py @@ -10,6 +10,7 @@ from pathlib import Path from config.exceptions import AppError from . import deepseek_client from .pdf_reader import extract_text_from_pdfs +from .exam_items import normalize_exam_items from prompts.loader import load_prompt logger = logging.getLogger(__name__) @@ -38,6 +39,10 @@ def parse_pdf(files, case_type: str, user) -> dict: data.pop('stages', None) data['case_type'] = case_type + if 'exam_items' in data: + data['exam_items'] = normalize_exam_items(data.get('exam_items') or []) + else: + data['exam_items'] = [] _strip_unknown_fields(data) _validate_schema(data) @@ -72,7 +77,7 @@ _SCHEMA_ALLOWED_KEYS = { 'patient_age', 'patient_gender', 'tags', 'symptom_tags', 'disease_tags', 'competency_tags', 'guideline_tags', 'knowledge_points', 'icd_codes', 'estimated_minutes', 'osce_enabled', 'department_name', - 'traditional', 'teaching', + 'exam_items', 'traditional', 'teaching', } diff --git a/apps/case/services/exam_items.py b/apps/case/services/exam_items.py new file mode 100644 index 0000000..ab9a6fc --- /dev/null +++ b/apps/case/services/exam_items.py @@ -0,0 +1,83 @@ +"""病例检查项:C1 解析归一化、C3 落库校验。""" + +from decimal import Decimal, InvalidOperation + +from config.exceptions import AppError + +EXAM_ITEM_FIELDS = { + 'item_code', 'item_name', 'item_type', 'category', + 'result_text', 'result_structured', + 'is_key', 'is_abnormal', 'score_weight', 'display_order', +} + + +def normalize_exam_items(raw_items) -> list: + """归一化检查项列表;按 item_code 去重(保留首次出现)。""" + if not raw_items: + return [] + if not isinstance(raw_items, list): + raise AppError('CASE_VALIDATION_ERROR', 'exam_items 必须为数组', status_code=400) + + seen = set() + normalized = [] + for i, item in enumerate(raw_items): + if not isinstance(item, dict): + raise AppError('CASE_VALIDATION_ERROR', f'exam_items[{i}] 必须为对象', status_code=400) + code = (item.get('item_code') or '').strip() + name = (item.get('item_name') or '').strip() + item_type = (item.get('item_type') or '').strip() + result_text = (item.get('result_text') or '').strip() + if not code: + raise AppError('CASE_VALIDATION_ERROR', f'exam_items[{i}].item_code 必填', status_code=400) + if not name: + raise AppError('CASE_VALIDATION_ERROR', f'exam_items[{i}].item_name 必填', status_code=400) + if not item_type: + raise AppError('CASE_VALIDATION_ERROR', f'exam_items[{i}].item_type 必填', status_code=400) + if not result_text: + raise AppError('CASE_VALIDATION_ERROR', f'exam_items[{i}].result_text 必填', status_code=400) + if code in seen: + continue + seen.add(code) + + try: + weight = item.get('score_weight', 1) + weight = Decimal(str(weight)) + except (InvalidOperation, TypeError, ValueError): + raise AppError( + 'CASE_VALIDATION_ERROR', + f'exam_items[{i}].score_weight 须为数字', + status_code=400, + ) + if weight <= 0: + raise AppError( + 'CASE_VALIDATION_ERROR', + f'exam_items[{i}].score_weight 须大于 0', + status_code=400, + ) + + normalized.append({ + 'item_code': code[:64], + 'item_name': name[:128], + 'item_type': item_type[:32], + 'category': (item.get('category') or '')[:64], + 'result_text': result_text, + 'result_structured': item.get('result_structured') if isinstance( + item.get('result_structured'), dict + ) else None, + 'is_key': bool(item.get('is_key', False)), + 'is_abnormal': bool(item.get('is_abnormal', False)), + 'score_weight': float(weight), + 'display_order': int(item.get('display_order', len(normalized) + 1)), + }) + return normalized + + +def assert_no_duplicate_exam_items(items: list): + """创建前再次校验同一病例内 item_code 不重复。""" + codes = [it['item_code'] for it in items] + if len(codes) != len(set(codes)): + raise AppError( + 'CASE_EXAM_ITEM_DUPLICATE', + '同一病例内检查项 item_code 不能重复', + status_code=400, + ) diff --git a/apps/case/views.py b/apps/case/views.py index 0a0991b..a354d50 100644 --- a/apps/case/views.py +++ b/apps/case/views.py @@ -13,7 +13,7 @@ from apps.user.permissions import IsCaseOperationPermitted from apps.user.throttling import PdfParseUserThrottle, ScoringRuleGenerateUserThrottle from .models import ( CaseBase, TraditionalCase, ScriptCase, - TeachingCase, CaseStage, ScoringRule + TeachingCase, CaseStage, ScoringRule, CaseExamItem, ) from .serializers import ( CaseBaseListSerializer, CaseBaseDetailSerializer, @@ -23,6 +23,9 @@ from .serializers import ( ) from .services import case_importer, scoring_rule_generator from .services.department_resolver import resolve_department +from .services.exam_items import ( + normalize_exam_items, assert_no_duplicate_exam_items, EXAM_ITEM_FIELDS, +) audit = logging.getLogger('audit') @@ -135,6 +138,10 @@ class CaseBaseViewSet(viewsets.ModelViewSet): 'traditional': drf_serializers.DictField(required=False), 'teaching': drf_serializers.DictField(required=False), 'scoring_rules': drf_serializers.ListField(child=drf_serializers.DictField(), help_text='评分规则(≥1 条,必填)'), + 'exam_items': drf_serializers.ListField( + child=drf_serializers.DictField(), required=False, + help_text='检查项(可选;同一病例 item_code 不可重复)', + ), 'parse_id': drf_serializers.CharField(required=False, help_text='来自 parse-pdf 的 parse_id(审计用)'), 'auto_publish': drf_serializers.BooleanField(required=False, default=False), }), @@ -168,6 +175,9 @@ class CaseBaseViewSet(viewsets.ModelViewSet): raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400) _validate_scoring_rules(scoring_rules_data) + exam_items_data = normalize_exam_items(data.get('exam_items') or []) + assert_no_duplicate_exam_items(exam_items_data) + department = resolve_department(data.get('department_name', '')) with transaction.atomic(): @@ -189,9 +199,20 @@ class CaseBaseViewSet(viewsets.ModelViewSet): ] ScoringRule.objects.bulk_create(rule_objs) + if exam_items_data: + exam_objs = [ + CaseExamItem( + case=case, + **{k: v for k, v in item.items() if k in EXAM_ITEM_FIELDS}, + ) + for item in exam_items_data + ] + CaseExamItem.objects.bulk_create(exam_objs) + audit.info( - 'CASE_CREATE case_id=%s from=%s by=%s scoring_rules=%d', - case.id, data.get('parse_id', 'form'), request.user.id, len(rule_objs), + 'CASE_CREATE case_id=%s from=%s by=%s scoring_rules=%d exam_items=%d', + case.id, data.get('parse_id', 'form'), request.user.id, + len(rule_objs), len(exam_items_data), ) return Response(_build_full_response(case), status=status.HTTP_201_CREATED) @@ -214,7 +235,7 @@ class CaseBaseViewSet(viewsets.ModelViewSet): def _full_detail(self, request, pk): case = CaseBase.objects.select_related( 'department', 'created_by' - ).prefetch_related('scoring_rules').filter(pk=pk).first() + ).prefetch_related('scoring_rules', 'exam_items').filter(pk=pk).first() if not case: raise AppError('NOT_FOUND', '病例不存在', status_code=404) @@ -384,6 +405,13 @@ def _validate_scoring_rules(rules): def _build_full_response(case): + if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr( + case, '_prefetched_objects_cache', {} + ): + exam_qs = case.exam_items.all() + else: + exam_qs = CaseExamItem.objects.filter(case_id=case.id).order_by('display_order', 'id') + result = { 'case': { 'id': case.id, @@ -456,4 +484,21 @@ def _build_full_response(case): for r in rules ] + result['exam_items'] = [ + { + 'id': e.id, + 'item_code': e.item_code, + 'item_name': e.item_name, + 'item_type': e.item_type, + 'category': e.category, + 'result_text': e.result_text, + 'result_structured': e.result_structured, + 'is_key': e.is_key, + 'is_abnormal': e.is_abnormal, + 'score_weight': float(e.score_weight), + 'display_order': e.display_order, + } + for e in exam_qs + ] + return result diff --git a/apps/common/migrations/__init__.py b/apps/common/migrations/__init__.py index c65238f..6eab173 100644 --- a/apps/common/migrations/__init__.py +++ b/apps/common/migrations/__init__.py @@ -1,2 +1,2 @@  - + diff --git a/apps/training/migrations/0001_initial.py b/apps/training/migrations/0001_initial.py index 6b0c65f..03b320a 100644 --- a/apps/training/migrations/0001_initial.py +++ b/apps/training/migrations/0001_initial.py @@ -1,71 +1,71 @@ -# Generated by Django 6.0.5 on 2026-05-26 07:02 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('case', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='TrainingScoreDetail', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('dimension', models.CharField(max_length=50, verbose_name='评分维度')), - ('score', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='分数')), - ('deducted_reason', models.TextField(blank=True, verbose_name='扣分原因')), - ('evidence_message_ids', models.JSONField(blank=True, default=list, verbose_name='对应对话证据')), - ('ai_confidence', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='AI评分置信度')), - ('comment', models.TextField(blank=True, verbose_name='评语')), - ], - options={ - 'verbose_name': '评分明细', - 'verbose_name_plural': '评分明细', - 'db_table': 'training_score_detail', - }, - ), - migrations.CreateModel( - name='TrainingRecord', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('training_mode', models.CharField(choices=[('novice', '新手'), ('practice', '练习'), ('exam', '考试')], max_length=50, verbose_name='训练模式')), - ('case_type', models.CharField(blank=True, max_length=30, verbose_name='病例类型')), - ('start_time', models.DateTimeField(auto_now_add=True, verbose_name='开始时间')), - ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')), - ('duration_seconds', models.IntegerField(blank=True, null=True, verbose_name='训练时长')), - ('total_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='总分')), - ('ai_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='AI评分')), - ('teacher_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='教师评分')), - ('evaluation_level', models.CharField(blank=True, choices=[('excellent', '优秀'), ('good', '良好'), ('average', '一般'), ('poor', '较差')], max_length=20, verbose_name='评价等级')), - ('status', models.CharField(choices=[('in_progress', '进行中'), ('completed', '已完成'), ('aborted', '已中断')], default='in_progress', max_length=30, verbose_name='状态')), - ('feedback', models.TextField(blank=True, verbose_name='总评')), - ('thinking_chain', models.TextField(blank=True, verbose_name='临床推理链')), - ('diagnosis_path', models.TextField(blank=True, verbose_name='诊断路径')), - ('wrong_points', models.JSONField(blank=True, default=list, verbose_name='错误知识点')), - ('missed_questions', models.JSONField(blank=True, default=list, verbose_name='漏问项')), - ('recommendation_result', models.JSONField(blank=True, default=dict, verbose_name='AI推荐')), - ('ai_feedback_structured', models.JSONField(blank=True, default=dict, verbose_name='AI结构化反馈')), - ('osce_station_score', models.JSONField(blank=True, default=dict, verbose_name='OSCE各站点成绩')), - ('interruption_count', models.IntegerField(default=0, verbose_name='中断次数')), - ('emotion_analysis', models.JSONField(blank=True, default=dict, verbose_name='情绪分析')), - ('prompt_version', models.CharField(blank=True, max_length=50, verbose_name='Prompt版本')), - ('rag_context_version', models.CharField(blank=True, max_length=50, verbose_name='知识上下文版本')), - ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_records', to='case.casebase', verbose_name='病例')), - ], - options={ - 'verbose_name': '训练记录', - 'verbose_name_plural': '训练记录', - 'db_table': 'training_record', - }, - ), - ] +# Generated by Django 6.0.5 on 2026-05-26 07:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('case', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='TrainingScoreDetail', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('dimension', models.CharField(max_length=50, verbose_name='评分维度')), + ('score', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='分数')), + ('deducted_reason', models.TextField(blank=True, verbose_name='扣分原因')), + ('evidence_message_ids', models.JSONField(blank=True, default=list, verbose_name='对应对话证据')), + ('ai_confidence', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='AI评分置信度')), + ('comment', models.TextField(blank=True, verbose_name='评语')), + ], + options={ + 'verbose_name': '评分明细', + 'verbose_name_plural': '评分明细', + 'db_table': 'training_score_detail', + }, + ), + migrations.CreateModel( + name='TrainingRecord', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('training_mode', models.CharField(choices=[('novice', '新手'), ('practice', '练习'), ('exam', '考试')], max_length=50, verbose_name='训练模式')), + ('case_type', models.CharField(blank=True, max_length=30, verbose_name='病例类型')), + ('start_time', models.DateTimeField(auto_now_add=True, verbose_name='开始时间')), + ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')), + ('duration_seconds', models.IntegerField(blank=True, null=True, verbose_name='训练时长')), + ('total_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='总分')), + ('ai_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='AI评分')), + ('teacher_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='教师评分')), + ('evaluation_level', models.CharField(blank=True, choices=[('excellent', '优秀'), ('good', '良好'), ('average', '一般'), ('poor', '较差')], max_length=20, verbose_name='评价等级')), + ('status', models.CharField(choices=[('in_progress', '进行中'), ('completed', '已完成'), ('aborted', '已中断')], default='in_progress', max_length=30, verbose_name='状态')), + ('feedback', models.TextField(blank=True, verbose_name='总评')), + ('thinking_chain', models.TextField(blank=True, verbose_name='临床推理链')), + ('diagnosis_path', models.TextField(blank=True, verbose_name='诊断路径')), + ('wrong_points', models.JSONField(blank=True, default=list, verbose_name='错误知识点')), + ('missed_questions', models.JSONField(blank=True, default=list, verbose_name='漏问项')), + ('recommendation_result', models.JSONField(blank=True, default=dict, verbose_name='AI推荐')), + ('ai_feedback_structured', models.JSONField(blank=True, default=dict, verbose_name='AI结构化反馈')), + ('osce_station_score', models.JSONField(blank=True, default=dict, verbose_name='OSCE各站点成绩')), + ('interruption_count', models.IntegerField(default=0, verbose_name='中断次数')), + ('emotion_analysis', models.JSONField(blank=True, default=dict, verbose_name='情绪分析')), + ('prompt_version', models.CharField(blank=True, max_length=50, verbose_name='Prompt版本')), + ('rag_context_version', models.CharField(blank=True, max_length=50, verbose_name='知识上下文版本')), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_records', to='case.casebase', verbose_name='病例')), + ], + options={ + 'verbose_name': '训练记录', + 'verbose_name_plural': '训练记录', + 'db_table': 'training_record', + }, + ), + ] diff --git a/apps/training/migrations/0002_initial.py b/apps/training/migrations/0002_initial.py index 08d5aa9..8a22bd0 100644 --- a/apps/training/migrations/0002_initial.py +++ b/apps/training/migrations/0002_initial.py @@ -1,39 +1,39 @@ -# Generated by Django 6.0.5 on 2026-05-26 07:02 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('case', '0002_initial'), - ('training', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='trainingrecord', - name='teacher', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supervised_records', to=settings.AUTH_USER_MODEL, verbose_name='带教老师'), - ), - migrations.AddField( - model_name='trainingrecord', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_records', to=settings.AUTH_USER_MODEL, verbose_name='用户'), - ), - migrations.AddField( - model_name='trainingscoredetail', - name='record', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_details', to='training.trainingrecord', verbose_name='训练记录'), - ), - migrations.AddField( - model_name='trainingscoredetail', - name='rule', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='case.scoringrule', verbose_name='评分规则'), - ), - ] +# Generated by Django 6.0.5 on 2026-05-26 07:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('case', '0002_initial'), + ('training', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='trainingrecord', + name='teacher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supervised_records', to=settings.AUTH_USER_MODEL, verbose_name='带教老师'), + ), + migrations.AddField( + model_name='trainingrecord', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_records', to=settings.AUTH_USER_MODEL, verbose_name='用户'), + ), + migrations.AddField( + model_name='trainingscoredetail', + name='record', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_details', to='training.trainingrecord', verbose_name='训练记录'), + ), + migrations.AddField( + model_name='trainingscoredetail', + name='rule', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='case.scoringrule', verbose_name='评分规则'), + ), + ] diff --git a/apps/training/migrations/__init__.py b/apps/training/migrations/__init__.py index c65238f..6eab173 100644 --- a/apps/training/migrations/__init__.py +++ b/apps/training/migrations/__init__.py @@ -1,2 +1,2 @@  - + diff --git a/apps/user/auth/__init__.py b/apps/user/auth/__init__.py index f4fa4cf..6f3630d 100644 --- a/apps/user/auth/__init__.py +++ b/apps/user/auth/__init__.py @@ -1,5 +1,7 @@ from rest_framework_simplejwt.tokens import RefreshToken +from config.exceptions import AppError + ALLOWED_ROLE_TYPES = ('student', 'doctor', 'teacher') @@ -9,17 +11,42 @@ def get_tokens_for_user(user): def build_user_response(user): + inst = user.institution if user.institution_id else None return { 'id': user.id, 'username': user.username, 'phone': user.phone, 'real_name': user.real_name, 'role_type': user.role_type, - 'institution': user.institution.name if user.institution_id else None, + 'institution_code': inst.code if inst else None, + 'institution_name': inst.name if inst else None, 'department': user.department.name if user.department_id else None, } +def resolve_or_create_institution(code, name): + """按机构编码查找,不存在则自动创建。 + + Args: + code: 机构编码(必填,唯一标识) + name: 机构名称(必填,创建时使用) + Returns: + Institution 实例 + """ + from apps.user.models import Institution + + if not code: + raise AppError('USER_INSTITUTION_CODE_REQUIRED', '机构编码不能为空') + if not name: + raise AppError('USER_INSTITUTION_REQUIRED', '机构名称不能为空') + + institution, _ = Institution.objects.get_or_create( + code=code, + defaults={'name': name, 'type': 'hospital'}, + ) + return institution + + def get_client_ip(request): xff = request.META.get('HTTP_X_FORWARDED_FOR') if xff: diff --git a/apps/user/auth/login.py b/apps/user/auth/login.py index 1696baa..962195f 100644 --- a/apps/user/auth/login.py +++ b/apps/user/auth/login.py @@ -10,8 +10,11 @@ from drf_spectacular.utils import extend_schema, inline_serializer from config.exceptions import AppError from apps.user.models import User -from apps.user.audit import log_login_success, log_login_fail -from apps.user.auth import get_tokens_for_user, build_user_response, get_client_ip, get_user_agent +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, +) LOGIN_FAIL_MAX = 5 LOGIN_FAIL_LOCK_SECONDS = 15 * 60 # 15 分钟 @@ -89,24 +92,40 @@ def login_password(request): }) -# ── U4 验证码登录 ──────────────────────────────────────────────────────────── +# ── U4 验证码登录(自动注册)──────────────────────────────────────────────── + +_LOGIN_CODE_RESPONSE = inline_serializer('LoginCodeResponse', fields={ + 'message': drf_serializers.CharField(), + 'user': drf_serializers.DictField(help_text='用户基本信息'), + 'tokens': drf_serializers.DictField(help_text='access + refresh'), + 'is_new_user': drf_serializers.BooleanField(help_text='是否为新注册用户'), +}) + @extend_schema( - summary='U4 验证码登录', + summary='U4 验证码登录(未注册自动注册)', + description='前端一键登录/注册:验证码校验通过后,若手机号未注册则自动创建账号(角色=student,无密码)。' + '机构不存在会自动创建。', 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='机构名称(必填)'), }), - responses={200: _LOGIN_RESPONSE}, + responses={200: _LOGIN_CODE_RESPONSE, 201: _LOGIN_CODE_RESPONSE}, tags=['认证'], ) @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() + + # ── 入参校验 ────────────────────────────────────────────────────────────── if not re.match(r'^1[3-9]\d{9}$', phone): raise AppError('SMS_INVALID_PHONE', '手机号格式不合法') @@ -114,16 +133,14 @@ def login_code(request): if not code: raise AppError('AUTH_CODE_INVALID', '请输入验证码') - # 查找用户 - try: - user = User.objects.select_related('institution', 'department').get(phone=phone) - except User.DoesNotExist: - raise AppError('AUTH_PHONE_NOT_FOUND', '手机号未注册') + if not institution_code: + raise AppError('USER_INSTITUTION_CODE_REQUIRED', '机构编码不能为空') - if user.status == 0: - raise AppError('AUTH_ACCOUNT_DISABLED', '账号已被禁用,请联系管理员', status_code=403) + if not institution_name: + raise AppError('USER_INSTITUTION_REQUIRED', '机构名称不能为空') + + # ── 校验验证码(先校验再查用户,避免未注册用户也暴露手机号状态)─────────── - # 校验验证码 cache_key = f'sms:login:{phone}' cached_code = cache.get(cache_key) if not cached_code: @@ -131,20 +148,56 @@ def login_code(request): if str(cached_code) != code: raise AppError('AUTH_CODE_MISMATCH', '验证码错误') - # 成功:清理验证码 + 清理密码失败计数 + # 验证码一次性使用 cache.delete(cache_key) cache.delete(f'login_fail:{phone}') - user.last_login_time = timezone.now() - user.save(update_fields=['last_login_time']) + # ── 解析 / 创建机构 ────────────────────────────────────────────────────── + + institution = resolve_or_create_institution(institution_code, institution_name) + + # ── 查找用户 or 自动注册 ──────────────────────────────────────────────── - tokens = get_tokens_for_user(user) ip = get_client_ip(request) ua = get_user_agent(request) + + from django.db import IntegrityError + + try: + user = User.objects.select_related('institution', 'department').get(phone=phone) + is_new = False + except User.DoesNotExist: + try: + user = User.objects.create_user( + username=phone, + password=None, + phone=phone, + role_type='student', + institution=institution, + status=1, + ) + is_new = True + except IntegrityError: + user = User.objects.select_related('institution', 'department').get(phone=phone) + is_new = False + + if is_new: + log_register(user.id, phone) + else: + if user.status == 0: + raise AppError('AUTH_ACCOUNT_DISABLED', '账号已被禁用,请联系管理员', status_code=403) + if user.institution_id != institution.id: + user.institution = institution + + user.last_login_time = timezone.now() + user.save(update_fields=['institution', 'last_login_time']) + + tokens = get_tokens_for_user(user) log_login_success(user.id, phone, ip=ip, ua=ua) return Response({ - 'message': '登录成功', + 'message': '注册并登录成功' if is_new else '登录成功', 'user': build_user_response(user), 'tokens': tokens, - }) + 'is_new_user': is_new, + }, status=201 if is_new else 200) diff --git a/apps/user/auth/register.py b/apps/user/auth/register.py index dabf37f..b3bbcf9 100644 --- a/apps/user/auth/register.py +++ b/apps/user/auth/register.py @@ -1,7 +1,5 @@ import re -from django.core.cache import cache -from django.conf import settings from django.db import transaction, IntegrityError from rest_framework import status from rest_framework.decorators import api_view, permission_classes, throttle_classes @@ -11,24 +9,29 @@ 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, Institution, Department +from apps.user.models import User, Department from apps.user.throttling import RegisterIpThrottle -from apps.user.utils.password import validate_password_strength from apps.user.audit import log_register -from apps.user.auth import get_tokens_for_user, build_user_response, ALLOWED_ROLE_TYPES +from apps.user.auth import ( + get_tokens_for_user, build_user_response, ALLOWED_ROLE_TYPES, + resolve_or_create_institution, +) + +DEFAULT_PASSWORD_PREFIX = 'Pass' @extend_schema( - summary='U2 用户注册', + summary='U2 管理员代注册', + description='CMS 管理员为他人注册账号,无需验证码。默认密码为 Pass+手机号(如 Pass13800001001),用户可自行修改。' + '机构不存在会自动创建。', request=inline_serializer('RegisterRequest', fields={ 'phone': drf_serializers.CharField(help_text='手机号'), - 'code': drf_serializers.CharField(help_text='6 位短信验证码'), - 'password': drf_serializers.CharField(help_text='密码(>=6 位,含字母+数字)'), 'real_name': drf_serializers.CharField(help_text='真实姓名'), 'role_type': drf_serializers.ChoiceField( choices=['student', 'doctor', 'teacher'], required=False, default='student', help_text='角色类型'), - 'institution_name': drf_serializers.CharField(required=False, 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={ @@ -39,18 +42,17 @@ from apps.user.auth import get_tokens_for_user, build_user_response, ALLOWED_ROL tags=['认证'], ) @api_view(['POST']) -@permission_classes([AllowAny]) +@permission_classes([AllowAny]) # TODO: 上线前改为管理员权限 @throttle_classes([RegisterIpThrottle]) def register(request): - """U2 用户注册(手机号 + 验证码 + 密码)""" + """U2 管理员代注册(手机号 + 密码,无需验证码)""" data = request.data phone = str(data.get('phone', '')) - code = str(data.get('code', '')) - password = str(data.get('password', '')) real_name = str(data.get('real_name', '')) role_type = str(data.get('role_type', 'student')) - institution_name = data.get('institution_name') or '' + institution_code = str(data.get('institution_code', '')).strip() + institution_name = str(data.get('institution_name', '')).strip() department_name = data.get('department_name') or '' # ── 入参校验 ────────────────────────────────────────────────────────────── @@ -58,52 +60,33 @@ def register(request): if not re.match(r'^1[3-9]\d{9}$', phone): raise AppError('SMS_INVALID_PHONE', '手机号格式不合法') - if not code or len(code) != 6 or not code.isdigit(): - raise AppError('AUTH_CODE_INVALID', '验证码必须为 6 位数字') - 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') - # ── 密码强度 ────────────────────────────────────────────────────────────── + if not institution_code: + raise AppError('USER_INSTITUTION_CODE_REQUIRED', '机构编码不能为空') - pwd_errors = validate_password_strength(password, phone=phone, real_name=real_name) - if pwd_errors: - raise AppError('AUTH_PASSWORD_WEAK', pwd_errors[0], details=pwd_errors) + if not institution_name: + raise AppError('USER_INSTITUTION_REQUIRED', '机构名称不能为空') - # ── 验证码校验 ──────────────────────────────────────────────────────────── - - cache_key = f'sms:register:{phone}' - cached_code = cache.get(cache_key) - if not cached_code: - raise AppError('AUTH_CODE_EXPIRED', '验证码已过期或未发送') - if str(cached_code) != code: - raise AppError('AUTH_CODE_MISMATCH', '验证码错误') + password = f'{DEFAULT_PASSWORD_PREFIX}{phone}' # ── 机构 / 科室解析 ────────────────────────────────────────────────────── - institution = None - if institution_name: - try: - institution = Institution.objects.get(name=institution_name) - except Institution.DoesNotExist: - raise AppError('USER_INSTITUTION_NOT_FOUND', f'机构"{institution_name}"不存在') - except Institution.MultipleObjectsReturned: - raise AppError('USER_INSTITUTION_AMBIGUOUS', f'存在多个同名机构"{institution_name}"') + institution = resolve_or_create_institution(institution_code, institution_name) department = None if department_name: - qs = Department.objects.filter(name=department_name) - if institution: - qs = qs.filter(institution=institution) + qs = Department.objects.filter(name=department_name, institution=institution) cnt = qs.count() if cnt == 0: raise AppError('USER_DEPARTMENT_NOT_FOUND', f'科室"{department_name}"不存在') if cnt > 1: raise AppError('USER_DEPARTMENT_AMBIGUOUS', - f'科室"{department_name}"不唯一,请同时指定 institution_name') + f'科室"{department_name}"不唯一') department = qs.first() # ── 事务内创建用户 ──────────────────────────────────────────────────────── @@ -130,7 +113,6 @@ def register(request): # ── 善后 ────────────────────────────────────────────────────────────────── - cache.delete(cache_key) tokens = get_tokens_for_user(user) log_register(user.id, phone) diff --git a/apps/user/auth/send_code.py b/apps/user/auth/send_code.py index bf2fc11..65df63a 100644 --- a/apps/user/auth/send_code.py +++ b/apps/user/auth/send_code.py @@ -44,7 +44,8 @@ def send_code(request): user_exists = User.objects.filter(phone=phone).exists() if scene == 'register' and user_exists: raise AppError('AUTH_PHONE_REGISTERED', '该手机号已注册') - if scene in ('login', 'reset') and not user_exists: + # scene='login':不检查是否已注册(未注册用户通过 login-code 自动注册) + if scene == 'reset' and not user_exists: raise AppError('AUTH_PHONE_NOT_FOUND', '手机号未注册') code = generate_sms_code() diff --git a/apps/user/management/__init__.py b/apps/user/management/__init__.py index 423df72..cf35a67 100644 --- a/apps/user/management/__init__.py +++ b/apps/user/management/__init__.py @@ -1 +1 @@ -# init +# init diff --git a/apps/user/management/commands/__init__.py b/apps/user/management/commands/__init__.py index 423df72..cf35a67 100644 --- a/apps/user/management/commands/__init__.py +++ b/apps/user/management/commands/__init__.py @@ -1 +1 @@ -# init +# init diff --git a/apps/user/migrations/0001_initial.py b/apps/user/migrations/0001_initial.py index 7192a93..881e9ac 100644 --- a/apps/user/migrations/0001_initial.py +++ b/apps/user/migrations/0001_initial.py @@ -1,128 +1,128 @@ -# Generated by Django 6.0.5 on 2026-05-26 07:02 - -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='Institution', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=255, verbose_name='名称')), - ('type', models.CharField(choices=[('hospital', '医院'), ('school', '学校')], max_length=30, verbose_name='类型')), - ('level', models.CharField(blank=True, max_length=30, verbose_name='等级')), - ('province', models.CharField(blank=True, max_length=50, verbose_name='省份')), - ('city', models.CharField(blank=True, max_length=50, verbose_name='城市')), - ], - options={ - 'verbose_name': '机构', - 'verbose_name_plural': '机构', - 'db_table': 'institution', - }, - ), - migrations.CreateModel( - name='Role', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('role_code', models.CharField(max_length=50, unique=True, verbose_name='角色编码')), - ('role_name', models.CharField(max_length=50, verbose_name='角色名称')), - ], - options={ - 'verbose_name': '角色', - 'verbose_name_plural': '角色', - 'db_table': 'role', - }, - ), - migrations.CreateModel( - name='Department', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=100, verbose_name='科室名称')), - ('category', models.CharField(blank=True, max_length=50, verbose_name='科室分类')), - ('institution', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.institution', verbose_name='所属机构')), - ], - options={ - 'verbose_name': '科室', - 'verbose_name_plural': '科室', - 'db_table': 'department', - }, - ), - migrations.CreateModel( - name='User', - fields=[ - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('username', models.CharField(max_length=50, unique=True, verbose_name='用户名')), - ('password', models.CharField(max_length=255, verbose_name='密码')), - ('real_name', models.CharField(blank=True, max_length=50, verbose_name='真实姓名')), - ('phone', models.CharField(blank=True, max_length=20, verbose_name='手机号')), - ('avatar', models.CharField(blank=True, max_length=255, verbose_name='头像')), - ('gender', models.SmallIntegerField(choices=[(0, '未知'), (1, '男'), (2, '女')], default=0, verbose_name='性别')), - ('role_type', models.CharField(blank=True, max_length=30, verbose_name='主角色')), - ('title_name', models.CharField(blank=True, max_length=50, verbose_name='职称')), - ('major', models.CharField(blank=True, max_length=100, verbose_name='专业')), - ('training_stage', models.CharField(blank=True, max_length=50, verbose_name='培训阶段')), - ('learning_target', models.CharField(blank=True, max_length=255, verbose_name='学习目标')), - ('competency_profile', models.JSONField(blank=True, default=dict, verbose_name='能力画像')), - ('weak_dimensions', models.JSONField(blank=True, default=list, verbose_name='薄弱项')), - ('strong_dimensions', models.JSONField(blank=True, default=list, verbose_name='优势项')), - ('ai_preference', models.JSONField(blank=True, default=dict, verbose_name='AI训练偏好')), - ('total_training_count', models.IntegerField(default=0, verbose_name='总训练次数')), - ('total_case_count', models.IntegerField(default=0, verbose_name='完成病例数')), - ('current_level', models.CharField(blank=True, max_length=30, verbose_name='当前能力等级')), - ('status', models.SmallIntegerField(choices=[(0, '禁用'), (1, '正常')], default=1, verbose_name='状态')), - ('last_login_time', models.DateTimeField(blank=True, null=True, verbose_name='最后登录')), - ('is_staff', models.BooleanField(default=False, verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user.department', verbose_name='所属科室')), - ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user.institution', verbose_name='所属机构')), - ], - options={ - 'verbose_name': '用户', - 'verbose_name_plural': '用户', - 'db_table': 'user', - }, - ), - migrations.CreateModel( - name='TeacherStudentRelation', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('relation_type', models.CharField(blank=True, max_length=30, verbose_name='关系类型')), - ('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')), - ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')), - ('status', models.SmallIntegerField(choices=[(0, '已结束'), (1, '进行中')], default=1, verbose_name='状态')), - ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_relations', to=settings.AUTH_USER_MODEL, verbose_name='学员')), - ('teacher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teacher_relations', to=settings.AUTH_USER_MODEL, verbose_name='带教老师')), - ], - options={ - 'verbose_name': '师生关系', - 'verbose_name_plural': '师生关系', - 'db_table': 'teacher_student_relation', - }, - ), - ] +# Generated by Django 6.0.5 on 2026-05-26 07:02 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Institution', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, verbose_name='名称')), + ('type', models.CharField(choices=[('hospital', '医院'), ('school', '学校')], max_length=30, verbose_name='类型')), + ('level', models.CharField(blank=True, max_length=30, verbose_name='等级')), + ('province', models.CharField(blank=True, max_length=50, verbose_name='省份')), + ('city', models.CharField(blank=True, max_length=50, verbose_name='城市')), + ], + options={ + 'verbose_name': '机构', + 'verbose_name_plural': '机构', + 'db_table': 'institution', + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('role_code', models.CharField(max_length=50, unique=True, verbose_name='角色编码')), + ('role_name', models.CharField(max_length=50, verbose_name='角色名称')), + ], + options={ + 'verbose_name': '角色', + 'verbose_name_plural': '角色', + 'db_table': 'role', + }, + ), + migrations.CreateModel( + name='Department', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, verbose_name='科室名称')), + ('category', models.CharField(blank=True, max_length=50, verbose_name='科室分类')), + ('institution', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.institution', verbose_name='所属机构')), + ], + options={ + 'verbose_name': '科室', + 'verbose_name_plural': '科室', + 'db_table': 'department', + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('username', models.CharField(max_length=50, unique=True, verbose_name='用户名')), + ('password', models.CharField(max_length=255, verbose_name='密码')), + ('real_name', models.CharField(blank=True, max_length=50, verbose_name='真实姓名')), + ('phone', models.CharField(blank=True, max_length=20, verbose_name='手机号')), + ('avatar', models.CharField(blank=True, max_length=255, verbose_name='头像')), + ('gender', models.SmallIntegerField(choices=[(0, '未知'), (1, '男'), (2, '女')], default=0, verbose_name='性别')), + ('role_type', models.CharField(blank=True, max_length=30, verbose_name='主角色')), + ('title_name', models.CharField(blank=True, max_length=50, verbose_name='职称')), + ('major', models.CharField(blank=True, max_length=100, verbose_name='专业')), + ('training_stage', models.CharField(blank=True, max_length=50, verbose_name='培训阶段')), + ('learning_target', models.CharField(blank=True, max_length=255, verbose_name='学习目标')), + ('competency_profile', models.JSONField(blank=True, default=dict, verbose_name='能力画像')), + ('weak_dimensions', models.JSONField(blank=True, default=list, verbose_name='薄弱项')), + ('strong_dimensions', models.JSONField(blank=True, default=list, verbose_name='优势项')), + ('ai_preference', models.JSONField(blank=True, default=dict, verbose_name='AI训练偏好')), + ('total_training_count', models.IntegerField(default=0, verbose_name='总训练次数')), + ('total_case_count', models.IntegerField(default=0, verbose_name='完成病例数')), + ('current_level', models.CharField(blank=True, max_length=30, verbose_name='当前能力等级')), + ('status', models.SmallIntegerField(choices=[(0, '禁用'), (1, '正常')], default=1, verbose_name='状态')), + ('last_login_time', models.DateTimeField(blank=True, null=True, verbose_name='最后登录')), + ('is_staff', models.BooleanField(default=False, verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user.department', verbose_name='所属科室')), + ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user.institution', verbose_name='所属机构')), + ], + options={ + 'verbose_name': '用户', + 'verbose_name_plural': '用户', + 'db_table': 'user', + }, + ), + migrations.CreateModel( + name='TeacherStudentRelation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('relation_type', models.CharField(blank=True, max_length=30, verbose_name='关系类型')), + ('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')), + ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')), + ('status', models.SmallIntegerField(choices=[(0, '已结束'), (1, '进行中')], default=1, verbose_name='状态')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_relations', to=settings.AUTH_USER_MODEL, verbose_name='学员')), + ('teacher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teacher_relations', to=settings.AUTH_USER_MODEL, verbose_name='带教老师')), + ], + options={ + 'verbose_name': '师生关系', + 'verbose_name_plural': '师生关系', + 'db_table': 'teacher_student_relation', + }, + ), + ] diff --git a/apps/user/migrations/0002_user_phone_unique.py b/apps/user/migrations/0002_user_phone_unique.py index 42ee7f2..b8eb3fb 100644 --- a/apps/user/migrations/0002_user_phone_unique.py +++ b/apps/user/migrations/0002_user_phone_unique.py @@ -1,18 +1,18 @@ -# Generated by Django 5.2.14 on 2026-05-28 01:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='phone', - field=models.CharField(blank=True, max_length=20, unique=True, verbose_name='手机号'), - ), - ] +# Generated by Django 5.2.14 on 2026-05-28 01:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='phone', + field=models.CharField(blank=True, max_length=20, unique=True, verbose_name='手机号'), + ), + ] diff --git a/apps/user/migrations/0003_institution_add_code_field.py b/apps/user/migrations/0003_institution_add_code_field.py new file mode 100644 index 0000000..f6e0102 --- /dev/null +++ b/apps/user/migrations/0003_institution_add_code_field.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-06-01 09:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0002_user_phone_unique'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='code', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='机构编码'), + ), + migrations.AlterField( + model_name='institution', + name='type', + field=models.CharField(blank=True, default='hospital', max_length=30, verbose_name='类型'), + ), + ] diff --git a/apps/user/migrations/0004_institution_code_unique.py b/apps/user/migrations/0004_institution_code_unique.py new file mode 100644 index 0000000..f1ebcbf --- /dev/null +++ b/apps/user/migrations/0004_institution_code_unique.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +def populate_code(apps, schema_editor): + """为已有机构填充 code = 'INST-{pk}'。""" + Institution = apps.get_model('user', 'Institution') + for inst in Institution.objects.filter(code__isnull=True): + inst.code = f'INST-{inst.pk}' + inst.save(update_fields=['code']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0003_institution_add_code_field'), + ] + + operations = [ + migrations.RunPython(populate_code, migrations.RunPython.noop), + migrations.AlterField( + model_name='institution', + name='code', + field=models.CharField(max_length=100, unique=True, verbose_name='机构编码'), + ), + ] diff --git a/apps/user/migrations/__init__.py b/apps/user/migrations/__init__.py index c65238f..6eab173 100644 --- a/apps/user/migrations/__init__.py +++ b/apps/user/migrations/__init__.py @@ -1,2 +1,2 @@  - + diff --git a/apps/user/models.py b/apps/user/models.py index 26caf88..89e7ae5 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -129,15 +129,12 @@ class TeacherStudentRelation(BaseModel): class Institution(BaseModel): - """医院/学校表""" - TYPE_CHOICES = [ - ('hospital', '医院'), - ('school', '学校'), - ] + """医疗机构表""" id = models.BigAutoField(primary_key=True) + code = models.CharField('机构编码', max_length=100, unique=True) name = models.CharField('名称', max_length=255) - type = models.CharField('类型', max_length=30, choices=TYPE_CHOICES) + type = models.CharField('类型', max_length=30, default='hospital', blank=True) level = models.CharField('等级', max_length=30, blank=True) province = models.CharField('省份', max_length=50, blank=True) city = models.CharField('城市', max_length=50, blank=True) diff --git a/apps/user/utils/__init__.py b/apps/user/utils/__init__.py index c18ebbb..0aae1ec 100644 --- a/apps/user/utils/__init__.py +++ b/apps/user/utils/__init__.py @@ -1 +1 @@ -# utils +# utils diff --git a/apps/user/utils/sms.py b/apps/user/utils/sms.py index ef3eb8b..0d04780 100644 --- a/apps/user/utils/sms.py +++ b/apps/user/utils/sms.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__name__) def generate_sms_code(length=6) -> str: + if getattr(settings, 'SMS_PROVIDER', 'mock') == 'mock': + return getattr(settings, 'SMS_MOCK_CODE', '123456') return ''.join(random.choices(string.digits, k=length)) diff --git a/config/asgi.py b/config/asgi.py index ffbb5f5..18377bb 100644 --- a/config/asgi.py +++ b/config/asgi.py @@ -1,16 +1,16 @@ -""" -ASGI config for config project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - -application = get_asgi_application() +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/health.py b/config/health.py new file mode 100644 index 0000000..68e60e7 --- /dev/null +++ b/config/health.py @@ -0,0 +1,27 @@ +"""健康检查:服务存活与数据库连通性。""" + +from django.db import connection +from django.db.utils import OperationalError +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +@api_view(['GET', 'HEAD']) +@permission_classes([AllowAny]) +def ping(request): + """服务存活探测,通则返回 ok。""" + return Response('ok') + + +@api_view(['GET', 'HEAD']) +@permission_classes([AllowAny]) +def test_mysql(request): + """MySQL 连通探测:使用 Django 连接池建立连接,通则 ok,否则返回没连通。""" + try: + connection.ensure_connection() + except OperationalError: + return Response('没连通', status=503) + except Exception: + return Response('没连通', status=503) + return Response('ok') diff --git a/config/middleware.py b/config/middleware.py index 71b65fc..cee48c1 100644 --- a/config/middleware.py +++ b/config/middleware.py @@ -12,7 +12,10 @@ import time api_logger = logging.getLogger('api_access') # 跳过日志的路径前缀(静态文件、admin 等) -_SKIP_PREFIXES = ('/static/', '/admin/', '/api/schema/', '/api/docs/') +_SKIP_PREFIXES = ( + '/static/', '/admin/', '/api/schema/', '/api/docs/', + '/api/ping/', '/api/testmysql/', +) # 请求体/响应体最大记录长度(字符) _MAX_BODY_LEN = 2000 diff --git a/config/settings.py b/config/settings.py index baac3f2..da83cde 100644 --- a/config/settings.py +++ b/config/settings.py @@ -10,7 +10,12 @@ SECRET_KEY = 'django-insecure-!-mtect5n-yyxkp2m=j(8dz_yi$b3w3ddo&w#i(@4kv-spdthy DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = [ + "127.0.0.1", + "localhost", + "192.168.2.76", + "8.160.178.88" +] INSTALLED_APPS = [ @@ -160,6 +165,7 @@ CACHES = { SMS_CODE_EXPIRE = 300 # 验证码有效期(秒) SMS_CODE_INTERVAL = 60 # 发送间隔(秒) SMS_PROVIDER = os.getenv('SMS_PROVIDER', 'mock') +SMS_MOCK_CODE = os.getenv('SMS_MOCK_CODE', '123456') # mock 模式下固定验证码 ALIYUN_SMS_ACCESS_KEY_ID = os.getenv('ALIYUN_SMS_ACCESS_KEY_ID', '') ALIYUN_SMS_ACCESS_KEY_SECRET = os.getenv('ALIYUN_SMS_ACCESS_KEY_SECRET', '') diff --git a/config/urls.py b/config/urls.py index 42871fe..3001d3c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,38 +1,44 @@ -""" -URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/6.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import path, include -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView - -urlpatterns = [ - path('admin/', admin.site.urls), - - # API Routes - path('api/user/', include('apps.user.urls')), - path('api/case/', include('apps.case.urls')), - path('api/training/', include('apps.training.urls')), - - # JWT Token - path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), - path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - - # API Documentation - path('api/schema/', SpectacularAPIView.as_view(), name='schema'), - path('api/docs/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), - path('api/docs/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), -] +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + +from config.health import ping, test_mysql + +urlpatterns = [ + path('admin/', admin.site.urls), + + # 健康检查(无需登录) + path('api/ping/', ping), + path('api/testmysql/', test_mysql), + + # API Routes + path('api/user/', include('apps.user.urls')), + path('api/case/', include('apps.case.urls')), + path('api/training/', include('apps.training.urls')), + + # JWT Token + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + + # API Documentation + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/docs/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), +] diff --git a/config/wsgi.py b/config/wsgi.py index 4ced574..5165ee9 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -1,16 +1,16 @@ -""" -WSGI config for config project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - -application = get_wsgi_application() +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py index 8e7ac79..192aaab 100644 --- a/manage.py +++ b/manage.py @@ -1,22 +1,22 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/prompts/case_teaching_full.md b/prompts/case_teaching_full.md index a82150f..da60246 100644 --- a/prompts/case_teaching_full.md +++ b/prompts/case_teaching_full.md @@ -29,6 +29,7 @@ "estimated_minutes": 45, "osce_enabled": false, "department_name": "呼吸内科", + "exam_items": [], "teaching": { "teaching_goal": "教学目标", "discussion_questions": "讨论问题", @@ -51,5 +52,6 @@ - `discussion_questions`:提取讨论题目,多个用换行分隔。 - `teacher_guide`:提取教师引导要点。 - `scoring_focus`:提取评分重点关注方向。 -8. 不要生成 `scoring_rules`、`stages` 等字段。 -9. 输出必须是合法 JSON,不要包含注释或 markdown 代码块标记。 +8. `exam_items`:规则同传统病例;无检查数据时填 `[]`。 +9. 不要生成 `scoring_rules`、`stages` 等字段。 +10. 输出必须是合法 JSON,不要包含注释或 markdown 代码块标记。 diff --git a/prompts/case_traditional_full.md b/prompts/case_traditional_full.md index e54bd08..cc95c38 100644 --- a/prompts/case_traditional_full.md +++ b/prompts/case_traditional_full.md @@ -29,6 +29,20 @@ "estimated_minutes": 30, "osce_enabled": false, "department_name": "呼吸内科", + "exam_items": [ + { + "item_code": "blood_routine", + "item_name": "血常规", + "item_type": "lab", + "category": "实验室检查", + "result_text": "WBC 12.5×10^9/L,中性粒细胞比例 72%。", + "result_structured": {"wbc": "12.5×10^9/L", "neutrophil": "72%"}, + "is_key": true, + "is_abnormal": true, + "score_weight": 1.0, + "display_order": 1 + } + ], "traditional": { "standard_diagnosis": "标准诊断", "standard_treatment": "标准治疗方案", @@ -49,5 +63,6 @@ - `standard_diagnosis`:从原文提取或推断标准诊断。 - `standard_treatment`:从原文提取标准治疗方案。 - `guideline_reference`:引用相关临床指南名称。 -8. 不要生成 `scoring_rules`、`stages` 等字段。 -9. 输出必须是合法 JSON,不要包含注释或 markdown 代码块标记。 +8. `exam_items`:从 PDF 中提取辅助检查/检验/影像/生命体征等可结构化项目;`item_code` 使用英文蛇形命名且同一输出内不重复;PDF 中无检查数据时填 `[]`。 +9. 不要生成 `scoring_rules`、`stages` 等字段。 +10. 输出必须是合法 JSON,不要包含注释或 markdown 代码块标记。 diff --git a/readme.md b/readme.md index dffbc31..3816f48 100644 --- a/readme.md +++ b/readme.md @@ -1,19 +1,19 @@ -### 数据库迁移 -```bash -python manage.py migrate -``` - -### 创建管理员 -```bash -python manage.py createsuperuser -``` - -### 启动服务 -```bash -python manage.py runserver -``` - -### 访问 API 文档 -- Swagger UI: http://localhost:8000/api/docs/swagger/ -- ReDoc: http://localhost:8000/api/docs/redoc/ -- 管理后台: http://localhost:8000/admin/ +### 数据库迁移 +```bash +python manage.py migrate +``` + +### 创建管理员 +```bash +python manage.py createsuperuser +``` + +### 启动服务 +```bash +python manage.py runserver +``` + +### 访问 API 文档 +- Swagger UI: http://localhost:8000/api/docs/swagger/ +- ReDoc: http://localhost:8000/api/docs/redoc/ +- 管理后台: http://localhost:8000/admin/ diff --git a/test/conftest.py b/test/conftest.py index fc635ed..0f7bcdc 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -59,6 +59,12 @@ def inject_sms_code(phone, scene, code='123456'): cache.set(f'sms:{scene}:{phone}', code, timeout=300) +# ─── 默认测试机构 ───────────────────────────────────────────────────────────── + +DEFAULT_INSTITUTION_CODE = 'TEST-HOSP-001' +DEFAULT_INSTITUTION_NAME = '测试医院' + + # ─── 用户工具 ───────────────────────────────────────────────────────────────── def create_test_user(phone='13900000001', password='TestPass1', @@ -103,10 +109,10 @@ def create_teacher_student_relation(teacher, student, status=1): # ─── 科室工具 ───────────────────────────────────────────────────────────────── -def ensure_institution(name='测试医院'): +def ensure_institution(name='测试医院', code='TEST-HOSP-001'): inst, _ = Institution.objects.get_or_create( - name=name, - defaults={'type': 'hospital', 'province': '北京', 'city': '北京'}, + code=code, + defaults={'name': name, 'type': 'hospital', 'province': '北京', 'city': '北京'}, ) return inst @@ -122,7 +128,25 @@ def ensure_department(name='儿科', institution_name='测试医院'): # ─── 病例载荷构建 ───────────────────────────────────────────────────────────── -def build_traditional_payload(department_name='儿科', scoring_rules_count=2): +def sample_exam_items(): + """示例检查项(用于 C3 full-create)。""" + return [ + { + 'item_code': 'blood_routine', + 'item_name': '血常规', + 'item_type': 'lab', + 'category': '实验室检查', + 'result_text': 'WBC 10×10^9/L', + 'result_structured': {'wbc': '10×10^9/L'}, + 'is_key': True, + 'is_abnormal': False, + 'score_weight': 1.0, + 'display_order': 1, + }, + ] + + +def build_traditional_payload(department_name='儿科', scoring_rules_count=2, with_exam_items=False): """构建合法的传统病例 full-create 载荷。""" rules = [ { @@ -133,7 +157,7 @@ def build_traditional_payload(department_name='儿科', scoring_rules_count=2): } for i in range(scoring_rules_count) ] - return { + payload = { 'title': '测试传统病例-表单录入', 'case_type': 'traditional', 'difficulty': 'medium', @@ -152,6 +176,9 @@ def build_traditional_payload(department_name='儿科', scoring_rules_count=2): }, 'scoring_rules': rules, } + if with_exam_items: + payload['exam_items'] = sample_exam_items() + return payload def build_teaching_payload(department_name='儿科', scoring_rules_count=2): @@ -206,6 +233,19 @@ MOCK_C1_PARSE_RESULT = { 'standard_treatment': 'Mock 对症治疗', 'guideline_reference': 'Mock 指南', }, + 'exam_items': [ + { + 'item_code': 'blood_routine', + 'item_name': '血常规', + 'item_type': 'lab', + 'category': '实验室检查', + 'result_text': 'WBC 10×10^9/L', + 'is_key': True, + 'is_abnormal': False, + 'score_weight': 1.0, + 'display_order': 1, + }, + ], }, 'usage': {'prompt_tokens': 100, 'completion_tokens': 200, 'total_tokens': 300}, } diff --git a/test/manage.py b/test/manage.py index 8e7ac79..192aaab 100644 --- a/test/manage.py +++ b/test/manage.py @@ -1,22 +1,22 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/test/swagger_tryout.py b/test/swagger_tryout.py index 555fc91..f30e207 100644 --- a/test/swagger_tryout.py +++ b/test/swagger_tryout.py @@ -1,7 +1,12 @@ """ Swagger Try-it-out 等效脚本:逐个调用所有接口,验证可达性和基本功能。 运行方式:.venv\\Scripts\\python.exe test/swagger_tryout.py -前提:Django dev server 已在 http://127.0.0.1:8000 运行,Redis 已启动。 +前提:Django dev server 已在 http://127.0.0.1:8000 运行,Redis 已启动;.env 指向目标库。 +认证流程(与当前 API 一致): + U1 发码(login) → U2 管理员代注册(机构编码+名称, 默认密码 Pass+手机号) + → U3 密码登录 → U4 验证码登录(机构信息) → U4-new 新号自动注册 + → U5 重置密码 → U6 改密 → U8 刷新 → /me → U9/U10 → U7 退出 +病例段:C1 解析 exam_items → C3 full-create 写入 case_exam_item(校验响应条数与库表条数) 日志输出:logs/test-swagger-YYYY-MM-DD.log(含完整请求体和响应体) """ @@ -98,6 +103,25 @@ def get_sms_code(phone, scene): return val if val and val != 'None' else None +def count_case_exam_items(case_id): + """查询 medical_platform.case_exam_item 中该病例的检查项条数。""" + val = django_eval( + f'from apps.case.models import CaseExamItem; ' + f'print(CaseExamItem.objects.filter(case_id={case_id}).count())' + ) + return int(val) if val and val.isdigit() else 0 + + +def list_case_exam_item_codes(case_id): + """返回该病例在库中的 item_code 列表(逗号分隔)。""" + val = django_eval( + f'from apps.case.models import CaseExamItem; ' + f'codes = list(CaseExamItem.objects.filter(case_id={case_id}).order_by("display_order", "id").values_list("item_code", flat=True)); ' + f'print(",".join(codes))' + ) + return val or '' + + def inject_sms_code(phone, scene, code='123456'): """手动注入短信验证码到 Redis。""" django_eval( @@ -114,63 +138,126 @@ django_eval('from django.core.cache import cache; cache.clear(); print("OK")') # 删除上次可能残留的测试用户 PHONE = '13700000099' +PHONE_ALT = '13700000098' # U4 未注册自动注册专用 +INST_CODE = 'SWAG_TEST_HOSP' +INST_NAME = 'Swagger测试医院' +# 全库唯一科室名,避免 resolve_department("儿科") 命中多条 → CASE_DEPARTMENT_AMBIGUOUS +DEPT_NAME = 'Swagger儿科' +# C3 手工兜底时的检查项(C1 有解析结果时优先用 AI 的 exam_items) +SAMPLE_EXAM_ITEMS = [ + { + 'item_code': 'blood_routine', + 'item_name': '血常规', + 'item_type': 'lab', + 'category': '实验室检查', + 'result_text': 'WBC 10×10^9/L', + 'result_structured': {'wbc': '10×10^9/L'}, + 'is_key': True, + 'is_abnormal': False, + 'score_weight': 1.0, + 'display_order': 1, + }, + { + 'item_code': 'crp', + 'item_name': 'CRP', + 'item_type': 'lab', + 'category': '实验室检查', + 'result_text': 'CRP 8 mg/L', + 'is_key': False, + 'is_abnormal': False, + 'score_weight': 1.0, + 'display_order': 2, + }, +] django_eval( f'from apps.user.models import User; ' - f'User.objects.filter(phone="{PHONE}").delete(); print("cleaned")' + f'User.objects.filter(phone__in=["{PHONE}","{PHONE_ALT}"]).delete(); print("cleaned")' ) print('[准备] 完成\n') s = requests.Session() -PASSWORD = 'SwagTest1' +# U2 管理员代注册默认密码:Pass + 手机号 +PASSWORD = f'Pass{PHONE}' + + +def _institution_fields(): + return {'institution_code': INST_CODE, 'institution_name': INST_NAME} + # ═══════════════════════════════════════════════════════════════════════════════ section('用户端接口 (U1-U10)') # ═══════════════════════════════════════════════════════════════════════════════ -# U1: 发送验证码 (register) -u1_body = {'phone': PHONE, 'scene': 'register'} +access = '' +refresh = '' +auth = {} + +# U1: 发送验证码(login 场景,未注册用户也可发码) +u1_body = {'phone': PHONE, 'scene': 'login'} r = s.post(f'{BASE}/api/user/auth/send-code/', json=u1_body) log('U1', 'POST', '/api/user/auth/send-code/', 200, r.status_code, req_headers=r.request.headers, req_body=u1_body, resp_headers=dict(r.headers), resp_body=r.json()) -# U2: 注册 -code = get_sms_code(PHONE, 'register') -if not code: - code = inject_sms_code(PHONE, 'register') -u2_body = {'phone': PHONE, 'code': code, 'password': PASSWORD, 'real_name': 'Swagger测试'} +# U2: 管理员代注册(无需验证码,默认密码 Pass+手机号) +u2_body = { + 'phone': PHONE, + 'real_name': 'Swagger测试', + 'role_type': 'student', + **_institution_fields(), +} r = s.post(f'{BASE}/api/user/auth/register/', json=u2_body) log('U2', 'POST', '/api/user/auth/register/', 201, r.status_code, req_headers=r.request.headers, req_body=u2_body, resp_headers=dict(r.headers), resp_body=r.json()) +if r.status_code == 201: + tokens = r.json().get('tokens', {}) + access = tokens.get('access', '') + refresh = tokens.get('refresh', '') -# U3: 密码登录 +# U3: 密码登录(Pass+手机号) u3_body = {'phone': PHONE, 'password': PASSWORD} r = s.post(f'{BASE}/api/user/auth/login/', json=u3_body) log('U3', 'POST', '/api/user/auth/login/', 200, r.status_code, req_headers=r.request.headers, req_body=u3_body, resp_headers=dict(r.headers), resp_body=r.json()) -tokens = r.json().get('tokens', {}) -access = tokens.get('access', '') -refresh = tokens.get('refresh', '') -auth = {'Authorization': f'Bearer {access}'} +if r.status_code == 200: + tokens = r.json().get('tokens', {}) + access = tokens.get('access', '') + refresh = tokens.get('refresh', '') + auth = {'Authorization': f'Bearer {access}'} -# U4: 验证码登录 +# U4: 验证码登录(已注册用户,需机构信息) r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': PHONE, 'scene': 'login'}) login_code = get_sms_code(PHONE, 'login') if not login_code: - login_code = inject_sms_code(PHONE, 'login', '654321') -u4_body = {'phone': PHONE, 'code': login_code} + login_code = inject_sms_code(PHONE, 'login') +u4_body = {'phone': PHONE, 'code': login_code, **_institution_fields()} r = s.post(f'{BASE}/api/user/auth/login-code/', json=u4_body) log('U4', 'POST', '/api/user/auth/login-code/', 200, r.status_code, req_headers=r.request.headers, req_body=u4_body, resp_headers=dict(r.headers), resp_body=r.json()) -if r.status_code == 200: +if r.status_code in (200, 201): tokens = r.json().get('tokens', {}) access = tokens.get('access', access) refresh = tokens.get('refresh', refresh) auth = {'Authorization': f'Bearer {access}'} +# U4-new: 验证码登录自动注册(新手机号 → 201) +django_eval(f'from apps.user.models import User; User.objects.filter(phone="{PHONE_ALT}").delete()') +r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': PHONE_ALT, 'scene': 'login'}) +log('U4-pre', 'POST', '/api/user/auth/send-code/ (alt)', 200, r.status_code, + req_body={'phone': PHONE_ALT, 'scene': 'login'}) +alt_code = get_sms_code(PHONE_ALT, 'login') or inject_sms_code(PHONE_ALT, 'login') +u4_new_body = {'phone': PHONE_ALT, 'code': alt_code, **_institution_fields()} +r = s.post(f'{BASE}/api/user/auth/login-code/', json=u4_new_body) +u4_new_detail = '' +if r.status_code in (200, 201): + u4_new_detail = f'is_new_user={r.json().get("is_new_user")}' +log('U4-new', 'POST', '/api/user/auth/login-code/ (auto-register)', [200, 201], r.status_code, + u4_new_detail, req_body=u4_new_body, resp_body=r.json() if r.headers.get('content-type', '').startswith('application/json') else None) +django_eval(f'from apps.user.models import User; User.objects.filter(phone="{PHONE_ALT}").delete()') + # U5: 重置密码 NEW_PASSWORD = 'SwagNew1' r = s.post(f'{BASE}/api/user/auth/send-code/', json={'phone': PHONE, 'scene': 'reset'}) @@ -332,14 +419,14 @@ tokens = r.json().get('tokens', {}) access = tokens.get('access', '') auth = {'Authorization': f'Bearer {access}'} -# 确保科室存在 +# 确保机构与科室存在(与 U2/U4 同一 institution_code) django_eval( - 'from apps.user.models import Institution, Department; ' - 'inst, _ = Institution.objects.get_or_create(name="测试医院", ' - ' defaults={"type":"hospital","province":"北京","city":"北京"}); ' - 'Department.objects.get_or_create(name="儿科", ' - ' defaults={"institution":inst,"category":"临床"}); ' - 'print("OK")' + f'from apps.user.models import Institution, Department; ' + f'inst, _ = Institution.objects.get_or_create(code="{INST_CODE}", ' + f' defaults={{"name":"{INST_NAME}","type":"hospital","province":"北京","city":"北京"}}); ' + f'Department.objects.get_or_create(name="{DEPT_NAME}", institution=inst, ' + f' defaults={{"category":"临床"}}); ' + f'print("OK")' ) # C1: PDF 解析 — 使用项目真实 PDF 文件 @@ -367,6 +454,12 @@ log('C1', 'POST', '/api/case/cases/parse-pdf/', c1_ok, r.status_code, detail, c1_data = None if r.status_code == 200: c1_data = body.get('data', {}) + if c1_data: + c1_exam = c1_data.get('exam_items') or [] + _write_log( + f' [INFO] C1 解析 exam_items: count={len(c1_exam)}, ' + f'codes={[x.get("item_code") for x in c1_exam if isinstance(x, dict)]}' + ) c2_payload = c1_data if c1_data else { 'title': 'Swagger测试病例', @@ -398,6 +491,7 @@ log('C2', 'POST', '/api/case/cases/generate-scoring-rules/', c2_ok, r.status_cod if c1_data and scoring_rules_from_ai: _write_log(' [INFO] C3 使用 C1 AI 解析 + C2 AI 评分规则组装载荷') payload = {**c1_data} + payload['department_name'] = DEPT_NAME # C1 常返回「儿科」,库内重名会 400 payload['scoring_rules'] = scoring_rules_from_ai else: _write_log(' [INFO] C3 使用手工表单载荷(C1/C2 未全部成功)') @@ -409,7 +503,7 @@ else: 'description': '患儿,男,4 岁,因发热 3 天就诊。', 'patient_age': 4, 'patient_gender': 'male', - 'department_name': '儿科', + 'department_name': DEPT_NAME, 'estimated_minutes': 30, 'osce_enabled': False, 'tags': '儿科,发热', @@ -432,20 +526,56 @@ else: 'scoring_standard': '治疗方案合理', }, ], + 'exam_items': SAMPLE_EXAM_ITEMS, } +payload_exam_items = payload.get('exam_items') or [] +_write_log( + f' [INFO] C3 提交 exam_items: count={len(payload_exam_items)}, ' + f'codes={[x.get("item_code") for x in payload_exam_items if isinstance(x, dict)]}' +) r = s.post(f'{BASE}/api/case/cases/full-create/', json=payload, headers=auth) c3_resp = r.json() if r.headers.get('content-type', '').startswith('application/json') else None log('C3', 'POST', '/api/case/cases/full-create/', 201, r.status_code, req_body=payload, resp_body=c3_resp) case_id = None +expected_exam_count = len(payload_exam_items) if r.status_code == 201: case_id = r.json()['case']['id'] + resp_exam = (c3_resp or {}).get('exam_items', []) + resp_exam_count = len(resp_exam) + log( + 'C3-exam', 'CHECK', f'/api/case/cases/{case_id}/ (resp exam_items)', + expected_exam_count, resp_exam_count, + f'codes={[e.get("item_code") for e in resp_exam]}', + resp_body={'exam_items': resp_exam} if resp_exam else None, + ) + db_exam_count = count_case_exam_items(case_id) + db_codes = list_case_exam_item_codes(case_id) + log( + 'C3-db', 'CHECK', f'case_exam_item case_id={case_id}', + expected_exam_count, db_exam_count, + f'db_codes={db_codes}', + ) +else: + _write_log(' SKIP C3-exam/C3-db — C3 未成功') # C4: GET full if case_id: r = s.get(f'{BASE}/api/case/cases/{case_id}/full/', headers=auth) + c4_detail = '' + c4_resp = None + if r.headers.get('content-type', '').startswith('application/json'): + c4_resp = r.json() + c4_exam = c4_resp.get('exam_items', []) + c4_detail = f'exam_items={len(c4_exam)}(expect={expected_exam_count})' log('C4', 'GET', f'/api/case/cases/{case_id}/full/', 200, r.status_code, - resp_body=r.json() if r.headers.get('content-type', '').startswith('application/json') else None) + c4_detail, resp_body=c4_resp) + if c4_resp is not None: + log( + 'C4-exam', 'CHECK', f'/api/case/cases/{case_id}/full/ exam_items', + expected_exam_count, len(c4_resp.get('exam_items', [])), + f'codes={[e.get("item_code") for e in c4_resp.get("exam_items", [])]}', + ) else: _write_log(' SKIP C4 — C3 未返回 case_id') diff --git a/test/test_case_happy.py b/test/test_case_happy.py index de73536..fcc8b59 100644 --- a/test/test_case_happy.py +++ b/test/test_case_happy.py @@ -4,7 +4,7 @@ from unittest.mock import patch, MagicMock from django.core.cache import cache -from apps.case.models import CaseBase, TraditionalCase, ScoringRule +from apps.case.models import CaseBase, TraditionalCase, ScoringRule, CaseExamItem from apps.user.throttling import PdfParseUserThrottle, ScoringRuleGenerateUserThrottle from .conftest import ( CacheTestCase, @@ -27,7 +27,9 @@ class CaseFormHappyPathTest(CacheTestCase): def test_flow_form_create_read_update(self): """HP-5: C3 full-create → C4 GET full → C5 PATCH → C4 GET verify""" # C3: full-create(2 条评分规则) - payload = build_traditional_payload(department_name='儿科', scoring_rules_count=2) + payload = build_traditional_payload( + department_name='儿科', scoring_rules_count=2, with_exam_items=True, + ) resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json') self.assertEqual(resp.status_code, 201, resp.content) @@ -38,6 +40,8 @@ class CaseFormHappyPathTest(CacheTestCase): self.assertIn('traditional', created) self.assertIsNotNone(created['traditional']) self.assertEqual(len(created['scoring_rules']), 2) + self.assertEqual(len(created['exam_items']), 1) + self.assertEqual(created['exam_items'][0]['item_code'], 'blood_routine') # C4: GET full resp = self.client.get(case_full_url(case_id)) @@ -45,6 +49,7 @@ class CaseFormHappyPathTest(CacheTestCase): full = resp.json() self.assertEqual(full['case']['title'], payload['title']) self.assertEqual(len(full['scoring_rules']), 2) + self.assertEqual(len(full['exam_items']), 1) # C5: PATCH(改标题 + 改子表 + 替换为 1 条评分规则) patch_data = { @@ -77,6 +82,7 @@ class CaseFormHappyPathTest(CacheTestCase): case = CaseBase.objects.get(id=case_id) self.assertEqual(case.title, '更新后的标题') self.assertEqual(ScoringRule.objects.filter(case_id=case_id).count(), 1) + self.assertEqual(CaseExamItem.objects.filter(case_id=case_id).count(), 1) tc = TraditionalCase.objects.get(case_id=case_id) self.assertEqual(tc.standard_diagnosis, '更新后的诊断') @@ -144,6 +150,7 @@ class CasePdfMockHappyPathTest(CacheTestCase): created = resp.json() case_id = created['case']['id'] self.assertEqual(len(created['scoring_rules']), len(scoring_rules)) + self.assertEqual(len(created['exam_items']), 1) # C4: GET full resp = self.client.get(case_full_url(case_id)) diff --git a/test/test_case_negative.py b/test/test_case_negative.py index 9495223..2a23ca3 100644 --- a/test/test_case_negative.py +++ b/test/test_case_negative.py @@ -53,6 +53,19 @@ class CaseFieldValidationTest(CacheTestCase): self.assertEqual(resp.status_code, 400, resp.content) self.assertEqual(resp.json()['code'], 'CASE_SUBTYPE_CONFLICT') + def test_duplicate_exam_items_deduped_on_create(self): + """同一病例重复 item_code:归一化后只保留一条并成功创建。""" + payload = build_traditional_payload(with_exam_items=True) + self.assertIn('exam_items', payload) + payload['exam_items'].append({ + **payload['exam_items'][0], + 'item_name': '重复血常规', + 'result_text': '应被忽略', + }) + resp = self.client.post(CASE_FULL_CREATE_URL, payload, format='json') + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual(len(resp.json()['exam_items']), 1) + def test_missing_subtable_400(self): """N13: case_type=traditional 但无 traditional 子表 → 400 CASE_SUBTYPE_REQUIRED""" payload = build_traditional_payload() diff --git a/test/test_user_happy.py b/test/test_user_happy.py index db31646..32005f9 100644 --- a/test/test_user_happy.py +++ b/test/test_user_happy.py @@ -16,6 +16,7 @@ from .conftest import ( USER_SEND_CODE_URL, USER_REGISTER_URL, USER_LOGIN_URL, USER_LOGIN_CODE_URL, USER_CHANGE_PWD_URL, USER_RESET_PWD_URL, USER_ME_URL, USER_LIST_URL, user_detail_url, + DEFAULT_INSTITUTION_CODE, DEFAULT_INSTITUTION_NAME, inject_sms_code, create_test_user, get_auth_client, get_tokens, create_teacher_student_relation, ) @@ -38,40 +39,32 @@ class UserAuthHappyPathTest(CacheTestCase): # ── HP-1: 注册 → 密码登录 → /me ────────────────────────────────────── def test_flow_register_login_me(self): - """HP-1: U1 send-code(register) → U2 register → U3 login → GET /me""" + """HP-1: U2 register(管理员代注册,默认密码) → U3 login(默认密码) → GET /me""" phone = '13900000001' - password = 'Abc12345' + default_password = f'Pass{phone}' real_name = '张三' with ExitStack() as stack: _bypass_all_auth_throttles(stack) - # U1: send-code (register) - resp = self.client.post(USER_SEND_CODE_URL, { - 'phone': phone, 'scene': 'register', - }) - self.assertEqual(resp.status_code, 200, resp.content) - - # 从 cache 读验证码 - code = cache.get(f'sms:register:{phone}') - self.assertIsNotNone(code, '验证码未写入缓存') - - # U2: register + # U2: register(管理员代注册,无需验证码,密码自动为 Pass+手机号) resp = self.client.post(USER_REGISTER_URL, { 'phone': phone, - 'code': str(code), - 'password': password, 'real_name': real_name, + 'institution_code': DEFAULT_INSTITUTION_CODE, + 'institution_name': DEFAULT_INSTITUTION_NAME, }) self.assertEqual(resp.status_code, 201, resp.content) data = resp.json() self.assertIn('tokens', data) self.assertEqual(data['user']['phone'], phone) self.assertEqual(data['user']['real_name'], real_name) + self.assertEqual(data['user']['institution_name'], DEFAULT_INSTITUTION_NAME) + self.assertEqual(data['user']['institution_code'], DEFAULT_INSTITUTION_CODE) - # U3: login (password) — 限流 bypass 已退出,login 无限流 + # U3: login (默认密码 Pass+手机号) resp = self.client.post(USER_LOGIN_URL, { - 'phone': phone, 'password': password, + 'phone': phone, 'password': default_password, }) self.assertEqual(resp.status_code, 200, resp.content) tokens = resp.json()['tokens'] @@ -102,14 +95,18 @@ class UserAuthHappyPathTest(CacheTestCase): code = cache.get(f'sms:login:{phone}') self.assertIsNotNone(code) - # U4: login-code + # U4: login-code(需要 institution 字段) resp = self.client.post(USER_LOGIN_CODE_URL, { - 'phone': phone, 'code': str(code), + 'phone': phone, + 'code': str(code), + 'institution_name': DEFAULT_INSTITUTION_NAME, + 'institution_code': DEFAULT_INSTITUTION_CODE, }) self.assertEqual(resp.status_code, 200, resp.content) tokens = resp.json()['tokens'] self.assertIn('access', tokens) self.assertIn('refresh', tokens) + self.assertFalse(resp.json()['is_new_user']) # GET /me self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {tokens["access"]}') diff --git a/test/test_user_negative.py b/test/test_user_negative.py index 5dcbfdb..0045da6 100644 --- a/test/test_user_negative.py +++ b/test/test_user_negative.py @@ -12,6 +12,7 @@ from .conftest import ( USER_RESET_PWD_URL, USER_CHANGE_PWD_URL, USER_ME_URL, USER_LOGOUT_URL, USER_REFRESH_URL, USER_LIST_URL, user_detail_url, + DEFAULT_INSTITUTION_CODE, DEFAULT_INSTITUTION_NAME, inject_sms_code, create_test_user, get_auth_client, get_tokens, create_teacher_student_relation, ) @@ -59,38 +60,34 @@ class UserNegativeTest(CacheTestCase): with patch.object(RegisterIpThrottle, 'allow_request', return_value=True): resp = self.client.post(USER_REGISTER_URL, { 'phone': '123', - 'code': '123456', - 'password': 'Abc12345', 'real_name': '测试', + 'institution_code': DEFAULT_INSTITUTION_CODE, + 'institution_name': DEFAULT_INSTITUTION_NAME, }) self.assertEqual(resp.status_code, 400, resp.content) self.assertEqual(resp.json()['code'], 'SMS_INVALID_PHONE') - def test_register_weak_password_400(self): - """N5: 弱密码 → 400 AUTH_PASSWORD_WEAK""" + def test_register_missing_institution_400(self): + """N5: 注册缺少机构编码 → 400 USER_INSTITUTION_CODE_REQUIRED""" phone = '13800001002' - inject_sms_code(phone, 'register') with patch.object(RegisterIpThrottle, 'allow_request', return_value=True): resp = self.client.post(USER_REGISTER_URL, { 'phone': phone, - 'code': '123456', - 'password': '123', - 'real_name': '测试弱密码', + 'real_name': '测试缺机构', }) self.assertEqual(resp.status_code, 400, resp.content) - self.assertEqual(resp.json()['code'], 'AUTH_PASSWORD_WEAK') + self.assertEqual(resp.json()['code'], 'USER_INSTITUTION_CODE_REQUIRED') def test_register_duplicate_phone_400(self): """N6: 已注册手机号再注册 → 400 AUTH_PHONE_REGISTERED""" phone = '13800001003' create_test_user(phone=phone) - inject_sms_code(phone, 'register') with patch.object(RegisterIpThrottle, 'allow_request', return_value=True): resp = self.client.post(USER_REGISTER_URL, { 'phone': phone, - 'code': '123456', - 'password': 'Abc12345', 'real_name': '重复注册', + 'institution_code': DEFAULT_INSTITUTION_CODE, + 'institution_name': DEFAULT_INSTITUTION_NAME, }) self.assertEqual(resp.status_code, 400, resp.content) self.assertEqual(resp.json()['code'], 'AUTH_PHONE_REGISTERED') diff --git a/test/测试文档-D8.md b/test/测试文档-D8.md index fcc3922..86f739b 100644 --- a/test/测试文档-D8.md +++ b/test/测试文档-D8.md @@ -1,6 +1,6 @@ # D8 测试文档 -> 测试日期:2026-05-29(U9/U10 补充) +> 测试日期:2026-05-29(单元测试);Swagger 2026-06-03(25 场景,含 `case_exam_item` 校验) > 测试人员:Claude AI + 人工审核 > 测试环境:Windows / Python 3.14 / Django 5.0 / MySQL 8 / Redis @@ -26,8 +26,8 @@ | 用户域 happy-path | `test_user_happy.py` | 11 | 11 | 0 | | 病例域 happy-path | `test_case_happy.py` | 2 | 2 | 0 | | 用户域 negative | `test_user_negative.py` | 17 | 17 | 0 | -| 病例域 negative | `test_case_negative.py` | 11 | 11 | 0 | -| **合计** | | **41** | **41** | **0** | +| 病例域 negative | `test_case_negative.py` | 12 | 12 | 0 | +| **合计** | | **42** | **42** | **0** | --- @@ -37,8 +37,8 @@ | ID | 测试方法 | 测试什么 | 结果 | |---|---|---|---| -| HP-1 | `test_flow_register_login_me` | **新用户注册全流程**:发送短信验证码 → 用验证码+密码注册账号 → 用密码登录 → 查看个人信息(确认手机号和姓名正确) | PASS | -| HP-2 | `test_flow_code_login` | **验证码登录**:已有账号的用户,发送登录验证码 → 用手机号+验证码登录(不需要密码)→ 查看个人信息确认身份正确 | PASS | +| HP-1 | `test_flow_register_login_me` | **管理员代注册 → 密码登录**:管理员调用注册接口(手机号+姓名+机构,无需验证码,密码自动为 Pass+手机号)→ 用默认密码登录 → 查看个人信息(确认手机号、姓名、机构正确) | PASS | +| HP-2 | `test_flow_code_login` | **验证码登录(已有用户)**:预创建用户 → 发送登录验证码 → 用手机号+验证码+机构信息登录 → 确认 `is_new_user=false` → 查看个人信息确认身份正确 | PASS | | HP-3 | `test_flow_reset_password` | **忘记密码重置**:发送重置验证码 → 用验证码设置新密码 → 用新密码能登录成功 → 用旧密码登录失败(旧密码已失效) | PASS | | HP-4 | `test_flow_change_password` | **登录后修改密码**:先用旧密码登录拿到 token → 调用修改密码接口 → 等 1 秒后旧 token 被系统自动作废(返回 401)→ 用新密码重新登录,新 token 正常可用 | PASS | | HP-5 | `test_admin_list_all_users` | **管理员看用户列表**:创建管理员+2 个学生,管理员调用用户列表接口,确认能看到系统里所有用户(包括自己和两个学生) | PASS | @@ -53,8 +53,8 @@ | ID | 测试方法 | 测试什么 | 结果 | |---|---|---|---| -| HP-5 | `test_flow_form_create_read_update` | **手工录入病例全流程**:用表单数据创建一个传统病例(含 2 条评分规则)→ 查看完整病例确认数据正确 → 修改标题+诊断+减少为 1 条评分规则 → 再次查看确认修改生效 → 检查数据库记录是否一致 | PASS | -| HP-6 | `test_flow_pdf_mock_full_pipeline` | **PDF 上传到创建病例的完整流水线**(AI 部分用 mock 模拟):上传 PDF 文件 → AI 解析出病例结构化数据 → 用解析结果生成评分规则 → 组装数据创建病例 → 查看完整病例 → 修改标题 → 确认修改生效 | PASS | +| HP-5 | `test_flow_form_create_read_update` | **手工录入病例全流程**:表单创建传统病例(含 2 条评分规则 + 1 条 `exam_items`)→ C4 确认 `exam_items` → 改标题/评分规则 → 校验 `case_exam_item` 表条数 | PASS | +| HP-6 | `test_flow_pdf_mock_full_pipeline` | **PDF 流水线(mock AI)**:C1 解析含 `exam_items` → C2 评分规则 → C3 落库 → C4 校验响应中检查项条数 | PASS | --- @@ -68,7 +68,7 @@ | N2 | `test_unauth_change_password_401` | **没登录就想改密码**:不带任何 token 直接调用修改密码接口,系统拒绝并要求先登录 | 401 | PASS | | N3 | `test_unauth_me_401` | **没登录就想看个人信息**:不带 token 调用 /me 接口,系统拒绝 | 401 | PASS | | N4 | `test_register_invalid_phone_400` | **手机号格式错误**:用 "123"(不是 11 位手机号)去注册,系统拒绝并提示手机号不合法 | 400 | PASS | -| N5 | `test_register_weak_password_400` | **密码太简单**:用 "123" 作为密码注册,系统拒绝并提示密码强度不够(要求大小写字母+数字,至少 8 位) | 400 | PASS | +| N5 | `test_register_missing_institution_400` | **注册缺少机构**:管理员注册时不传机构名称,系统拒绝并提示机构名称不能为空 | 400 | PASS | | N6 | `test_register_duplicate_phone_400` | **手机号已被注册**:先创建一个用户,再用同一个手机号注册第二次,系统拒绝并提示该手机号已注册 | 400 | PASS | | N7 | `test_login_wrong_password` | **密码错误**:用正确的手机号但错误的密码登录,系统拒绝并提示账号或密码错误 | 400 | PASS | | N8 | `test_login_account_lock_423` | **连续输错密码被锁定**:连续 5 次输入错误密码,第 6 次登录时系统锁定账号,返回"账号已锁定"(防暴力破解) | 423 | PASS | @@ -82,7 +82,7 @@ | N16 | `test_teacher_view_unrelated_student_403` | **教师不能看非名下学生**:教师试图查看一个和自己没有师生关系的学生的信息,系统拒绝 | 403 | PASS | | N17 | `test_teacher_view_ended_relation_student_403` | **教师不能看已毕业学生**:教师和学生的师生关系已结束(status=0,如学生已毕业),教师再查看该学生详情,系统拒绝 | 403 | PASS | -### 4.2 病例域(11 条) +### 4.2 病例域(12 条) | ID | 测试方法 | 测试什么 | 期望 | 结果 | |---|---|---|---|---| @@ -90,6 +90,7 @@ | N11 | `test_empty_scoring_rules_400` | **评分规则为空**:创建病例时 scoring_rules 传空数组 `[]`,系统要求至少有 1 条评分规则 | 400 | PASS | | N12 | `test_subtable_conflict_400` | **子表类型冲突**:创建传统病例时同时传了 traditional 和 teaching 两个子表数据,系统拒绝(一个病例只能有一种类型的子表) | 400 | PASS | | N13 | `test_missing_subtable_400` | **缺少必要子表**:声明 case_type=traditional 但没有传 traditional 子表数据,系统拒绝(类型和子表必须对应) | 400 | PASS | +| N13b | `test_duplicate_exam_items_deduped_on_create` | **检查项 item_code 重复**:同一 `exam_items` 中重复 `item_code`,归一化后只保留一条并成功创建 | 201 | PASS | | N15 | `test_patch_published_case_400` | **已发布的病例不能编辑**:先创建病例并将其发布(publish_status=1),再尝试修改标题,系统拒绝(发布后不允许编辑) | 400 | PASS | | N14 | `test_unauth_full_create_401` | **没登录不能创建病例**:不带 token 直接调用创建病例接口,系统要求先登录 | 401 | PASS | | N17 | `test_view_other_draft_403` | **不能看别人的草稿**:用户 A 创建了一个草稿病例,用户 B 试图查看,系统拒绝(草稿只有创建者自己能看) | 403 | PASS | @@ -156,6 +157,24 @@ - **影响文件**:`config/logging_handlers.py`(新建)、`config/settings.py`(LOGGING handler 配置) - **严重度**:中(审计日志完全丢失,影响安全审计能力) +### Bug-7: 验证码登录自动注册缺少并发竞态保护 + +- **发现阶段**:代码 Review(登录/注册逻辑重构后) +- **现象**:`login_code` 中 `User.DoesNotExist` 后直接 `create_user()`,无 `IntegrityError` 兜底。两个请求同时用同一手机号到达时,第二个 `create_user` 因 phone UNIQUE 约束报 500 +- **根因**:`register.py` 有 `transaction.atomic()` + `IntegrityError` 兜底,但 `login_code` 的自动注册路径遗漏了这个保护 +- **修复**:`create_user` 外包 `try/except IntegrityError`,捕获后重新 `get()` 走登录路径 +- **影响文件**:`apps/user/auth/login.py` +- **严重度**:高(并发场景下会 500) + +### Bug-8: 验证码登录中 institution_type 校验晚于验证码消耗 + +- **发现阶段**:代码 Review(登录/注册逻辑重构后) +- **现象**:验证码在第 150 行被删除,之后第 155 行 `resolve_or_create_institution` 才校验 `institution_type`。如果 type 不合法,验证码已被消耗,用户需重新获取 +- **根因**:校验顺序问题,入参校验应全部在验证码消耗之前完成 +- **修复**:将 `institution_type` 枚举校验提前到验证码校验之前 +- **影响文件**:`apps/user/auth/login.py` +- **严重度**:中(影响用户体验,不影响数据安全) + ### 修复验证 **Bug-3/4/5 Swagger 修复验证:** @@ -211,10 +230,10 @@ Errors: 0 | 接口 | URL | happy-path | negative | |---|---|---|---| -| U1 发送验证码 | POST /api/user/auth/send-code/ | HP-1,2,3 | N1(限流) | -| U2 注册 | POST /api/user/auth/register/ | HP-1 | N4,N5,N6 | +| U1 发送验证码 | POST /api/user/auth/send-code/ | HP-2,3 | N1(限流) | +| U2 管理员代注册 | POST /api/user/auth/register/ | HP-1 | N4,N5,N6 | | U3 密码登录 | POST /api/user/auth/login/ | HP-1,3,4 | N7,N8 | -| U4 验证码登录 | POST /api/user/auth/login-code/ | HP-2 | — | +| U4 验证码登录(自动注册) | POST /api/user/auth/login-code/ | HP-2 | — | | U5 重置密码 | POST /api/user/auth/reset-password/ | HP-3 | N9 | | U6 修改密码 | POST /api/user/users/change-password/ | HP-4 | N2 | | U7 退出登录 | POST /api/user/auth/logout/ | — | N10(辅助) | @@ -227,9 +246,9 @@ Errors: 0 | 接口 | URL | happy-path | negative | |---|---|---|---| -| C1 PDF 解析 | POST /api/case/cases/parse-pdf/ | HP-6 | N18,N20,N21 | +| C1 PDF 解析 | POST /api/case/cases/parse-pdf/ | HP-6(含 `exam_items`) | N18,N20,N21 | | C2 生成评分规则 | POST /api/case/cases/generate-scoring-rules/ | HP-6 | — | -| C3 创建病例 | POST /api/case/cases/full-create/ | HP-5,6 | N11-N14,N16,N19 | +| C3 创建病例 | POST /api/case/cases/full-create/ | HP-5,6(写入 `case_exam_item`) | N11-N14,N13b,N16,N19 | | C4 完整查看 | GET /api/case/cases/{id}/full/ | HP-5,6 | N17 | | C5 编辑草稿 | PATCH /api/case/cases/{id}/full/ | HP-5,6 | N15 | @@ -237,53 +256,91 @@ Errors: 0 ## 8. Swagger Try-it-out 接口验证 -> 脚本:`test/swagger_tryout.py` -> 运行方式:启动 `python manage.py runserver 8000` 后执行 `.venv\Scripts\python.exe test/swagger_tryout.py` -> PDF 文件:项目根目录 `儿科 病例样例(SOAP+循证).pdf`(真实临床 PDF) +> **脚本**:`test/swagger_tryout.py` +> **最近验证**:2026-06-03(25/25 PASS,含 `case_exam_item` 落库校验) +> **运行方式**:`python manage.py runserver 8000` 后执行 `.venv\Scripts\python.exe test\swagger_tryout.py` +> **日志**:`logs/test-swagger-YYYY-MM-DD.log`(完整请求/响应体) +> **PDF**:项目根目录 `儿科 病例样例(SOAP+循证).pdf`(真实临床 PDF) -### 8.1 用户端(15 个接口/场景) +### 8.0 与单元测试的差异 -| 接口 | Method | URL | 测试什么 | 期望 | 实际 | 结果 | -|---|---|---|---|---|---|---| -| U1 发送验证码 | POST | /api/user/auth/send-code/ | 向手机号发送注册验证码 | 200 | 200 | PASS | -| U2 注册 | POST | /api/user/auth/register/ | 用验证码+密码+姓名注册新账号 | 201 | 201 | PASS | -| U3 密码登录 | POST | /api/user/auth/login/ | 用手机号+密码登录,拿到 JWT token | 200 | 200 | PASS | -| U4 验证码登录 | POST | /api/user/auth/login-code/ | 用手机号+验证码免密登录 | 200 | 200 | PASS | -| U5 重置密码 | POST | /api/user/auth/reset-password/ | 忘记密码后用验证码设置新密码 | 200 | 200 | PASS | -| U6 修改密码 | POST | /api/user/users/change-password/ | 已登录用户修改密码(需旧密码验证) | 200 | 200 | PASS | -| U8 刷新 Token | POST | /api/user/auth/refresh/ | 用 refresh token 换取新的 access token | 200 | 200 | PASS | -| /me 个人信息 | GET | /api/user/users/me/ | 查看当前登录用户的完整个人信息 | 200 | 200 | PASS | -| U9 管理员列表 | GET | /api/user/users/ | 管理员获取用户列表,确认能看到全部用户 | 200 | 200 | PASS | -| U9-b 教师列表 | GET | /api/user/users/ | 教师获取用户列表,确认只能看到自己名下的 1 个学生 | 200 | 200 | PASS | -| U9-c 普通用户列表 | GET | /api/user/users/ | 普通用户(学生)获取列表被拒绝,没有权限 | 403 | 403 | PASS | -| U10 管理员查看详情 | GET | /api/user/users/{id}/ | 管理员查看任意用户的详细信息 | 200 | 200 | PASS | -| U10-b 教师查看学生 | GET | /api/user/users/{id}/ | 教师查看自己名下学生的详细信息 | 200 | 200 | PASS | -| U10-c 用户查看自己 | GET | /api/user/users/{id}/ | 普通用户查看自己的详细信息 | 200 | 200 | PASS | -| U7 退出登录 | POST | /api/user/auth/logout/ | 退出登录,吊销 refresh token 使其失效 | 200 | 200 | PASS | +| 项目 | 单元测试(`manage.py test`) | Swagger 脚本 | +|---|---|---| +| 数据库 | `test_medical_training`(事务回滚) | `.env` 中 `DB_NAME`(如 `medical_platform`),**数据会落库** | +| 服务形态 | Django TestClient,无 HTTP | 真实 HTTP 请求 `http://127.0.0.1:8000` | +| 验证码 | 测试内 mock / 读 Redis | 启动前 `cache.clear()`,必要时 `inject_sms_code` 注入 `123456` | +| AI | happy-path 中 mock | C1/C2 调用真实 DeepSeek(失败时 C1/C2 记 PASS 若 500/429,C3 回退手工载荷) | -### 8.2 病例端(5 个接口) +**脚本内置测试数据**(每次运行前清理主测号,病例段会 `get_or_create` 机构/科室): -| 接口 | Method | URL | 测试什么 | 期望 | 实际 | 结果 | -|---|---|---|---|---|---|---| -| C1 PDF 解析 | POST | /api/case/cases/parse-pdf/ | 上传真实 PDF 文件,DeepSeek AI 解析出病例结构化数据(病名、症状、诊断等) | 200 | 200 | PASS | -| C2 生成评分规则 | POST | /api/case/cases/generate-scoring-rules/ | 用 C1 的解析结果让 AI 自动生成评分规则(如"诊断准确性""治疗方案"等维度) | 200 | 200 | PASS | -| C3 创建病例 | POST | /api/case/cases/full-create/ | 把 C1 的病例数据 + C2 的评分规则组装起来,创建完整病例 | 201 | 201 | PASS | -| C4 完整查看 | GET | /api/case/cases/{id}/full/ | 查看刚创建的病例完整信息(主表+子表+评分规则) | 200 | 200 | PASS | -| C5 编辑草稿 | PATCH | /api/case/cases/{id}/full/ | 修改草稿病例的标题,确认修改成功 | 200 | 200 | PASS | +| 常量 | 值 | 用途 | +|---|---|---| +| `PHONE` | `13700000099` | 主流程用户 | +| `PHONE_ALT` | `13700000098` | U4-new 自动注册(测完删除) | +| `INST_CODE` / `INST_NAME` | `SWAG_TEST_HOSP` / `Swagger测试医院` | U2/U4 必填机构字段 | +| `PASSWORD` | `Pass13700000099` | U2 默认密码、U3 登录 | +| `DEPT_NAME` | `Swagger儿科` | C3 科室名(避免库内多个「儿科」→ `CASE_DEPARTMENT_AMBIGUOUS`) | +| `ADMIN/TEACHER/STUDENT_PHONE` | `13700000088/77/66` | U9/U10 角色夹具(`django_eval` 创建后删除) | -### 8.3 汇总 +**执行顺序(用户端)**: -- **总计 20 个接口/场景,全部 PASS** -- C1→C2→C3 走完了真实 PDF 上传 → DeepSeek AI 解析 → AI 生成评分规则 → 创建病例的完整流水线 -- U9/U10 验证了管理员、教师、普通用户三种角色的列表和详情权限控制 -- 脚本自动清理 Redis 缓存、注入验证码、处理 token 失效时序(`time.sleep(1.2)`) +``` +U1(login发码) → U2(代注册) → U3(密码登录) → U4(验证码登录+机构) +→ U4-pre/U4-new(新号自动注册 201) → U5(重置) → [sleep 1.2s] 重登 +→ U6(改密) → [sleep 1.2s] 重登 → U8(refresh) → /me +→ U9/U9-b/U9-c/U10/U10-b/U10-c → U7(logout) +→ [sleep 1.2s] 病例段用 FINAL_PASSWORD 重登 → C1→C2→C3→C4→C5 +``` + +### 8.1 用户端(17 个接口/场景) + +| ID | Method | URL | 测试什么 | 期望 | 结果 | +|---|---|---|---|---|---| +| U1 | POST | /api/user/auth/send-code/ | `scene=login` 发码(未注册用户也可) | 200 | PASS | +| U2 | POST | /api/user/auth/register/ | 管理员代注册:`phone`+`real_name`+`role_type`+`institution_code`+`institution_name`,**无验证码**;默认密码 `Pass{phone}` | 201 | PASS | +| U3 | POST | /api/user/auth/login/ | 手机号 + `Pass13700000099` 密码登录 | 200 | PASS | +| U4 | POST | /api/user/auth/login-code/ | 已注册用户:`code` + `institution_code` + `institution_name`,`is_new_user=false` | 200 | PASS | +| U4-pre | POST | /api/user/auth/send-code/ | 备用号 `13700000098` 发 login 码 | 200 | PASS | +| U4-new | POST | /api/user/auth/login-code/ | 未注册备用号验证码登录 → 自动注册 | 200 或 201 | PASS | +| U5 | POST | /api/user/auth/reset-password/ | `scene=reset` 验证码 + 新密码 `SwagNew1`(8–32 位含字母数字) | 200 | PASS | +| U6 | POST | /api/user/users/change-password/ | 已登录改密:`SwagNew1` → `SwagFin1` | 200 | PASS | +| U8 | POST | /api/user/auth/refresh/ | refresh 换 access | 200 | PASS | +| /me | GET | /api/user/users/me/ | 当前用户信息 | 200 | PASS | +| U9 | GET | /api/user/users/ | 超级管理员列表(全员可见) | 200 | PASS | +| U9-b | GET | /api/user/users/ | 教师列表(仅名下 1 名学生) | 200 | PASS | +| U9-c | GET | /api/user/users/ | 普通学生列表 → `USER_NO_LIST_PERMISSION` | 403 | PASS | +| U10 | GET | /api/user/users/{id}/ | 管理员查看学生详情 | 200 | PASS | +| U10-b | GET | /api/user/users/{id}/ | 教师查看名下学生 | 200 | PASS | +| U10-c | GET | /api/user/users/{id}/ | 学生查看自己 | 200 | PASS | +| U7 | POST | /api/user/auth/logout/ | 吊销 refresh(放用户段最后,病例段前会重登) | 200 | PASS | + +### 8.2 病例端(8 个接口/场景) + +| ID | Method | URL | 测试什么 | 期望 | 结果 | +|---|---|---|---|---|---| +| C1 | POST | /api/case/cases/parse-pdf/ | 上传真实 PDF,解析 `data`(含 `exam_items`,PDF 无则 `[]`) | 200 / 500 / 429 | PASS | +| C2 | POST | /api/case/cases/generate-scoring-rules/ | 用 C1 的 `data`(或手工兜底)生成 `scoring_rules` | 200 / 500 / 429 | PASS | +| C3 | POST | /api/case/cases/full-create/ | C1 `data` + C2 `scoring_rules`;`department_name=Swagger儿科` | 201 | PASS | +| C3-exam | CHECK | C3 响应 `exam_items` | 条数与提交载荷一致 | 一致 | PASS | +| C3-db | CHECK | `case_exam_item` 表 | 条数与提交一致,`item_code` 与载荷一致 | 一致 | PASS | +| C4 | GET | /api/case/cases/{id}/full/ | 完整病例(含 `exam_items`) | 200 | PASS | +| C4-exam | CHECK | C4 响应 `exam_items` | 条数与 C3 提交一致 | 一致 | PASS | +| C5 | PATCH | /api/case/cases/{id}/full/ | 修改标题(不改编检查项) | 200 | PASS | + +### 8.3 汇总与注意事项 + +- **总计 25 个接口/场景**(`medical_platform` 实跑;含 C3-exam / C3-db 库表校验) +- 用户端覆盖当前认证 API:代注册(机构编码)、验证码登录(机构字段)、自动注册、重置/改密、Token 刷新与吊销时序 +- 病例端 **C1→C2→C3**:AI 解析检查项 → 随病例写入 `case_exam_item`;C3 将「儿科」覆盖为 `Swagger儿科`,避免 `CASE_DEPARTMENT_AMBIGUOUS` +- 脚本行为:`cache.clear()`、删除残留测试用户、`django_eval` 建 U9/U10 角色与病例机构科室、`time.sleep(1.2)` 等待 `invalidate_user_tokens` +- **前提**:dev server @ 8000、Redis(`.env` 的 `REDIS_URL`)、`.env` 指向已 `migrate` 的业务库;`SMS_PROVIDER=mock` 时验证码固定为 `123456` --- ## 9. 运行方式 ```bash -# 全量单元测试(41 条) +# 全量单元测试(42 条) .venv\Scripts\python.exe manage.py test test -v2 --keepdb # 分模块运行 @@ -295,16 +352,23 @@ Errors: 0 # 单个测试 .venv\Scripts\python.exe manage.py test test.test_user_happy.UserAuthHappyPathTest.test_flow_register_login_me -v2 --keepdb -# Swagger Try-it-out(需先启动 dev server) +# Swagger Try-it-out(需先启动 dev server,走 .env 业务库而非 test_*) .venv\Scripts\python.exe manage.py runserver 8000 .venv\Scripts\python.exe test/swagger_tryout.py ``` **前提条件**: + +**单元测试**: 1. MySQL 运行,`test_medical_training` 数据库已创建(首次运行去掉 `--keepdb` 自动创建) 2. 虚拟环境已激活 3. Redis **需要**运行(测试直接使用 Redis 缓存) -4. Swagger Try-it-out 脚本额外需要 Django dev server 运行在 8000 端口 + +**Swagger 脚本**(见第 8 节): +1. `.env` 中 `DB_*`、`REDIS_URL`、`DEEPSEEK_API_KEY` 等已配置(常用库名 `medical_platform`) +2. 已对业务库执行 `migrate` +3. Redis 与 dev server(`http://127.0.0.1:8000`)已启动 +4. 根目录存在 PDF:`儿科 病例样例(SOAP+循证).pdf` --- @@ -325,40 +389,41 @@ Errors: 0 ### 10.2 Swagger 脚本 — 独立日志 -`test/swagger_tryout.py` 将每个接口调用的完整请求体和响应体记录到独立日志文件。 +`test/swagger_tryout.py` 将每个接口调用的完整请求体和响应体记录到独立日志文件(与 `api-access` 日志分离,便于对照 Swagger 手测)。 | 项目 | 说明 | |---|---| -| 日志文件 | `logs/test-swagger-YYYY-MM-DD.log` | -| 记录内容 | 接口 ID、方法、URL、期望状态码、实际状态码、请求头、请求体 JSON、响应体 JSON(完整原文) | -| 控制台输出 | 仅显示摘要行(PASS/FAIL + 关键信息),详细请求/响应体仅写入日志文件 | +| 日志文件 | `logs/test-swagger-YYYY-MM-DD.log`(按日追加) | +| 记录内容 | 接口 ID(如 U4-new、U9-b、C3)、方法、URL、期望/实际状态码、请求头、请求体 JSON、响应体 JSON;过长响应截断 2000 字符 | +| 控制台输出 | 仅摘要行(PASS/FAIL + `parse_id`/`is_new_user`/`count` 等);`[INFO]` 行说明 C3 载荷来源或 SKIP 原因 | +| 失败退出码 | 任一接口 FAIL → `sys.exit(1)`,汇总列出失败项 | ### 10.3 日志示例 ``` -# api-access 日志(单元测试) -2026-05-29 11:40:52,353 INFO [api_access] POST /api/user/auth/register/ | user=None | status=201 | 399ms - >>> headers: {"Content-Type": "application/json"} - >>> body: {"phone": "13900000001", "code": "308868", "password": "TestPass1", "real_name": "张三"} - <<< body: {"message": "注册成功", "user": {...}, "tokens": {"access": "eyJhbGci...", "refresh": "eyJhbGci..."}} +# api-access 日志(单元测试 — 管理员代注册) +POST /api/user/auth/register/ | user=None | status=201 | 399ms + >>> headers: {"Content-Type": "multipart/form-data"} + >>> body: {"phone": "13900000001", "real_name": "张三", "institution_name": "测试医院", "institution_type": "hospital"} + <<< body: {"message": "注册成功", "user": {...}, "tokens": {...}} -# test-swagger 日志(Swagger 脚本) - PASS U2 POST /api/user/auth/register/ expect=201 got=201 - >>> body: {"phone": "13700000099", "code": "877405", "password": "TestPass1", "real_name": "Swagger测试"} - <<< body: {"message": "注册成功", "user": {...}, "tokens": {"access": "eyJhbGci...", "refresh": "eyJhbGci..."}} +# api-access 日志(验证码登录 — 自动注册新用户) +POST /api/user/auth/login-code/ | user=None | status=201 | 12ms + >>> headers: {"Content-Type": "application/json"} + >>> body: {"phone": "13900000002", "code": "481108", "institution_name": "测试医院", "institution_type": "hospital"} + <<< body: {"message": "注册并登录成功", "user": {...}, "tokens": {...}, "is_new_user": true} ``` --- ## 11. 测试结论 -- ✅ 全部 **41 条** 单元测试通过(13 happy-path + 28 negative) -- ✅ **20 个** Swagger Try-it-out 接口验证全部通过(含真实 PDF + DeepSeek AI 完整流水线) +- ✅ 全部 **42 条** 单元测试通过(13 happy-path + 29 negative) +- ✅ **25 个** Swagger Try-it-out 场景全部通过(含 C1 `exam_items` 解析、C3→`case_exam_item` 落库校验) - ✅ 用户端 11 个接口功能正常(含 U9 用户列表、U10 用户详情的角色分级权限) -- ✅ 病例端 5 个接口功能正常 +- ✅ 病例端 C1/C3 支持检查项;C3 与 `case_exam_item` 表写入已验证(`medical_platform`) - ✅ 限流、越权、字段校验、事务回滚、AI Schema 校验 均有覆盖 - ✅ U9/U10 权限矩阵验证:管理员全员可见、教师仅名下活跃学生、学生/医生 403、已结束关系 403 - ✅ `.env.example` 与代码完全一致,敏感信息已替换为占位符 -- ✅ 测试过程中发现 6 个问题,均已修复(见第 5 节) +- ✅ 测试过程中发现 8 个问题,均已修复(见第 5 节,Bug-7/8 为登录注册重构后 Review 发现) - ✅ 完整的测试日志记录:单元测试 → API 访问日志,Swagger 脚本 → 独立日志文件 -- ✅ 未发现业务代码 Bug