feat: add django user center auth integration

This commit is contained in:
刘金宝
2026-06-01 14:28:43 +08:00
parent b80e298b4f
commit 338e2c8e1d
12 changed files with 330 additions and 17 deletions
+7
View File
@@ -13,6 +13,13 @@ RUNTIME_MEMORY_BACKEND=redis
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://localhost:6379/0
RUNTIME_MEMORY_TTL_SECONDS=7200 RUNTIME_MEMORY_TTL_SECONDS=7200
# Django user center auth for frontend integration
AUTH_VALIDATE_ENABLED=false
AUTH_USER_ME_URL=http://192.168.2.76:8000/api/user/users/me/
AUTH_TIMEOUT_SECONDS=5
AUTH_CACHE_TTL_SECONDS=300
AUTH_ALLOW_DEMO_USER_ID=true
# OpenAI-compatible LLM # OpenAI-compatible LLM
LLM_BASE_URL=https://api.deepseek.com/chat/completions LLM_BASE_URL=https://api.deepseek.com/chat/completions
LLM_API_KEY= LLM_API_KEY=
+28
View File
@@ -137,6 +137,34 @@ http://127.0.0.1:5173
| `LLM_REASON_MODEL` | Reason 测试模型 | | `LLM_REASON_MODEL` | Reason 测试模型 |
| `LLM_MOCK_ENABLED` | 是否强制 mock | | `LLM_MOCK_ENABLED` | 是否强制 mock |
| `LLM_FALLBACK_TO_MOCK` | 真实模型失败时是否回退 mock | | `LLM_FALLBACK_TO_MOCK` | 真实模型失败时是否回退 mock |
| `AUTH_VALIDATE_ENABLED` | 是否启用 Django 用户中心鉴权 |
| `AUTH_USER_ME_URL` | Django 当前用户接口,例如 `http://192.168.2.76:8000/api/user/users/me/` |
| `AUTH_TIMEOUT_SECONDS` | 调用用户中心超时时间 |
| `AUTH_CACHE_TTL_SECONDS` | 用户信息短期缓存时间 |
| `AUTH_ALLOW_DEMO_USER_ID` | 外部鉴权开启时是否允许 `X-User-Id` Demo 兜底 |
## Django 用户中心联调
正式联调时,前端进入医疗问诊 Agent 后先调用:
```text
GET /api/v1/auth/me
```
前端需要携带宿主系统登录态:
```http
Authorization: Bearer <token>
X-Entry-Scene: mac_vue_dev
```
如果宿主系统使用 Cookie 登录,则前端需要开启 `withCredentials`,后端会把 Cookie 转发到:
```text
http://192.168.2.76:8000/api/user/users/me/
```
FastAPI 会从 Django 返回值中提取 `user_id`,后续病例训练、会话、评价和历史记录都按该 `user_id` 隔离。`X-User-Id` 只作为本地 Demo 兼容方式。
## 验证命令 ## 验证命令
+22
View File
@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from app.core.response import ApiResponse, ok
from app.core.user_context import UserContext, get_user_context
from app.schemas.auth import AuthMeResponse
router = APIRouter()
@router.get("/me", response_model=ApiResponse[AuthMeResponse])
async def auth_me(ctx: UserContext = Depends(get_user_context)):
"""当前用户:返回经 Django 用户中心或 Demo Header 标准化后的用户信息。"""
return ok(
AuthMeResponse(
user_id=ctx.user_id,
source=ctx.auth_source,
username=ctx.username,
display_name=ctx.display_name,
tenant_id=ctx.tenant_id,
role=ctx.role,
)
)
+2 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import agent, cases, evaluations, imports, knowledge, llm_test, sessions from app.api import agent, auth, cases, evaluations, imports, knowledge, llm_test, sessions
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(agent.router, tags=["agent"]) api_router.include_router(agent.router, tags=["agent"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(cases.router, prefix="/cases", tags=["cases"]) api_router.include_router(cases.router, prefix="/cases", tags=["cases"])
api_router.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) api_router.include_router(sessions.router, prefix="/sessions", tags=["sessions"])
api_router.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"]) api_router.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"])
+7
View File
@@ -83,6 +83,11 @@ class Settings(BaseModel):
runtime_memory_ttl_seconds: int = Field(default_factory=lambda: int(os.getenv("RUNTIME_MEMORY_TTL_SECONDS", "7200"))) runtime_memory_ttl_seconds: int = Field(default_factory=lambda: int(os.getenv("RUNTIME_MEMORY_TTL_SECONDS", "7200")))
runtime_memory_backend: str = Field(default_factory=lambda: os.getenv("RUNTIME_MEMORY_BACKEND", "memory")) runtime_memory_backend: str = Field(default_factory=lambda: os.getenv("RUNTIME_MEMORY_BACKEND", "memory"))
redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0")) redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0"))
auth_validate_enabled: bool = Field(default_factory=lambda: os.getenv("AUTH_VALIDATE_ENABLED", "false").lower() == "true")
auth_user_me_url: str = Field(default_factory=lambda: os.getenv("AUTH_USER_ME_URL", ""))
auth_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("AUTH_TIMEOUT_SECONDS", "5")))
auth_cache_ttl_seconds: int = Field(default_factory=lambda: int(os.getenv("AUTH_CACHE_TTL_SECONDS", "300")))
auth_allow_demo_user_id: bool = Field(default_factory=lambda: os.getenv("AUTH_ALLOW_DEMO_USER_ID", "true").lower() == "true")
def as_public_dict(self) -> dict[str, Any]: def as_public_dict(self) -> dict[str, Any]:
"""配置展示:返回允许暴露给 Demo 前端的功能开关。""" """配置展示:返回允许暴露给 Demo 前端的功能开关。"""
@@ -102,6 +107,8 @@ class Settings(BaseModel):
"llm_reasoning_effort": self.llm_reasoning_effort, "llm_reasoning_effort": self.llm_reasoning_effort,
"llm_fast_max_tokens": self.llm_fast_max_tokens, "llm_fast_max_tokens": self.llm_fast_max_tokens,
"runtime_memory_backend": self.runtime_memory_backend, "runtime_memory_backend": self.runtime_memory_backend,
"auth_validate_enabled": self.auth_validate_enabled,
"auth_source": "django_user_center" if self.auth_validate_enabled else "demo_header",
} }
+3
View File
@@ -13,3 +13,6 @@ class UserContext:
request_id: str | None = None request_id: str | None = None
ip_address: str | None = None ip_address: str | None = None
user_agent: str | None = None user_agent: str | None = None
username: str | None = None
display_name: str | None = None
auth_source: str = "demo_header"
+23 -1
View File
@@ -1,7 +1,9 @@
from fastapi import Header, Request from fastapi import Header, Request
from app.core.config import settings
from app.core.context import UserContext from app.core.context import UserContext
from app.core.exceptions import AppError from app.core.exceptions import AppError
from app.services.external_auth_service import ExternalAuthService
async def get_user_context( async def get_user_context(
@@ -13,7 +15,26 @@ async def get_user_context(
x_entry_scene: str | None = Header(default=None, alias="X-Entry-Scene"), x_entry_scene: str | None = Header(default=None, alias="X-Entry-Scene"),
x_request_id: str | None = Header(default=None, alias="X-Request-Id"), x_request_id: str | None = Header(default=None, alias="X-Request-Id"),
) -> UserContext: ) -> UserContext:
"""用户校验:读取请求头并强制校验 `X-User-Id`""" """用户校验:正式联调优先调用 Django 用户中心,Demo 模式兼容 X-User-Id。"""
if settings.auth_validate_enabled and (request.headers.get("Authorization") or request.headers.get("Cookie")):
user = await ExternalAuthService().authenticate(request)
return UserContext(
user_id=user.user_id,
tenant_id=user.tenant_id or x_tenant_id,
role=user.role or x_user_role,
class_id=x_class_id,
entry_scene=x_entry_scene,
request_id=x_request_id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"),
username=user.username,
display_name=user.display_name,
auth_source=user.source,
)
if settings.auth_validate_enabled and not settings.auth_allow_demo_user_id:
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization or Cookie is required", 401)
if not x_user_id or not x_user_id.strip(): if not x_user_id or not x_user_id.strip():
raise AppError("USER_ID_REQUIRED", "X-User-Id header is required", 401) raise AppError("USER_ID_REQUIRED", "X-User-Id header is required", 401)
@@ -26,4 +47,5 @@ async def get_user_context(
request_id=x_request_id, request_id=x_request_id,
ip_address=request.client.host if request.client else None, ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"), user_agent=request.headers.get("User-Agent"),
auth_source="demo_header",
) )
+2 -2
View File
@@ -24,8 +24,8 @@ def create_app() -> FastAPI:
"http://127.0.0.1:5174", "http://127.0.0.1:5174",
"http://localhost:5174", "http://localhost:5174",
], ],
allow_origin_regex=r"^http://(127\.0\.0\.1|localhost):\d+$", allow_origin_regex=r"^http://(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+):\d+$",
allow_credentials=False, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
+12
View File
@@ -0,0 +1,12 @@
from pydantic import BaseModel
class AuthMeResponse(BaseModel):
"""认证用户响应:返回医疗问诊 Agent 标准化后的当前用户信息。"""
user_id: str
source: str
username: str | None = None
display_name: str | None = None
tenant_id: str | None = None
role: str | None = None
@@ -0,0 +1,128 @@
from __future__ import annotations
import hashlib
import time
from dataclasses import dataclass
from typing import Any
import httpx
from fastapi import Request
from app.core.config import settings
from app.core.exceptions import AppError
@dataclass(frozen=True)
class AuthenticatedUser:
"""外部认证用户:承载从 Django 用户中心解析出的标准用户信息。"""
user_id: str
source: str = "django_user_center"
username: str | None = None
display_name: str | None = None
tenant_id: str | None = None
role: str | None = None
class ExternalAuthService:
"""外部认证服务:调用 Django `/api/user/users/me/` 并标准化当前登录用户。"""
_cache: dict[str, tuple[float, AuthenticatedUser]] = {}
async def authenticate(self, request: Request) -> AuthenticatedUser:
"""用户鉴权:转发前端鉴权凭证到 Django 用户中心,成功后返回标准用户信息。"""
if not settings.auth_user_me_url:
raise AppError("AUTH_CONFIG_MISSING", "auth user me url is not configured", 500)
outbound_headers = self._build_forward_headers(request)
if not outbound_headers:
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization or Cookie is required", 401)
cache_key = self._cache_key(outbound_headers)
cached = self._read_cache(cache_key)
if cached:
return cached
try:
async with httpx.AsyncClient(timeout=settings.auth_timeout_seconds, follow_redirects=False) as client:
response = await client.get(settings.auth_user_me_url, headers=outbound_headers)
except httpx.TimeoutException as exc:
raise AppError("AUTH_USER_CENTER_UNAVAILABLE", "auth user center request timeout", 503) from exc
except httpx.HTTPError as exc:
raise AppError("AUTH_USER_CENTER_UNAVAILABLE", "auth user center unavailable", 503) from exc
if response.status_code != 200:
raise AppError("AUTH_USER_INVALID", "current login user is invalid", 401)
try:
payload = response.json()
except ValueError as exc:
raise AppError("AUTH_USER_PARSE_FAILED", "auth user center returned invalid json", 502) from exc
user = self._parse_user(payload)
self._write_cache(cache_key, user)
return user
def _build_forward_headers(self, request: Request) -> dict[str, str]:
"""认证转发:只透传鉴权必要 Header,避免把无关内部头转发给用户中心。"""
headers: dict[str, str] = {}
authorization = request.headers.get("Authorization")
cookie = request.headers.get("Cookie")
if authorization:
headers["Authorization"] = authorization
if cookie:
headers["Cookie"] = cookie
if request.headers.get("X-CSRFToken"):
headers["X-CSRFToken"] = request.headers["X-CSRFToken"]
return headers
def _parse_user(self, payload: dict[str, Any]) -> AuthenticatedUser:
"""用户解析:兼容常见 Django 用户接口结构,并提取稳定 user_id。"""
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
user_id = self._first_present(data, ["id", "user_id", "uid", "pk", "uuid"])
if user_id is None:
raise AppError("AUTH_USER_PARSE_FAILED", "auth user id is missing", 502)
username = self._first_present(data, ["username", "account", "mobile", "phone"])
display_name = self._first_present(data, ["display_name", "name", "nickname", "real_name"])
role = self._first_present(data, ["role", "user_role"])
tenant_id = self._first_present(data, ["tenant_id", "org_id", "organization_id"])
return AuthenticatedUser(
user_id=str(user_id),
username=str(username) if username is not None else None,
display_name=str(display_name) if display_name is not None else None,
role=str(role) if role is not None else None,
tenant_id=str(tenant_id) if tenant_id is not None else None,
)
@staticmethod
def _first_present(data: dict[str, Any], keys: list[str]) -> Any:
"""用户解析:按优先级读取第一个非空字段。"""
for key in keys:
value = data.get(key)
if value is not None and value != "":
return value
return None
@staticmethod
def _cache_key(headers: dict[str, str]) -> str:
"""认证缓存:基于鉴权凭证生成不可逆缓存键,避免保存明文 token。"""
raw = "|".join(f"{key}:{value}" for key, value in sorted(headers.items()))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _read_cache(self, cache_key: str) -> AuthenticatedUser | None:
"""认证缓存:读取进程内短期用户缓存,减少频繁请求 Django 用户中心。"""
item = self._cache.get(cache_key)
if not item:
return None
expires_at, user = item
if expires_at <= time.time():
self._cache.pop(cache_key, None)
return None
return user
def _write_cache(self, cache_key: str, user: AuthenticatedUser) -> None:
"""认证缓存:写入短期用户缓存,缓存只保存标准用户信息,不保存 token 明文。"""
ttl = max(settings.auth_cache_ttl_seconds, 0)
if ttl <= 0:
return
self._cache[cache_key] = (time.time() + ttl, user)
+5
View File
@@ -40,6 +40,11 @@ def run_api_contract_tests() -> None:
assert hello.status_code == 200 assert hello.status_code == 200
assert hello.json()["code"] == "OK" assert hello.json()["code"] == "OK"
auth_me = client.get("/api/v1/auth/me", headers=headers)
assert auth_me.status_code == 200
assert auth_me.json()["data"]["user_id"] == "api_user_001"
assert auth_me.json()["data"]["source"] == "demo_header"
cases = client.get("/api/v1/cases", headers=headers) cases = client.get("/api/v1/cases", headers=headers)
assert cases.status_code == 200 assert cases.status_code == 200
case_id = cases.json()["data"]["items"][0]["id"] case_id = cases.json()["data"]["items"][0]["id"]
+91 -13
View File
@@ -21,11 +21,18 @@ API base: http://127.0.0.1:8000/api/v1
### 1.2 必传 Header ### 1.2 必传 Header
所有业务接口都必须携带以下 Header 正式联调时,前端必须携带宿主系统登录态,推荐使用 `Authorization`
| Header | 类型 | 说明 | | Header | 类型 | 说明 |
|---|---:|---| |---|---:|---|
| `X-User-Id` | string | 宿主系统传入的用户标识。后端按该字段隔离会话、提交、评价和历史记录。 | | `Authorization` | string | 宿主系统登录 token,例如 `Bearer <token>`。医疗问诊 Agent 会转发到 Django `/api/user/users/me/`。 |
| `Cookie` | string | 如果宿主系统使用 Cookie 登录,浏览器可通过 `withCredentials` 携带 Cookie。 |
本地 Demo 兼容以下 Header
| Header | 类型 | 说明 |
|---|---:|---|
| `X-User-Id` | string | Demo 兼容用户标识。正式联调启用外部鉴权后,以 Django `/me/` 返回的用户为准。 |
| `X-Entry-Scene` | string | 入口场景。Demo 前端默认 `vue_demo`。 | | `X-Entry-Scene` | string | 入口场景。Demo 前端默认 `vue_demo`。 |
可选 Header 可选 Header
@@ -78,11 +85,16 @@ API base: http://127.0.0.1:8000/api/v1
| `CASE_SQL_FILE_EMPTY` | SQL 文件为空 | 提示重新导出文件。 | | `CASE_SQL_FILE_EMPTY` | SQL 文件为空 | 提示重新导出文件。 |
| `CASE_SQL_FILE_TOO_LARGE` | 文件超过 5MB | 提示压缩或拆分。 | | `CASE_SQL_FILE_TOO_LARGE` | 文件超过 5MB | 提示压缩或拆分。 |
| `CASE_SQL_IMPORT_INVALID` | SQL 解析或字段映射失败 | 展示 `errors`,不允许确认导入。 | | `CASE_SQL_IMPORT_INVALID` | SQL 解析或字段映射失败 | 展示 `errors`,不允许确认导入。 |
| `AUTH_CREDENTIAL_REQUIRED` | 启用外部鉴权后未携带 Authorization/Cookie | 引导用户回宿主系统登录。 |
| `AUTH_USER_CENTER_UNAVAILABLE` | Django 用户中心不可访问或超时 | 提示稍后重试,检查用户中心服务。 |
| `AUTH_USER_INVALID` | Django 返回用户无效或登录态过期 | 引导用户重新登录。 |
| `AUTH_USER_PARSE_FAILED` | Django 返回结构无法提取 user_id | 联系后端确认用户接口字段。 |
## 2. 前端主流程 ## 2. 前端主流程
```text ```text
入口页 入口页
-> GET /auth/me
-> GET /agent/hello -> GET /agent/hello
病例页 病例页
-> GET /cases -> GET /cases
@@ -130,7 +142,73 @@ inquiry -> diagnosis -> treatment -> evaluating -> evaluated
| `evaluating` | 生成评价报告 | | `evaluating` | 生成评价报告 |
| `evaluated` | 查看报告、导出 PDF、查看历史 | | `evaluated` | 查看报告、导出 PDF、查看历史 |
## 3. Agent Hello ## 3. 认证接口
### `GET /auth/me`
用途:进入医疗问诊 Agent 前校验当前登录用户,并返回标准化用户信息。
调用链路:
```text
Vue 前端
-> GET /api/v1/auth/me
FastAPI 医疗问诊 Agent
-> GET http://192.168.2.76:8000/api/user/users/me/
Django 用户中心
-> 返回当前登录用户
FastAPI
-> 提取 user_id 并返回标准结构
```
正式联调请求头:
```http
Authorization: Bearer <宿token>
X-Entry-Scene: mac_vue_dev
```
Cookie 登录时:
```http
Cookie: sessionid=...
X-Entry-Scene: mac_vue_dev
```
Response `data`
```json
{
"user_id": "123",
"source": "django_user_center",
"username": "zhangsan",
"display_name": "张三",
"tenant_id": null,
"role": null
}
```
本地 Demo 兼容模式中,如果未开启外部鉴权或允许 `X-User-Id` 兜底,返回:
```json
{
"user_id": "demo_user_001",
"source": "demo_header",
"username": null,
"display_name": null,
"tenant_id": null,
"role": null
}
```
前端处理规则:
- 进入 Agent 时先调用 `/auth/me`
- 成功后使用返回的 `user_id` 展示当前用户。
- 后续业务接口继续携带相同 `Authorization` 或 Cookie。
- 正式联调不要让用户手动输入 `X-User-Id`
## 4. Agent Hello
### `GET /agent/hello` ### `GET /agent/hello`
@@ -170,7 +248,7 @@ Response `data`
- `llm_fallback_to_mock`:真实模型失败时是否回退 mock。 - `llm_fallback_to_mock`:真实模型失败时是否回退 mock。
- `runtime_memory_backend`:短期记忆后端,通常为 `redis``memory` - `runtime_memory_backend`:短期记忆后端,通常为 `redis``memory`
## 4. 病例接口 ## 5. 病例接口
### `GET /cases` ### `GET /cases`
@@ -301,7 +379,7 @@ Response `data`
- 用户输入固定确认文案后再调用 `DELETE` - 用户输入固定确认文案后再调用 `DELETE`
- 删除成功后清空当前病例、会话、检查结果、报告缓存,并刷新 `/cases` - 删除成功后清空当前病例、会话、检查结果、报告缓存,并刷新 `/cases`
## 5. 会话与问诊接口 ## 6. 会话与问诊接口
### `POST /sessions` ### `POST /sessions`
@@ -436,7 +514,7 @@ Response `data`
- 练习模式中是否点击提示不影响评分链路。 - 练习模式中是否点击提示不影响评分链路。
- 教学互动模式当前不显示提示入口。 - 教学互动模式当前不显示提示入口。
## 6. 检查/检验接口 ## 7. 检查/检验接口
### `GET /sessions/{session_id}/order-items` ### `GET /sessions/{session_id}/order-items`
@@ -491,7 +569,7 @@ Response `data`
- 重复申请不重复写入 runtime memory。 - 重复申请不重复写入 runtime memory。
- 前端按 `item_code` 去重展示。 - 前端按 `item_code` 去重展示。
## 7. 阶段提交接口 ## 8. 阶段提交接口
### `POST /sessions/{session_id}/complete-inquiry` ### `POST /sessions/{session_id}/complete-inquiry`
@@ -554,7 +632,7 @@ Response `data`
} }
``` ```
## 8. 评价与报告接口 ## 9. 评价与报告接口
### `POST /sessions/{session_id}/evaluation` ### `POST /sessions/{session_id}/evaluation`
@@ -650,7 +728,7 @@ Response `data`
} }
``` ```
## 9. 病例 SQL 导入接口 ## 10. 病例 SQL 导入接口
### `POST /imports/case-sql/preview` ### `POST /imports/case-sql/preview`
@@ -726,7 +804,7 @@ Response `data`
- 导入成功后刷新病例列表。 - 导入成功后刷新病例列表。
- 如果源 SQL 缺少 `case_exam_item`,后端会生成基础检查项,保证新病例可训练。 - 如果源 SQL 缺少 `case_exam_item`,后端会生成基础检查项,保证新病例可训练。
## 10. 知识检索接口 ## 11. 知识检索接口
### `GET /knowledge/search` ### `GET /knowledge/search`
@@ -750,7 +828,7 @@ Response `data`
} }
``` ```
## 11. LLM 测试接口 ## 12. LLM 测试接口
### `POST /llm/test/deepseek-fast` ### `POST /llm/test/deepseek-fast`
@@ -787,7 +865,7 @@ Request 同 Fast 测试。
Response 字段同 Fast 测试。 Response 字段同 Fast 测试。
## 12. 前端字段枚举 ## 13. 前端字段枚举
| 字段 | 允许值 | | 字段 | 允许值 |
|---|---| |---|---|
@@ -797,7 +875,7 @@ Response 字段同 Fast 测试。
| `session.status` | `inquiry``diagnosis``treatment``evaluating``evaluated` | | `session.status` | `inquiry``diagnosis``treatment``evaluating``evaluated` |
| `patient.gender` | `male``female``null` | | `patient.gender` | `male``female``null` |
## 13. 前端联调注意事项 ## 14. 前端联调注意事项
1. 所有请求必须带 `X-User-Id`,否则后端返回 `USER_ID_REQUIRED` 1. 所有请求必须带 `X-User-Id`,否则后端返回 `USER_ID_REQUIRED`
2. 当前 Demo 不做登录注册,`user_id` 由宿主系统或测试页传入。 2. 当前 Demo 不做登录注册,`user_id` 由宿主系统或测试页传入。