包含多个会话的累积代码变更: - 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>
209 lines
7.2 KiB
Python
209 lines
7.2 KiB
Python
# -*- 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="无权访问该门店数据",
|
||
)
|