Files
Neo-ZQYY/apps/backend/app/routers/admin_registry.py
Neo 6f8f12314f 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>
2026-04-06 00:03:48 +08:00

674 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
管理端路由 — 注册体系(连接器/租户/店铺/简写ID/店铺同步)。
端点清单:
- GET /api/admin/tenants — 所有活跃租户列表
- GET /api/admin/tenants/{tenant_id}/sites — 指定租户下所有活跃店铺
- PUT /api/admin/sites/{site_id}/site-code — 设置/修改简写ID
- GET /api/admin/sites/{site_id}/site-code-history — 简写ID 变更历史
- POST /api/admin/sites/sync — 手动触发店铺同步
- POST /api/admin/sites/sync/internal — 内部 APIETL DWD 完成后触发同步(无认证,隐藏)
除 /sites/sync/internal 外,所有端点要求 JWT + site_admin 或 tenant_admin 角色。
需求: A2.1, A2.2, A2.4, A2.5, A3.1, A3.2, A3.3, A3.4, A5.1, A5.2, A5.3, A5.4
"""
from __future__ import annotations
import logging
import re
import psycopg2
from fastapi import APIRouter, Depends, HTTPException, status
from psycopg2.extensions import connection as PgConnection
from app.auth.dependencies import CurrentUser, get_current_user
from app.config import (
ETL_DB_HOST,
ETL_DB_NAME,
ETL_DB_PASSWORD,
ETL_DB_PORT,
ETL_DB_USER,
)
from app.database import get_connection
from app.schemas.admin_registry import (
CreateSiteRequest,
SiteCodeHistoryItem,
SiteCodeResult,
SiteItem,
SiteSyncResult,
TenantItem,
UpdateSiteCodeRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin-registry"])
# 简写ID 格式:前 3 位字母/数字 + 后 3 位数字(共 6 位)
_SITE_CODE_PATTERN = re.compile(r"^[A-Z0-9]{3}\d{3}$")
# ── ETL 库直连(无 RLS管理端同步专用 ─────────────────
def _get_etl_admin_connection() -> PgConnection:
"""获取 ETL 库只读连接(无 RLS 隔离),用于管理端跨站点同步。
与 database.get_etl_readonly_connection 不同:
- 不设置 app.current_site_id需要读取所有站点数据
- 仍设置 read_only 防止误写
"""
conn = psycopg2.connect(
host=ETL_DB_HOST,
port=ETL_DB_PORT,
user=ETL_DB_USER,
password=ETL_DB_PASSWORD,
dbname=ETL_DB_NAME,
)
try:
conn.autocommit = False
with conn.cursor() as cur:
cur.execute("SET default_transaction_read_only = on")
conn.commit()
except Exception:
conn.close()
raise
return conn
# ── 店铺同步核心逻辑 ─────────────────────────────────────
def sync_sites_from_etl() -> SiteSyncResult:
"""从 ETL 库 dwd.dim_site 同步店铺到 biz.sites。
逻辑:
1. 读取 dwd.dim_sitescd2_is_current=1获取当前有效店铺
2. 对比 biz.sites
- 新 site_id → INSERTsite_code 留空tenant_id 通过 dim_site.tenant_id 映射 biz.tenants
- 已有 site_id 且 shop_name/site_label 变更 → UPDATE
3. 不删除已有记录
需求: A5.1, A5.2
"""
# 1. 从 ETL 库读取当前有效店铺
etl_conn = _get_etl_admin_connection()
try:
with etl_conn.cursor() as cur:
cur.execute(
"""
SELECT site_id, tenant_id, shop_name, site_label
FROM dwd.dim_site
WHERE scd2_is_current = 1
"""
)
etl_sites = cur.fetchall()
finally:
etl_conn.close()
if not etl_sites:
return SiteSyncResult(inserted=0, updated=0)
# 2. 在 app 库中执行对比和写入
app_conn = get_connection()
inserted = 0
updated = 0
try:
with app_conn.cursor() as cur:
# 构建 tenant_id → biz.tenants.id 映射
cur.execute("SELECT tenant_id, id FROM biz.tenants WHERE is_active = true")
tenant_map: dict[int, int] = {row[0]: row[1] for row in cur.fetchall()}
# 获取 biz.sites 现有数据site_id → (biz_id, site_name, site_label)
cur.execute(
"SELECT site_id, id, site_name, site_label FROM biz.sites"
)
existing: dict[int, tuple[int, str | None, str | None]] = {
row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()
}
for etl_site_id, etl_tenant_id, etl_shop_name, etl_site_label in etl_sites:
biz_tenant_id = tenant_map.get(etl_tenant_id)
if biz_tenant_id is None:
# 租户未注册,跳过
logger.warning(
"同步跳过: site_id=%s 的 tenant_id=%s 在 biz.tenants 中不存在",
etl_site_id, etl_tenant_id,
)
continue
if etl_site_id not in existing:
# 新增店铺site_code 留空
cur.execute(
"""
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_label)
VALUES (%s, %s, %s, %s)
""",
(biz_tenant_id, etl_site_id, etl_shop_name, etl_site_label),
)
inserted += 1
else:
# 已有店铺:检查名称/标签是否变更
_, cur_name, cur_label = existing[etl_site_id]
if cur_name != etl_shop_name or cur_label != etl_site_label:
cur.execute(
"""
UPDATE biz.sites
SET site_name = %s, site_label = %s, updated_at = NOW()
WHERE site_id = %s
""",
(etl_shop_name, etl_site_label, etl_site_id),
)
updated += 1
app_conn.commit()
except Exception:
app_conn.rollback()
raise
finally:
app_conn.close()
logger.info("店铺同步完成: 新增 %d, 更新 %d", inserted, updated)
return SiteSyncResult(inserted=inserted, updated=updated)
# ── 管理端权限依赖 ──────────────────────────────────────
def _require_admin():
"""管理端依赖:要求 JWT 中角色包含 site_admin 或 tenant_admin。"""
async def _dependency(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
admin_roles = {"site_admin", "tenant_admin"}
if not admin_roles.intersection(user.roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限site_admin 或 tenant_admin",
)
return user
return _dependency
# ── GET /api/admin/tenants ────────────────────────────────
@router.get("/tenants")
async def list_tenants(
user: CurrentUser = Depends(_require_admin()),
) -> list[TenantItem]:
"""
所有活跃租户列表(含连接器名称)。
JOIN biz.connectors 获取 connector_name。
需求 A2.1
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT t.id, t.tenant_id, t.tenant_name,
c.display_name AS connector_name, t.is_active
FROM biz.tenants t
JOIN biz.connectors c ON c.id = t.connector_id
WHERE t.is_active = true
ORDER BY t.id
"""
)
rows = cur.fetchall()
finally:
conn.close()
return [
TenantItem(
id=r[0],
tenant_id=r[1],
tenant_name=r[2],
connector_name=r[3],
is_active=r[4],
)
for r in rows
]
# ── GET /api/admin/tenants/{tenant_id}/sites ──────────────
@router.get("/tenants/{tenant_id}/sites")
async def list_tenant_sites(
tenant_id: int,
user: CurrentUser = Depends(_require_admin()),
) -> list[SiteItem]:
"""
指定租户下所有活跃店铺。
tenant_id 参数支持两种格式:
- 上游系统租户 IDBIGINT如 2790683160709957
- 内部主键SERIAL如 1, 2, 3...
自动判断:> 10000 视为上游 ID否则视为内部 PK。
需求 A2.2
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# CHANGE 2026-03-22 | Prompt: 管辖门店下拉为空 | 兼容上游 tenant_id 和内部 PK
# 自动判断:上游 ID 是 BIGINT远大于内部 SERIAL阈值 10000 足够区分
if tenant_id > 10000:
cur.execute(
"SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true",
(tenant_id,),
)
else:
cur.execute(
"SELECT id FROM biz.tenants WHERE id = %s AND is_active = true",
(tenant_id,),
)
row = cur.fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
internal_tenant_id = row[0]
cur.execute(
"""
SELECT id, site_id, site_name, site_code, site_label, is_active
FROM biz.sites
WHERE tenant_id = %s AND is_active = true
ORDER BY site_id
""",
(internal_tenant_id,),
)
rows = cur.fetchall()
finally:
conn.close()
return [
SiteItem(
id=r[0],
site_id=r[1],
site_name=r[2],
site_code=r[3],
site_label=r[4],
is_active=r[5],
)
for r in rows
]
# ── PUT /api/admin/sites/{site_id}/site-code ──────────────
@router.put("/sites/{site_id}/site-code")
async def update_site_code(
site_id: int,
body: UpdateSiteCodeRequest,
user: CurrentUser = Depends(_require_admin()),
) -> SiteCodeResult:
"""
设置/修改店铺简写ID事务内执行历史记录管理。
校验规则:
- 格式6 位,前 3 位字母/数字 + 后 3 位数字,统一大写
- 全局唯一biz.sites.site_code + biz.site_code_history.site_code
事务步骤:
a. 旧 code 在 site_code_history 中标记 is_current=false, retired_at=NOW()
b. 新 code 插入 site_code_historyis_current=true
c. 更新 biz.sites.site_code
d. 检查旧 code 是否有未审核申请引用,无引用则从 history 中删除旧记录
需求 A2.4, A3.1, A3.2, A3.3, A3.4
"""
new_code = body.new_code.strip().upper()
# ── 格式校验 ──
if not _SITE_CODE_PATTERN.match(new_code):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="简写ID 格式错误,需 6 位3+3 模式:前 3 位字母/数字 + 后 3 位数字)",
)
conn = get_connection()
try:
with conn.cursor() as cur:
# ── 校验店铺存在 ──
cur.execute(
"SELECT site_id, site_code FROM biz.sites WHERE id = %s",
(site_id,),
)
site_row = cur.fetchone()
if site_row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="店铺不存在",
)
db_site_id = site_row[0] # biz.sites.site_id上游 ID
old_code = site_row[1]
# ── 全局唯一性校验biz.sites + biz.site_code_history ──
cur.execute(
"""
SELECT 1 FROM biz.sites
WHERE site_code = %s AND id != %s
LIMIT 1
""",
(new_code, site_id),
)
if cur.fetchone():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"简写ID '{new_code}' 已被使用",
)
cur.execute(
"""
SELECT 1 FROM biz.site_code_history
WHERE site_code = %s AND site_id != %s
LIMIT 1
""",
(new_code, db_site_id),
)
if cur.fetchone():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"简写ID '{new_code}' 已被使用",
)
# ── 事务内执行变更 ──
# a. 旧 code 标记 retired
history_cleaned = False
if old_code:
cur.execute(
"""
UPDATE biz.site_code_history
SET is_current = false, retired_at = NOW()
WHERE site_id = %s AND site_code = %s AND is_current = true
""",
(db_site_id, old_code),
)
# b. 新 code 插入 history
cur.execute(
"""
INSERT INTO biz.site_code_history (site_id, site_code, is_current)
VALUES (%s, %s, true)
""",
(db_site_id, new_code),
)
# c. 更新 biz.sites.site_code
cur.execute(
"""
UPDATE biz.sites SET site_code = %s, updated_at = NOW()
WHERE id = %s
""",
(new_code, site_id),
)
# d. 检查旧 code 是否有未审核申请引用,无引用则清理历史
if old_code:
cur.execute(
"""
SELECT 1 FROM auth.user_applications
WHERE site_code = %s AND status = 'pending'
LIMIT 1
""",
(old_code,),
)
has_pending = cur.fetchone() is not None
if not has_pending:
cur.execute(
"""
DELETE FROM biz.site_code_history
WHERE site_id = %s AND site_code = %s AND is_current = false
""",
(db_site_id, old_code),
)
history_cleaned = True
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
return SiteCodeResult(
site_id=db_site_id,
old_code=old_code,
new_code=new_code,
history_cleaned=history_cleaned,
)
# ── GET /api/admin/sites/{site_id}/site-code-history ──────
@router.get("/sites/{site_id}/site-code-history")
async def get_site_code_history(
site_id: int,
user: CurrentUser = Depends(_require_admin()),
) -> list[SiteCodeHistoryItem]:
"""
查看简写ID 变更历史。
需求 A2.5
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 校验店铺存在,获取上游 site_id
cur.execute(
"SELECT site_id FROM biz.sites WHERE id = %s",
(site_id,),
)
site_row = cur.fetchone()
if site_row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="店铺不存在",
)
db_site_id = site_row[0]
cur.execute(
"""
SELECT id, site_code, is_current, created_at, retired_at
FROM biz.site_code_history
WHERE site_id = %s
ORDER BY created_at DESC
""",
(db_site_id,),
)
rows = cur.fetchall()
finally:
conn.close()
return [
SiteCodeHistoryItem(
id=r[0],
site_code=r[1],
is_current=r[2],
created_at=r[3],
retired_at=r[4],
)
for r in rows
]
# ── POST /api/admin/sites/sync ────────────────────────────
@router.post("/sites/sync")
async def sync_sites(
user: CurrentUser = Depends(_require_admin()),
) -> SiteSyncResult:
"""
手动触发店铺同步:从 ETL 库 dwd.dim_site 同步到 biz.sites。
返回同步结果(新增数/更新数)。
需求 A5.3
"""
return sync_sites_from_etl()
# ── POST /api/admin/sites/sync/internal ───────────────────
@router.post("/sites/sync/internal", include_in_schema=False)
async def sync_sites_internal() -> SiteSyncResult:
"""内部 APIETL DWD 完成后触发店铺同步。
不需要 JWT 认证(内部调用),通过 include_in_schema=False 隐藏。
后续可添加 API key 或 IP 白名单认证。
需求 A5.4
"""
return sync_sites_from_etl()
# ── POST /api/admin/sites测试功能手动创建店铺 ────────
@router.post("/sites", status_code=status.HTTP_201_CREATED)
async def create_site(
body: CreateSiteRequest,
user: CurrentUser = Depends(_require_admin()),
):
"""
手动创建店铺(测试功能)。
向 biz.sites 插入一条记录,可选指定 site_code。
site_id 和 site_code 需全局唯一,冲突返回 409。
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 校验 tenant_id 存在
cur.execute(
"SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true",
(body.tenant_id,),
)
tenant_row = cur.fetchone()
if tenant_row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在",
)
internal_tenant_id = tenant_row[0]
# site_code 格式校验(如果提供)
site_code = None
if body.site_code:
site_code = body.site_code.strip().upper()
if not _SITE_CODE_PATTERN.match(site_code):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="简写ID 格式错误,需 6 位3+3 格式)",
)
cur.execute(
"""
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_code)
VALUES (%s, %s, %s, %s)
RETURNING id, site_id, site_name, site_code
""",
(internal_tenant_id, body.site_id, body.site_name, site_code),
)
row = cur.fetchone()
# 如果有 site_code同步插入 history
if site_code:
cur.execute(
"""
INSERT INTO biz.site_code_history (site_id, site_code, is_current)
VALUES (%s, %s, true)
""",
(body.site_id, site_code),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except psycopg2.errors.UniqueViolation as e:
conn.rollback()
detail = str(e)
if "site_id" in detail:
raise HTTPException(status_code=409, detail="site_id 已存在")
if "site_code" in detail:
raise HTTPException(status_code=409, detail="简写ID 已被占用")
raise HTTPException(status_code=409, detail="唯一约束冲突")
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {"id": row[0], "siteId": row[1], "siteName": row[2], "siteCode": row[3]}
# ── DELETE /api/admin/sites/{site_id}(测试功能:删除店铺) ─
@router.delete("/sites/{site_id}")
async def delete_site(
site_id: int,
user: CurrentUser = Depends(_require_admin()),
):
"""
删除店铺(测试功能,硬删除)。
同时清理 site_code_history 中的关联记录。
site_id 参数为 biz.sites.id内部主键
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 获取上游 site_id 用于清理 history
cur.execute(
"SELECT site_id FROM biz.sites WHERE id = %s",
(site_id,),
)
row = cur.fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="店铺不存在",
)
upstream_site_id = row[0]
# 清理 site_code_history
cur.execute(
"DELETE FROM biz.site_code_history WHERE site_id = %s",
(upstream_site_id,),
)
# 删除店铺
cur.execute("DELETE FROM biz.sites WHERE id = %s", (site_id,))
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {"id": site_id}