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

@@ -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 causeJWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary新增 get_tenant_site_ids(tenant_id) 和 get_effective_site_ids(admin) 函数;改造 site_filter_clause 和 verify_site_access 支持 admin= keyword-only 参数(向后兼容旧签名)| Verifytenant_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提取管理员信息。
拒绝小程序 JWTaud 不匹配)及任何无效/过期令牌。
"""
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="无权访问该门店数据",
)