prepare backend-only fastapi deployment

This commit is contained in:
刘金宝
2026-06-01 17:32:18 +08:00
parent 338e2c8e1d
commit 132155c280
59 changed files with 374 additions and 9155 deletions
+8 -1
View File
@@ -18,7 +18,14 @@ def hello(ctx: UserContext = Depends(get_user_context), db: Session = Depends(ge
db.commit()
return ok(
AgentHelloResponse(
user=AgentHelloUser(user_id=ctx.user_id, tenant_id=ctx.tenant_id, role=ctx.role),
user=AgentHelloUser(
user_id=ctx.user_id,
tenant_id=ctx.tenant_id,
role=ctx.role,
source=ctx.auth_source,
username=ctx.username,
display_name=ctx.display_name,
),
features=settings.as_public_dict(),
)
)
+21 -1
View File
@@ -9,7 +9,8 @@ router = APIRouter()
@router.get("/me", response_model=ApiResponse[AuthMeResponse])
async def auth_me(ctx: UserContext = Depends(get_user_context)):
"""当前用户:返回经 Django 用户中心或 Demo Header 标准化后的用户信息。"""
"""当前用户:转发 Authorization 到 Django 用户中心,并返回标准化后的用户信息。"""
profile = ctx.profile or {}
return ok(
AuthMeResponse(
user_id=ctx.user_id,
@@ -18,5 +19,24 @@ async def auth_me(ctx: UserContext = Depends(get_user_context)):
display_name=ctx.display_name,
tenant_id=ctx.tenant_id,
role=ctx.role,
phone=profile.get("phone"),
avatar=profile.get("avatar"),
gender=profile.get("gender"),
institution=profile.get("institution"),
institution_name=profile.get("institution_name"),
department=profile.get("department"),
department_name=profile.get("department_name"),
title_name=profile.get("title_name"),
major=profile.get("major"),
training_stage=profile.get("training_stage"),
learning_target=profile.get("learning_target"),
competency_profile=profile.get("competency_profile"),
weak_dimensions=profile.get("weak_dimensions"),
strong_dimensions=profile.get("strong_dimensions"),
ai_preference=profile.get("ai_preference"),
total_training_count=profile.get("total_training_count"),
total_case_count=profile.get("total_case_count"),
current_level=profile.get("current_level"),
status=profile.get("status"),
)
)
+4 -5
View File
@@ -82,12 +82,11 @@ class Settings(BaseModel):
report_storage_dir: str = Field(default_factory=lambda: os.getenv("REPORT_STORAGE_DIR", "./storage/reports"))
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")
redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://redis:6379/0"))
auth_validate_enabled: bool = Field(default_factory=lambda: os.getenv("AUTH_VALIDATE_ENABLED", "true").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 前端的功能开关。"""
@@ -107,8 +106,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",
"auth_validate_enabled": True,
"auth_source": "django_user_center",
}
+2 -1
View File
@@ -15,4 +15,5 @@ class UserContext:
user_agent: str | None = None
username: str | None = None
display_name: str | None = None
auth_source: str = "demo_header"
auth_source: str = "django_user_center"
profile: dict | None = None
+8 -8
View File
@@ -16,10 +16,10 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
logger.warning(
"business_error code=%s path=%s user_id=%s",
"business_error code=%s path=%s request_id=%s",
exc.code,
request.url.path,
request.headers.get("X-User-Id"),
request.headers.get("X-Request-Id"),
)
return JSONResponse(
status_code=exc.status_code,
@@ -29,9 +29,9 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(RequestValidationError)
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
logger.warning(
"validation_error path=%s user_id=%s errors=%s",
"validation_error path=%s request_id=%s errors=%s",
request.url.path,
request.headers.get("X-User-Id"),
request.headers.get("X-Request-Id"),
exc.errors(),
)
return JSONResponse(
@@ -42,9 +42,9 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(SQLAlchemyError)
async def handle_database_error(request: Request, exc: SQLAlchemyError) -> JSONResponse:
logger.exception(
"database_error path=%s user_id=%s",
"database_error path=%s request_id=%s",
request.url.path,
request.headers.get("X-User-Id"),
request.headers.get("X-Request-Id"),
)
return JSONResponse(
status_code=500,
@@ -54,9 +54,9 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(Exception)
async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse:
logger.exception(
"unexpected_error path=%s user_id=%s",
"unexpected_error path=%s request_id=%s",
request.url.path,
request.headers.get("X-User-Id"),
request.headers.get("X-Request-Id"),
)
return JSONResponse(
status_code=500,
+16 -33
View File
@@ -1,51 +1,34 @@
from fastapi import Header, Request
from fastapi import Header, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
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
bearer_scheme = HTTPBearer(auto_error=False, description="Django 用户中心 access token")
async def get_user_context(
request: Request,
x_user_id: str | None = Header(default=None, alias="X-User-Id"),
x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id"),
x_user_role: str | None = Header(default=None, alias="X-User-Role"),
x_class_id: str | None = Header(default=None, alias="X-Class-Id"),
credentials: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
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:
"""用户校验:正式联调优先调用 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)
"""用户校验:只接受宿主系统 access token,并转发 Django 用户中心 `/me` 获取真实用户"""
if not credentials or not credentials.credentials.strip():
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization header is required", 401)
user = await ExternalAuthService().authenticate(request)
return UserContext(
user_id=x_user_id.strip(),
tenant_id=x_tenant_id,
role=x_user_role,
class_id=x_class_id,
user_id=user.user_id,
tenant_id=user.tenant_id,
role=user.role,
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"),
auth_source="demo_header",
username=user.username,
display_name=user.display_name,
auth_source=user.source,
profile=user.profile,
)
+3
View File
@@ -7,6 +7,9 @@ class AgentHelloUser(BaseModel):
user_id: str
tenant_id: str | None = None
role: str | None = None
source: str | None = None
username: str | None = None
display_name: str | None = None
class AgentHelloResponse(BaseModel):
+21
View File
@@ -1,3 +1,5 @@
from typing import Any
from pydantic import BaseModel
@@ -10,3 +12,22 @@ class AuthMeResponse(BaseModel):
display_name: str | None = None
tenant_id: str | None = None
role: str | None = None
phone: str | None = None
avatar: str | None = None
gender: int | None = None
institution: int | None = None
institution_name: str | None = None
department: int | None = None
department_name: str | None = None
title_name: str | None = None
major: str | None = None
training_stage: str | None = None
learning_target: str | None = None
competency_profile: dict[str, Any] | None = None
weak_dimensions: list[Any] | None = None
strong_dimensions: list[Any] | None = None
ai_preference: dict[str, Any] | None = None
total_training_count: int | None = None
total_case_count: int | None = None
current_level: str | None = None
status: int | None = None
+89 -9
View File
@@ -22,6 +22,20 @@ class AuthenticatedUser:
display_name: str | None = None
tenant_id: str | None = None
role: str | None = None
phone: str | None = None
avatar: str | None = None
gender: int | None = None
institution_id: int | None = None
institution_name: str | None = None
department_id: int | None = None
department_name: str | None = None
title_name: str | None = None
major: str | None = None
training_stage: str | None = None
learning_target: str | None = None
current_level: str | None = None
status: int | None = None
profile: dict[str, Any] | None = None
class ExternalAuthService:
@@ -36,7 +50,7 @@ class ExternalAuthService:
outbound_headers = self._build_forward_headers(request)
if not outbound_headers:
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization or Cookie is required", 401)
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization header is required", 401)
cache_key = self._cache_key(outbound_headers)
cached = self._read_cache(cache_key)
@@ -64,16 +78,14 @@ class ExternalAuthService:
return user
def _build_forward_headers(self, request: Request) -> dict[str, str]:
"""认证转发:只透传鉴权必要 Header,避免把无关内部头转发给用户中心。"""
"""认证转发:只透传 Bearer token,避免把无关内部头转发给用户中心。"""
headers: dict[str, str] = {}
authorization = request.headers.get("Authorization")
cookie = request.headers.get("Cookie")
if authorization:
authorization = authorization.strip()
if authorization and " " not in authorization:
authorization = f"Bearer {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:
@@ -82,18 +94,69 @@ class ExternalAuthService:
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)
status = self._to_int(data.get("status"))
if status == 0:
raise AppError("AUTH_USER_DISABLED", "current user is disabled", 403)
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"])
role = self._first_present(data, ["role_type", "role", "user_role"])
institution_id = self._to_int(data.get("institution"))
tenant_id = str(institution_id) if institution_id is not None else None
profile = self._build_profile(data)
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,
phone=self._to_str(data.get("phone")),
avatar=self._to_str(data.get("avatar")),
gender=self._to_int(data.get("gender")),
institution_id=institution_id,
institution_name=self._to_str(data.get("institution_name")),
department_id=self._to_int(data.get("department")),
department_name=self._to_str(data.get("department_name")),
title_name=self._to_str(data.get("title_name")),
major=self._to_str(data.get("major")),
training_stage=self._to_str(data.get("training_stage")),
learning_target=self._to_str(data.get("learning_target")),
current_level=self._to_str(data.get("current_level")),
status=status,
profile=profile,
)
def _build_profile(self, data: dict[str, Any]) -> dict[str, Any]:
"""用户画像:按 Django `/me` 字段白名单保留学习画像,供前端和后续 Agent 个性化使用。"""
keys = [
"id",
"username",
"real_name",
"phone",
"avatar",
"gender",
"role_type",
"institution",
"institution_name",
"department",
"department_name",
"title_name",
"major",
"training_stage",
"learning_target",
"competency_profile",
"weak_dimensions",
"strong_dimensions",
"ai_preference",
"total_training_count",
"total_case_count",
"current_level",
"status",
"last_login_time",
"created_at",
"updated_at",
]
return {key: data.get(key) for key in keys if key in data}
@staticmethod
def _first_present(data: dict[str, Any], keys: list[str]) -> Any:
"""用户解析:按优先级读取第一个非空字段。"""
@@ -103,6 +166,23 @@ class ExternalAuthService:
return value
return None
@staticmethod
def _to_int(value: Any) -> int | None:
"""用户解析:把 Django 返回的数字字段稳定转成 int,空值保持 None。"""
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _to_str(value: Any) -> str | None:
"""用户解析:把可展示字段转成字符串,空值保持 None。"""
if value is None or value == "":
return None
return str(value)
@staticmethod
def _cache_key(headers: dict[str, str]) -> str:
"""认证缓存:基于鉴权凭证生成不可逆缓存键,避免保存明文 token。"""