# -*- 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 — 内部 API:ETL 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_site(scd2_is_current=1)获取当前有效店铺 2. 对比 biz.sites: - 新 site_id → INSERT(site_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 参数支持两种格式: - 上游系统租户 ID(BIGINT,如 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_history(is_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: """内部 API:ETL 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}