init medical training project

This commit is contained in:
2026-05-29 15:58:00 +08:00
commit b4bb38b7be
91 changed files with 6765 additions and 0 deletions
View File
+16
View File
@@ -0,0 +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()
+77
View File
@@ -0,0 +1,77 @@
import uuid
import logging
from rest_framework.views import exception_handler as drf_exception_handler
from rest_framework.response import Response
from rest_framework.exceptions import APIException, ValidationError
logger = logging.getLogger(__name__)
_STATUS_TO_CODE = {
400: 'BAD_REQUEST',
401: 'AUTH_UNAUTHORIZED',
403: 'PERMISSION_DENIED',
404: 'NOT_FOUND',
405: 'METHOD_NOT_ALLOWED',
429: 'SYS_RATE_LIMIT',
500: 'SYS_INTERNAL',
503: 'SYS_DEPENDENCY_DOWN',
}
def custom_exception_handler(exc, context):
trace_id = uuid.uuid4().hex[:12]
response = drf_exception_handler(exc, context)
if response is None:
logger.exception('Unhandled server error trace_id=%s', trace_id)
return Response(
{'code': 'SYS_INTERNAL', 'message': '服务器内部错误', 'details': None, 'trace_id': trace_id},
status=500,
)
data = response.data
status_code = response.status_code
# Already structured by AppError or our custom exceptions
if isinstance(data, dict) and 'code' in data and 'message' in data:
response.data = {**data, 'trace_id': trace_id}
return response
# DRF ValidationError: {'field': ['error msg']}
if isinstance(exc, ValidationError):
response.data = {
'code': 'VALIDATION_ERROR',
'message': '请求参数不合法',
'details': data,
'trace_id': trace_id,
}
return response
# Standard DRF exceptions (AuthenticationFailed, PermissionDenied, Throttled, etc.)
message = _extract_message(data)
response.data = {
'code': _STATUS_TO_CODE.get(status_code, 'SYS_INTERNAL'),
'message': message,
'details': None,
'trace_id': trace_id,
}
return response
def _extract_message(data):
if isinstance(data, dict):
return str(data.get('detail', data))
if isinstance(data, list) and data:
return str(data[0])
return str(data)
class AppError(APIException):
"""统一业务异常。视图中 raise AppError('CODE', '消息', details, status_code) 即可。"""
def __init__(self, code, message, details=None, status_code=400):
self.status_code = status_code
super().__init__(detail=code)
# 绕过 DRF _get_error_details,防止 None 被转成字符串 "None"
self.detail = {'code': code, 'message': message, 'details': details}
+93
View File
@@ -0,0 +1,93 @@
"""自定义日志 Handler:按日期分文件,兼容 Windows 文件锁。
替代 TimedRotatingFileHandler,避免 Windows 上因 rename 操作遇到文件锁
PermissionError: [WinError 32])导致日志丢失的问题。
文件命名规则:{prefix}-YYYY-MM-DD.log
日期切换时自动打开新文件,无需 rename 旧文件。
"""
import logging
import time
from pathlib import Path
class DailyFileHandler(logging.Handler):
"""按日期自动分文件的日志 Handler。
与 TimedRotatingFileHandler 的关键区别:
- 文件直接以日期命名,日期切换时打开新文件,**不 rename 旧文件**
- 多进程(dev server + 测试 / 管理命令)可同时写入,互不阻塞
- 自动清理超过 backup_count 天的旧文件
dictConfig 用法::
'audit_file': {
'class': 'config.logging_handlers.DailyFileHandler',
'dir_path': '/path/to/logs',
'prefix': 'audit',
'backup_count': 30,
'formatter': 'verbose',
}
"""
def __init__(self, dir_path, prefix='audit', backup_count=30, encoding='utf-8'):
super().__init__()
self.dir_path = Path(dir_path)
self.dir_path.mkdir(parents=True, exist_ok=True)
self.prefix = prefix
self.backup_count = backup_count
self.encoding = encoding
self._current_date = None
self._stream = None
self._open_today()
def _today(self):
return time.strftime('%Y-%m-%d')
def _open_today(self):
"""打开当天的日志文件(追加模式)。"""
if self._stream:
try:
self._stream.close()
except OSError:
pass
self._current_date = self._today()
filepath = self.dir_path / f'{self.prefix}-{self._current_date}.log'
self._stream = open(filepath, 'a', encoding=self.encoding)
def emit(self, record):
try:
today = self._today()
if today != self._current_date:
self._open_today()
self._cleanup_old_files()
msg = self.format(record)
self._stream.write(msg + '\n')
self._stream.flush()
except Exception:
self.handleError(record)
def close(self):
self.acquire()
try:
if self._stream:
try:
self._stream.close()
except OSError:
pass
self._stream = None
finally:
self.release()
super().close()
def _cleanup_old_files(self):
"""删除超过 backup_count 天的旧日志文件。"""
if self.backup_count <= 0:
return
try:
files = sorted(self.dir_path.glob(f'{self.prefix}-*.log'))
for f in files[:-self.backup_count]:
f.unlink(missing_ok=True)
except OSError:
pass
+138
View File
@@ -0,0 +1,138 @@
"""API 请求/响应日志中间件。
记录每次 API 调用的完整信息:方法、路径、请求头、查询参数、请求体、
响应状态码、响应头、响应体。
日志输出到 `api_access` logger → `logs/api-access-YYYY-MM-DD.log`。
"""
import json
import logging
import time
api_logger = logging.getLogger('api_access')
# 跳过日志的路径前缀(静态文件、admin 等)
_SKIP_PREFIXES = ('/static/', '/admin/', '/api/schema/', '/api/docs/')
# 请求体/响应体最大记录长度(字符)
_MAX_BODY_LEN = 2000
# 需要记录的请求头(Django 中请求头加 HTTP_ 前缀并大写)
_LOG_REQUEST_HEADERS = (
'HTTP_AUTHORIZATION',
'HTTP_ACCEPT',
'HTTP_USER_AGENT',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
)
def _truncate(text, max_len=_MAX_BODY_LEN):
if len(text) > max_len:
return text[:max_len] + f'... (truncated, total {len(text)} chars)'
return text
def _safe_json_body(body_bytes, content_type=''):
"""尝试将请求/响应体解析为 JSON,失败则返回原始文本摘要。"""
if not body_bytes:
return ''
# multipart (文件上传) 不记录原始内容
if 'multipart' in content_type:
return f'<multipart form data, {len(body_bytes)} bytes>'
try:
text = body_bytes.decode('utf-8')
data = json.loads(text)
return _truncate(json.dumps(data, ensure_ascii=False))
except (UnicodeDecodeError, json.JSONDecodeError):
return _truncate(body_bytes.decode('utf-8', errors='replace'))
class APIAccessLogMiddleware:
"""记录 /api/ 请求与响应的中间件。"""
def __init__(self, get_response):
self.get_response = get_response
@staticmethod
def _collect_request_headers(request):
"""提取关键请求头。"""
headers = {}
ct = request.content_type or request.META.get('CONTENT_TYPE', '')
if ct:
headers['Content-Type'] = ct
for meta_key in _LOG_REQUEST_HEADERS:
val = request.META.get(meta_key)
if val:
name = meta_key.replace('HTTP_', '').replace('_', '-').title()
headers[name] = val
return headers
@staticmethod
def _collect_response_headers(response):
"""提取关键响应头。"""
keys = ('Content-Type', 'Content-Length', 'Allow',
'X-Request-Id', 'Retry-After', 'WWW-Authenticate')
headers = {}
for k in keys:
v = response.get(k)
if v:
headers[k] = v
return headers
def __call__(self, request):
path = request.path
if not path.startswith('/api/') or any(path.startswith(p) for p in _SKIP_PREFIXES):
return self.get_response(request)
start = time.time()
req_headers = self._collect_request_headers(request)
req_content_type = request.content_type or ''
if 'multipart' in req_content_type:
form_fields = dict(request.POST)
file_info = {
name: f'<file: {f.name}, {f.size} bytes>'
for name, f in request.FILES.items()
}
all_fields = {**form_fields, **file_info}
req_body = _truncate(json.dumps(all_fields, ensure_ascii=False)) if all_fields else ''
else:
req_body = _safe_json_body(request.body, req_content_type)
query = dict(request.GET) if request.GET else ''
user_id = None
response = self.get_response(request)
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
user_id = request.user.id
duration_ms = int((time.time() - start) * 1000)
resp_headers = self._collect_response_headers(response)
resp_content_type = response.get('Content-Type', '')
resp_body = ''
if hasattr(response, 'content'):
resp_body = _safe_json_body(response.content, resp_content_type)
req_h_str = json.dumps(req_headers, ensure_ascii=False) if req_headers else ''
resp_h_str = json.dumps(resp_headers, ensure_ascii=False) if resp_headers else ''
api_logger.info(
'%s %s | user=%s | query=%s | status=%s | %dms\n'
' >>> headers: %s\n'
' >>> body: %s\n'
' <<< headers: %s\n'
' <<< body: %s',
request.method, path,
user_id, query, response.status_code, duration_ms,
req_h_str, req_body,
resp_h_str, resp_body,
)
return response
+252
View File
@@ -0,0 +1,252 @@
import os
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
SECRET_KEY = 'django-insecure-!-mtect5n-yyxkp2m=j(8dz_yi$b3w3ddo&w#i(@4kv-spdthy'
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'rest_framework',
'rest_framework_simplejwt',
'django_filters',
'drf_spectacular',
# Local apps
'apps.common',
'apps.user',
'apps.case',
'apps.training',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'config.middleware.APIAccessLogMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database - MySQL
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.getenv('DB_NAME', 'medical_training'),
'USER': os.getenv('DB_USER', 'root'),
'PASSWORD': os.getenv('DB_PASSWORD', ''),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
'TEST': {
'NAME': 'test_medical_training',
'CHARSET': 'utf8mb4',
'COLLATION': 'utf8mb4_unicode_ci',
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ─── REST Framework ───────────────────────────────────────────────────────────
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'config.exceptions.custom_exception_handler',
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': [
'apps.user.authentication.RedisBlacklistJWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_THROTTLE_CLASSES': [],
'DEFAULT_THROTTLE_RATES': {
'sms_phone_minute': '1/minute',
'sms_phone_day': '10/day',
'sms_ip': '30/hour',
'register_ip': '10/hour',
'reset_phone': '5/hour',
'pdf_parse_user': '20/hour',
'scoring_rule_generate_user': '20/hour',
},
}
# ─── JWT ──────────────────────────────────────────────────────────────────────
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
}
# ─── Redis Cache ──────────────────────────────────────────────────────────────
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
},
}
}
# ─── SMS ──────────────────────────────────────────────────────────────────────
SMS_CODE_EXPIRE = 300 # 验证码有效期(秒)
SMS_CODE_INTERVAL = 60 # 发送间隔(秒)
SMS_PROVIDER = os.getenv('SMS_PROVIDER', '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', '')
ALIYUN_SMS_SIGN_NAME = os.getenv('ALIYUN_SMS_SIGN_NAME', '医疗训练平台')
ALIYUN_SMS_TEMPLATE_REGISTER = os.getenv('ALIYUN_SMS_TEMPLATE_REGISTER', '')
ALIYUN_SMS_TEMPLATE_LOGIN = os.getenv('ALIYUN_SMS_TEMPLATE_LOGIN', '')
ALIYUN_SMS_TEMPLATE_RESET = os.getenv('ALIYUN_SMS_TEMPLATE_RESET', '')
# ─── DeepSeek ─────────────────────────────────────────────────────────────────
DEEPSEEK_API_KEY = os.getenv('DEEPSEEK_API_KEY', '')
DEEPSEEK_BASE_URL = os.getenv('DEEPSEEK_BASE_URL', 'https://api.deepseek.com')
DEEPSEEK_MODEL = os.getenv('DEEPSEEK_MODEL', 'deepseek-chat')
DEEPSEEK_TIMEOUT_SECONDS = int(os.getenv('DEEPSEEK_TIMEOUT_SECONDS', '120'))
DEEPSEEK_MAX_RETRIES = int(os.getenv('DEEPSEEK_MAX_RETRIES', '1'))
# ─── Spectacular (Swagger / OpenAPI) ─────────────────────────────────────────
SPECTACULAR_SETTINGS = {
'TITLE': 'Medical Training API',
'DESCRIPTION': '医疗训练系统 API 文档',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
'COMPONENT_SPLIT_REQUEST': True,
# 修复同名枚举冲突(User.STATUS_CHOICES 与 CaseBase.STATUS_CHOICES 值相同,共用一个名称)
'ENUM_NAME_OVERRIDES': {
'CaseTypeEnum': 'apps.case.models.CaseBase.CASE_TYPE_CHOICES',
'CreatableCaseTypeEnum': [('traditional', 'traditional'), ('teaching', 'teaching')],
'CommonStatusEnum': 'apps.case.models.CaseBase.STATUS_CHOICES',
'PublishStatusEnum': 'apps.case.models.CaseBase.PUBLISH_STATUS_CHOICES',
'TrainingStatusEnum': 'apps.training.models.TrainingRecord.STATUS_CHOICES',
'TeacherStudentStatusEnum': 'apps.user.models.TeacherStudentRelation.STATUS_CHOICES',
},
}
# ─── Auth ─────────────────────────────────────────────────────────────────────
AUTH_USER_MODEL = 'user.User'
# ─── Logging ──────────────────────────────────────────────────────────────────
LOGS_DIR = BASE_DIR / 'logs'
LOGS_DIR.mkdir(exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{asctime} {levelname} [{name}] {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'audit_file': {
'class': 'config.logging_handlers.DailyFileHandler',
'dir_path': str(LOGS_DIR),
'prefix': 'audit',
'backup_count': 30,
'formatter': 'verbose',
},
'api_access_file': {
'class': 'config.logging_handlers.DailyFileHandler',
'dir_path': str(LOGS_DIR),
'prefix': 'api-access',
'backup_count': 30,
'formatter': 'verbose',
},
},
'loggers': {
'audit': {
'handlers': ['audit_file', 'console'],
'level': 'INFO',
'propagate': False,
},
'api_access': {
'handlers': ['api_access_file'],
'level': 'INFO',
'propagate': False,
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}
+38
View File
@@ -0,0 +1,38 @@
"""
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'),
]
+16
View File
@@ -0,0 +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()