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,
|
||||
|
||||
48
apps/backend/app/auth/internal_token.py
Normal file
48
apps/backend/app/auth/internal_token.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用 Internal-Token 认证依赖。
|
||||
|
||||
从环境变量 INTERNAL_API_TOKEN 读取期望 token,
|
||||
供 /api/internal/* 端点使用(不依赖 AIConfig)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
|
||||
def verify_internal_token(authorization: str = Header(...)) -> str:
|
||||
"""校验 Internal-Token 认证。
|
||||
|
||||
Header 格式:Authorization: Internal-Token {token}
|
||||
"""
|
||||
prefix = "Internal-Token "
|
||||
if not authorization.startswith(prefix):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证格式,需要 Internal-Token",
|
||||
)
|
||||
|
||||
token = authorization[len(prefix):]
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 不能为空",
|
||||
)
|
||||
|
||||
expected = os.environ.get("INTERNAL_API_TOKEN", "")
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="INTERNAL_API_TOKEN 未配置",
|
||||
)
|
||||
|
||||
if token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 不匹配",
|
||||
)
|
||||
|
||||
return token
|
||||
208
apps/backend/app/auth/tenant_admins.py
Normal file
208
apps/backend/app/auth/tenant_admins.py
Normal file
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
租户管理员认证依赖注入。
|
||||
|
||||
提供 require_tenant_admin() 依赖,验证 JWT aud=tenant-admin,
|
||||
与小程序端 get_current_user()(aud 隐含为 xcx)完全隔离。
|
||||
|
||||
用法:
|
||||
@router.get("/protected")
|
||||
async def endpoint(admin: CurrentTenantAdmin = Depends(require_tenant_admin)):
|
||||
print(admin.admin_id, admin.managed_site_ids)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:新增 get_tenant_site_ids(tenant_id) 和 get_effective_site_ids(admin) 函数;改造 site_filter_clause 和 verify_site_access 支持 admin= keyword-only 参数(向后兼容旧签名)| Verify:tenant_admin 新建店铺后无需重新登录即可访问所有端点
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
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 import config as _config
|
||||
from jose import jwt as _jose_jwt
|
||||
|
||||
# 复用与 dependencies.py 相同的 Bearer 提取器
|
||||
_bearer_scheme = HTTPBearer(auto_error=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentTenantAdmin:
|
||||
"""从 JWT 解析出的租户管理员上下文。"""
|
||||
|
||||
admin_id: int
|
||||
tenant_id: int
|
||||
managed_site_ids: list[int] = field(default_factory=list)
|
||||
display_name: str | None = None
|
||||
admin_type: str = "tenant_admin" # tenant_admin / site_admin
|
||||
|
||||
|
||||
async def require_tenant_admin(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
|
||||
) -> CurrentTenantAdmin:
|
||||
"""
|
||||
FastAPI 依赖:验证 JWT aud=tenant-admin,提取管理员信息。
|
||||
|
||||
拒绝小程序 JWT(aud 不匹配)及任何无效/过期令牌。
|
||||
"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
# 直接解码并验证 aud=tenant-admin + type=access
|
||||
# 不能复用 decode_access_token(),因为它不传 audience 参数,
|
||||
# jose 遇到 aud claim 但无 audience 参数时会直接拒绝。
|
||||
payload = _jose_jwt.decode(
|
||||
token,
|
||||
_config.JWT_SECRET_KEY,
|
||||
algorithms=[_config.JWT_ALGORITHM],
|
||||
audience="tenant-admin",
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 验证 token type 为 access(与 decode_access_token 一致)
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌类型不匹配",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# jose 在 aud claim 缺失时不会拒绝,需要显式检查
|
||||
if payload.get("aud") != "tenant-admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌类型不匹配",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 提取必要字段
|
||||
sub = payload.get("sub")
|
||||
tenant_id = payload.get("tenant_id")
|
||||
managed_site_ids = payload.get("managed_site_ids")
|
||||
|
||||
if sub is None or tenant_id is None or managed_site_ids is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
admin_id = int(sub)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 admin_id 格式无效",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return CurrentTenantAdmin(
|
||||
admin_id=admin_id,
|
||||
tenant_id=tenant_id,
|
||||
managed_site_ids=managed_site_ids,
|
||||
display_name=payload.get("display_name"),
|
||||
admin_type=payload.get("admin_type", "tenant_admin"),
|
||||
)
|
||||
|
||||
|
||||
# ── 数据隔离工具函数 ─────────────────────────────────────────
|
||||
|
||||
# [CHANGE P20260323-210000] intent: 根治 tenant_admin 的 managed_site_ids 限制,
|
||||
# tenant_admin 按 tenant_id 查 biz.sites 获取有效 site_ids,
|
||||
# site_admin 仍用 JWT 中的 managed_site_ids。
|
||||
# assumptions: biz.sites 数据量极小(几条),无需缓存
|
||||
# verify: tenant_admin 新建店铺后无需重新登录即可访问
|
||||
|
||||
|
||||
def get_tenant_site_ids(tenant_id: int) -> list[int]:
|
||||
"""查询租户下所有活跃店铺的 site_id 列表。
|
||||
|
||||
通过 biz.tenants.tenant_id(外部租户标识)→ biz.tenants.id(内部 PK)
|
||||
→ biz.sites.tenant_id 关联查询。
|
||||
"""
|
||||
from app.database import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.site_id
|
||||
FROM biz.sites s
|
||||
JOIN biz.tenants t ON t.id = s.tenant_id
|
||||
WHERE t.tenant_id = %s AND t.is_active = true
|
||||
AND s.is_active = true
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
return [row[0] for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_effective_site_ids(admin: CurrentTenantAdmin) -> list[int]:
|
||||
"""获取管理员的有效 site_id 列表。
|
||||
|
||||
- tenant_admin:实时查 biz.sites(覆盖新建店铺)
|
||||
- site_admin:使用 JWT 中的 managed_site_ids(精确控制)
|
||||
"""
|
||||
if admin.admin_type == "tenant_admin":
|
||||
return get_tenant_site_ids(admin.tenant_id)
|
||||
return admin.managed_site_ids
|
||||
|
||||
|
||||
def site_filter_clause(
|
||||
managed_site_ids: list[int] | None = None,
|
||||
*,
|
||||
admin: CurrentTenantAdmin | None = None,
|
||||
) -> tuple[str, tuple]:
|
||||
"""生成 site_id IN (...) SQL 片段,用于数据隔离查询。
|
||||
|
||||
优先使用 admin 参数(自动区分 tenant_admin/site_admin),
|
||||
也兼容旧的 managed_site_ids 直传方式。
|
||||
|
||||
返回 (sql_fragment, params_tuple),可直接拼入 WHERE 子句。
|
||||
"""
|
||||
if admin is not None:
|
||||
site_ids = get_effective_site_ids(admin)
|
||||
elif managed_site_ids is not None:
|
||||
site_ids = managed_site_ids
|
||||
else:
|
||||
return "1 = 0", ()
|
||||
|
||||
if not site_ids:
|
||||
return "1 = 0", ()
|
||||
placeholders = ", ".join(["%s"] * len(site_ids))
|
||||
return f"site_id IN ({placeholders})", tuple(site_ids)
|
||||
|
||||
|
||||
def verify_site_access(
|
||||
site_id: int,
|
||||
managed_site_ids: list[int] | None = None,
|
||||
*,
|
||||
admin: CurrentTenantAdmin | None = None,
|
||||
) -> None:
|
||||
"""校验 site_id 是否在管辖范围内,不在则抛 403。
|
||||
|
||||
优先使用 admin 参数(自动区分 tenant_admin/site_admin),
|
||||
也兼容旧的 managed_site_ids 直传方式。
|
||||
"""
|
||||
if admin is not None:
|
||||
effective_ids = get_effective_site_ids(admin)
|
||||
elif managed_site_ids is not None:
|
||||
effective_ids = managed_site_ids
|
||||
else:
|
||||
effective_ids = []
|
||||
|
||||
if site_id not in effective_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问该门店数据",
|
||||
)
|
||||
Reference in New Issue
Block a user