diff --git a/.env.example b/.env.example index 41d1ccf..c62ab75 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,13 @@ RUNTIME_MEMORY_BACKEND=redis REDIS_URL=redis://localhost:6379/0 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 LLM_BASE_URL=https://api.deepseek.com/chat/completions LLM_API_KEY= diff --git a/README.md b/README.md index 13a7449..bda8bfd 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,34 @@ http://127.0.0.1:5173 | `LLM_REASON_MODEL` | Reason 测试模型 | | `LLM_MOCK_ENABLED` | 是否强制 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 +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 兼容方式。 ## 验证命令 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..4a33c23 --- /dev/null +++ b/backend/app/api/auth.py @@ -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, + ) + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 0226225..9059984 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,9 +1,10 @@ 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.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(sessions.router, prefix="/sessions", tags=["sessions"]) api_router.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index eff2c17..abcf5e6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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_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")) + 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]: """配置展示:返回允许暴露给 Demo 前端的功能开关。""" @@ -102,6 +107,8 @@ class Settings(BaseModel): "llm_reasoning_effort": self.llm_reasoning_effort, "llm_fast_max_tokens": self.llm_fast_max_tokens, "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", } diff --git a/backend/app/core/context.py b/backend/app/core/context.py index 7243267..912df82 100644 --- a/backend/app/core/context.py +++ b/backend/app/core/context.py @@ -13,3 +13,6 @@ class UserContext: request_id: str | None = None ip_address: str | None = None user_agent: str | None = None + username: str | None = None + display_name: str | None = None + auth_source: str = "demo_header" diff --git a/backend/app/core/user_context.py b/backend/app/core/user_context.py index 238ac73..6c1ee50 100644 --- a/backend/app/core/user_context.py +++ b/backend/app/core/user_context.py @@ -1,7 +1,9 @@ from fastapi import Header, Request +from app.core.config import settings from app.core.context import UserContext from app.core.exceptions import AppError +from app.services.external_auth_service import ExternalAuthService 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_request_id: str | None = Header(default=None, alias="X-Request-Id"), ) -> 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(): 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, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("User-Agent"), + auth_source="demo_header", ) diff --git a/backend/app/main.py b/backend/app/main.py index 0188f04..43995e2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,8 +24,8 @@ def create_app() -> FastAPI: "http://127.0.0.1:5174", "http://localhost:5174", ], - allow_origin_regex=r"^http://(127\.0\.0\.1|localhost):\d+$", - allow_credentials=False, + allow_origin_regex=r"^http://(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+):\d+$", + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..ebccf45 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/external_auth_service.py b/backend/app/services/external_auth_service.py new file mode 100644 index 0000000..83a4000 --- /dev/null +++ b/backend/app/services/external_auth_service.py @@ -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) diff --git a/backend/tests/test_api_contract.py b/backend/tests/test_api_contract.py index 510cf57..2877b34 100644 --- a/backend/tests/test_api_contract.py +++ b/backend/tests/test_api_contract.py @@ -40,6 +40,11 @@ def run_api_contract_tests() -> None: assert hello.status_code == 200 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) assert cases.status_code == 200 case_id = cases.json()["data"]["items"][0]["id"] diff --git a/docs/03_api_design.md b/docs/03_api_design.md index fbfc4da..7bac01e 100644 --- a/docs/03_api_design.md +++ b/docs/03_api_design.md @@ -21,11 +21,18 @@ API base: http://127.0.0.1:8000/api/v1 ### 1.2 必传 Header -所有业务接口都必须携带以下 Header: +正式联调时,前端必须携带宿主系统登录态,推荐使用 `Authorization`: | Header | 类型 | 说明 | |---|---:|---| -| `X-User-Id` | string | 宿主系统传入的用户标识。后端按该字段隔离会话、提交、评价和历史记录。 | +| `Authorization` | string | 宿主系统登录 token,例如 `Bearer `。医疗问诊 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`。 | 可选 Header: @@ -78,11 +85,16 @@ API base: http://127.0.0.1:8000/api/v1 | `CASE_SQL_FILE_EMPTY` | SQL 文件为空 | 提示重新导出文件。 | | `CASE_SQL_FILE_TOO_LARGE` | 文件超过 5MB | 提示压缩或拆分。 | | `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. 前端主流程 ```text 入口页 + -> GET /auth/me -> GET /agent/hello 病例页 -> GET /cases @@ -130,7 +142,73 @@ inquiry -> diagnosis -> treatment -> evaluating -> evaluated | `evaluating` | 生成评价报告 | | `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` @@ -170,7 +248,7 @@ Response `data`: - `llm_fallback_to_mock`:真实模型失败时是否回退 mock。 - `runtime_memory_backend`:短期记忆后端,通常为 `redis` 或 `memory`。 -## 4. 病例接口 +## 5. 病例接口 ### `GET /cases` @@ -301,7 +379,7 @@ Response `data`: - 用户输入固定确认文案后再调用 `DELETE`。 - 删除成功后清空当前病例、会话、检查结果、报告缓存,并刷新 `/cases`。 -## 5. 会话与问诊接口 +## 6. 会话与问诊接口 ### `POST /sessions` @@ -436,7 +514,7 @@ Response `data`: - 练习模式中是否点击提示不影响评分链路。 - 教学互动模式当前不显示提示入口。 -## 6. 检查/检验接口 +## 7. 检查/检验接口 ### `GET /sessions/{session_id}/order-items` @@ -491,7 +569,7 @@ Response `data`: - 重复申请不重复写入 runtime memory。 - 前端按 `item_code` 去重展示。 -## 7. 阶段提交接口 +## 8. 阶段提交接口 ### `POST /sessions/{session_id}/complete-inquiry` @@ -554,7 +632,7 @@ Response `data`: } ``` -## 8. 评价与报告接口 +## 9. 评价与报告接口 ### `POST /sessions/{session_id}/evaluation` @@ -650,7 +728,7 @@ Response `data`: } ``` -## 9. 病例 SQL 导入接口 +## 10. 病例 SQL 导入接口 ### `POST /imports/case-sql/preview` @@ -726,7 +804,7 @@ Response `data`: - 导入成功后刷新病例列表。 - 如果源 SQL 缺少 `case_exam_item`,后端会生成基础检查项,保证新病例可训练。 -## 10. 知识检索接口 +## 11. 知识检索接口 ### `GET /knowledge/search` @@ -750,7 +828,7 @@ Response `data`: } ``` -## 11. LLM 测试接口 +## 12. LLM 测试接口 ### `POST /llm/test/deepseek-fast` @@ -787,7 +865,7 @@ Request 同 Fast 测试。 Response 字段同 Fast 测试。 -## 12. 前端字段枚举 +## 13. 前端字段枚举 | 字段 | 允许值 | |---|---| @@ -797,7 +875,7 @@ Response 字段同 Fast 测试。 | `session.status` | `inquiry`、`diagnosis`、`treatment`、`evaluating`、`evaluated` | | `patient.gender` | `male`、`female`、`null` | -## 13. 前端联调注意事项 +## 14. 前端联调注意事项 1. 所有请求必须带 `X-User-Id`,否则后端返回 `USER_ID_REQUIRED`。 2. 当前 Demo 不做登录注册,`user_id` 由宿主系统或测试页传入。