feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,18 +13,92 @@ FastAPI 依赖注入:从 JWT 提取当前用户信息。
|
||||
... # 受限逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
|
||||
from app.auth.jwt import decode_access_token
|
||||
from app.trace.context import SpanType, TraceSpan, get_current_trace
|
||||
from app.trace.decorators import truncate_token
|
||||
|
||||
# Bearer token 提取器
|
||||
_bearer_scheme = HTTPBearer(auto_error=True)
|
||||
|
||||
|
||||
# ── 鉴权失败原因分类常量 ──
|
||||
AUTH_EXPIRED = "AUTH_EXPIRED"
|
||||
AUTH_INVALID = "AUTH_INVALID"
|
||||
AUTH_MALFORMED = "AUTH_MALFORMED"
|
||||
AUTH_LIMITED = "AUTH_LIMITED"
|
||||
AUTH_FORBIDDEN = "AUTH_FORBIDDEN"
|
||||
|
||||
|
||||
def _record_auth_span(
|
||||
*,
|
||||
token: str,
|
||||
success: bool,
|
||||
user_id: int | None = None,
|
||||
site_id: int | None = None,
|
||||
roles: list[str] | None = None,
|
||||
user_status: str = "",
|
||||
failure_reason: str = "",
|
||||
detail: str = "",
|
||||
duration_ms: float = 0.0,
|
||||
) -> None:
|
||||
"""向当前 TraceContext 添加 AUTH span(无 trace 时静默跳过)。"""
|
||||
ctx = get_current_trace()
|
||||
if ctx is None:
|
||||
return
|
||||
|
||||
token_prefix = truncate_token(token)
|
||||
|
||||
if success:
|
||||
desc_zh = f"JWT 鉴权通过:user_id={user_id}, site_id={site_id}, roles={roles}"
|
||||
desc_en = f"JWT auth passed: user_id={user_id}, site_id={site_id}, roles={roles}"
|
||||
result_summary = "approved"
|
||||
else:
|
||||
desc_zh = f"JWT 鉴权失败:{failure_reason} — {detail}"
|
||||
desc_en = f"JWT auth failed: {failure_reason} — {detail}"
|
||||
result_summary = failure_reason
|
||||
|
||||
extra: dict = {}
|
||||
if failure_reason:
|
||||
extra["failure_reason"] = failure_reason
|
||||
|
||||
ctx.add_span(TraceSpan(
|
||||
span_type=SpanType.AUTH,
|
||||
module="auth.dependencies",
|
||||
function="get_current_user",
|
||||
description_zh=desc_zh,
|
||||
description_en=desc_en,
|
||||
params={"token_prefix": token_prefix},
|
||||
result_summary=result_summary,
|
||||
duration_ms=duration_ms,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
extra=extra,
|
||||
))
|
||||
|
||||
# 鉴权成功时将 user_id / site_id 写入 TraceContext
|
||||
if success and user_id is not None:
|
||||
ctx.user_id = user_id
|
||||
if site_id is not None:
|
||||
ctx.site_id = site_id
|
||||
|
||||
|
||||
def _classify_jwt_error(exc: JWTError) -> str:
|
||||
"""根据 JWTError 消息分类失败原因。"""
|
||||
msg = str(exc).lower()
|
||||
if "expired" in msg or "exp" in msg:
|
||||
return AUTH_EXPIRED
|
||||
return AUTH_INVALID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentUser:
|
||||
"""从 JWT 解析出的当前用户上下文。"""
|
||||
@@ -45,9 +119,17 @@ async def get_current_user(
|
||||
要求完整令牌(非 limited),失败时抛出 401。
|
||||
"""
|
||||
token = credentials.credentials
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except JWTError:
|
||||
except JWTError as exc:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
reason = _classify_jwt_error(exc)
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=reason, detail="无效的令牌",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
@@ -56,6 +138,12 @@ async def get_current_user(
|
||||
|
||||
# 受限令牌不允许通过此依赖
|
||||
if payload.get("limited"):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_LIMITED, detail="受限令牌无法访问此端点",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="受限令牌无法访问此端点",
|
||||
@@ -66,6 +154,12 @@ async def get_current_user(
|
||||
site_id = payload.get("site_id")
|
||||
|
||||
if user_id_raw is None or site_id is None:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌缺少必要字段",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
@@ -75,6 +169,12 @@ async def get_current_user(
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌中 user_id 格式无效",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 user_id 格式无效",
|
||||
@@ -82,6 +182,13 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
roles = payload.get("roles", [])
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
_record_auth_span(
|
||||
token=token, success=True,
|
||||
user_id=user_id, site_id=site_id, roles=roles,
|
||||
user_status="approved", duration_ms=elapsed,
|
||||
)
|
||||
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
@@ -102,9 +209,17 @@ async def get_current_user_or_limited(
|
||||
- 完整令牌:正常返回 CurrentUser
|
||||
"""
|
||||
token = credentials.credentials
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except JWTError:
|
||||
except JWTError as exc:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
reason = _classify_jwt_error(exc)
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=reason, detail="无效的令牌",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
@@ -113,6 +228,12 @@ async def get_current_user_or_limited(
|
||||
|
||||
user_id_raw = payload.get("sub")
|
||||
if user_id_raw is None:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌缺少必要字段",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
@@ -122,6 +243,12 @@ async def get_current_user_or_limited(
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌中 user_id 格式无效",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 user_id 格式无效",
|
||||
@@ -130,6 +257,12 @@ async def get_current_user_or_limited(
|
||||
|
||||
# 受限令牌:pending 用户
|
||||
if payload.get("limited"):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=True,
|
||||
user_id=user_id, site_id=0, roles=[],
|
||||
user_status="pending", duration_ms=elapsed,
|
||||
)
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
site_id=0,
|
||||
@@ -141,6 +274,12 @@ async def get_current_user_or_limited(
|
||||
# 完整令牌:要求 site_id
|
||||
site_id = payload.get("site_id")
|
||||
if site_id is None:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌缺少必要字段",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
@@ -148,6 +287,13 @@ async def get_current_user_or_limited(
|
||||
)
|
||||
|
||||
roles = payload.get("roles", [])
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
_record_auth_span(
|
||||
token=token, success=True,
|
||||
user_id=user_id, site_id=site_id, roles=roles,
|
||||
user_status="approved", duration_ms=elapsed,
|
||||
)
|
||||
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
|
||||
Reference in New Issue
Block a user