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:
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