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,673 @@
# -*- 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}