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:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -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,