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:
294
apps/backend/app/routers/admin_ai.py
Normal file
294
apps/backend/app/routers/admin_ai.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端 — AI 监控后台路由。
|
||||
|
||||
端点清单(13 个,全部需要 JWT + admin 角色):
|
||||
- GET /api/admin/ai/dashboard — 总览统计
|
||||
- GET /api/admin/ai/trigger-jobs — 调度任务分页列表
|
||||
- GET /api/admin/ai/trigger-jobs/{job_id} — 调度任务详情
|
||||
- POST /api/admin/ai/trigger-jobs/{job_id}/retry — 手动重跑
|
||||
- GET /api/admin/ai/run-logs — 调用记录分页列表
|
||||
- GET /api/admin/ai/run-logs/{log_id} — 调用记录详情
|
||||
- POST /api/admin/ai/cache/invalidate — 缓存失效
|
||||
- GET /api/admin/ai/budget — Token 预算
|
||||
- POST /api/admin/ai/batch-run — 创建批量执行(返回预估)
|
||||
- POST /api/admin/ai/batch-run/confirm — 确认批量执行
|
||||
- GET /api/admin/ai/alerts — 告警列表
|
||||
- POST /api/admin/ai/alerts/{log_id}/ack — 确认告警
|
||||
- POST /api/admin/ai/alerts/{log_id}/ignore — 忽略告警
|
||||
|
||||
需求: A1.1, A2.1, A2.4, A3.1, A4.1, A4.3, A5.1, A6.1, A7.1, A7.3, A8.1, A8.2, A8.3, A9.1, A9.2, A9.3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.admin_ai import (
|
||||
AlertActionResponse,
|
||||
AlertListResponse,
|
||||
BatchRunConfirm,
|
||||
BatchRunConfirmResponse,
|
||||
BatchRunEstimate,
|
||||
BatchRunRequest,
|
||||
BudgetResponse,
|
||||
CacheInvalidateRequest,
|
||||
CacheInvalidateResponse,
|
||||
DashboardResponse,
|
||||
RetryResponse,
|
||||
RunLogDetailResponse,
|
||||
RunLogListResponse,
|
||||
TriggerJobDetailResponse,
|
||||
TriggerJobListResponse,
|
||||
)
|
||||
from app.services.ai.admin_service import AdminAIService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/ai", tags=["admin-ai"])
|
||||
|
||||
|
||||
# ── 模块级服务实例 ────────────────────────────────────────
|
||||
_admin_svc = AdminAIService()
|
||||
|
||||
|
||||
# ── 权限依赖 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""
|
||||
管理端依赖:要求 JWT status=approved 且角色包含 site_admin 或 tenant_admin。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(require_permission()),
|
||||
) -> 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
|
||||
|
||||
|
||||
# ── Dashboard ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
site_id: Optional[int] = Query(None, description="门店 ID 筛选"),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> DashboardResponse:
|
||||
"""总览统计(支持 site_id 筛选)。"""
|
||||
data = await _admin_svc.get_dashboard(site_id=site_id)
|
||||
return DashboardResponse(**data)
|
||||
|
||||
|
||||
# ── 调度任务 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/trigger-jobs", response_model=TriggerJobListResponse)
|
||||
async def list_trigger_jobs(
|
||||
event_type: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
site_id: Optional[int] = Query(None),
|
||||
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> TriggerJobListResponse:
|
||||
"""调度任务分页列表。"""
|
||||
filters: dict = {}
|
||||
if event_type is not None:
|
||||
filters["event_type"] = event_type
|
||||
if status_filter is not None:
|
||||
filters["status"] = status_filter
|
||||
if site_id is not None:
|
||||
filters["site_id"] = site_id
|
||||
if date_from is not None:
|
||||
filters["date_from"] = date_from
|
||||
if date_to is not None:
|
||||
filters["date_to"] = date_to
|
||||
|
||||
data = await _admin_svc.list_trigger_jobs(filters, page=page, page_size=page_size)
|
||||
return TriggerJobListResponse(**data)
|
||||
|
||||
|
||||
@router.get("/trigger-jobs/{job_id}", response_model=TriggerJobDetailResponse)
|
||||
async def get_trigger_job(
|
||||
job_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> TriggerJobDetailResponse:
|
||||
"""调度任务详情。"""
|
||||
data = await _admin_svc.get_trigger_job(job_id)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="调度任务不存在")
|
||||
return TriggerJobDetailResponse(**data)
|
||||
|
||||
|
||||
@router.post("/trigger-jobs/{job_id}/retry", response_model=RetryResponse)
|
||||
async def retry_trigger_job(
|
||||
job_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> RetryResponse:
|
||||
"""手动重跑:创建新 trigger_job(is_forced=true),异步执行。"""
|
||||
try:
|
||||
new_job_id = await _admin_svc.retry_trigger_job(job_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return RetryResponse(trigger_job_id=new_job_id, status="pending")
|
||||
|
||||
|
||||
# ── 调用记录 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/run-logs", response_model=RunLogListResponse)
|
||||
async def list_run_logs(
|
||||
app_type: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
trigger_type: Optional[str] = Query(None),
|
||||
site_id: Optional[int] = Query(None),
|
||||
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> RunLogListResponse:
|
||||
"""调用记录分页列表。"""
|
||||
filters: dict = {}
|
||||
if app_type is not None:
|
||||
filters["app_type"] = app_type
|
||||
if status_filter is not None:
|
||||
filters["status"] = status_filter
|
||||
if trigger_type is not None:
|
||||
filters["trigger_type"] = trigger_type
|
||||
if site_id is not None:
|
||||
filters["site_id"] = site_id
|
||||
if date_from is not None:
|
||||
filters["date_from"] = date_from
|
||||
if date_to is not None:
|
||||
filters["date_to"] = date_to
|
||||
|
||||
data = await _admin_svc.list_run_logs(filters, page=page, page_size=page_size)
|
||||
return RunLogListResponse(**data)
|
||||
|
||||
|
||||
@router.get("/run-logs/{log_id}", response_model=RunLogDetailResponse)
|
||||
async def get_run_log(
|
||||
log_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> RunLogDetailResponse:
|
||||
"""调用记录详情(含完整 prompt/response/error,不脱敏)。"""
|
||||
data = await _admin_svc.get_run_log(log_id)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="调用记录不存在")
|
||||
return RunLogDetailResponse(**data)
|
||||
|
||||
|
||||
# ── 缓存管理 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/cache/invalidate", response_model=CacheInvalidateResponse)
|
||||
async def invalidate_cache(
|
||||
body: CacheInvalidateRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> CacheInvalidateResponse:
|
||||
"""批量缓存失效:将匹配条件的 ai_cache.status 设为 invalidated。"""
|
||||
affected = await _admin_svc.invalidate_cache(
|
||||
site_id=body.site_id,
|
||||
app_type=body.app_type,
|
||||
member_id=body.member_id,
|
||||
)
|
||||
return CacheInvalidateResponse(affected_count=affected)
|
||||
|
||||
|
||||
# ── Token 预算 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/budget", response_model=BudgetResponse)
|
||||
async def get_budget(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> BudgetResponse:
|
||||
"""Token 预算使用情况:日/月已用量、上限、百分比。"""
|
||||
data = await _admin_svc.get_budget()
|
||||
return BudgetResponse(**data)
|
||||
|
||||
|
||||
# ── 批量执行 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/batch-run", response_model=BatchRunEstimate)
|
||||
async def create_batch_run(
|
||||
body: BatchRunRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> BatchRunEstimate:
|
||||
"""创建批量执行请求,返回预估(不立即执行)。"""
|
||||
data = await _admin_svc.estimate_batch(
|
||||
app_types=body.app_types,
|
||||
member_ids=body.member_ids,
|
||||
site_id=body.site_id,
|
||||
)
|
||||
return BatchRunEstimate(**data)
|
||||
|
||||
|
||||
@router.post("/batch-run/confirm", response_model=BatchRunConfirmResponse)
|
||||
async def confirm_batch_run(
|
||||
body: BatchRunConfirm,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> BatchRunConfirmResponse:
|
||||
"""确认批量执行,后台异步执行。"""
|
||||
try:
|
||||
await _admin_svc.confirm_batch(batch_id=body.batch_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return BatchRunConfirmResponse(status="started")
|
||||
|
||||
|
||||
# ── 告警管理 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/alerts", response_model=AlertListResponse)
|
||||
async def list_alerts(
|
||||
alert_status: Optional[str] = Query(None, description="pending / acknowledged / ignored"),
|
||||
site_id: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> AlertListResponse:
|
||||
"""告警列表(ai_run_logs WHERE status IN ('failed','timeout','circuit_open'))。"""
|
||||
data = await _admin_svc.list_alerts(
|
||||
alert_status=alert_status,
|
||||
site_id=site_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return AlertListResponse(**data)
|
||||
|
||||
|
||||
@router.post("/alerts/{log_id}/ack", response_model=AlertActionResponse)
|
||||
async def ack_alert(
|
||||
log_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> AlertActionResponse:
|
||||
"""确认告警:alert_status → acknowledged。"""
|
||||
new_status = await _admin_svc.ack_alert(log_id)
|
||||
return AlertActionResponse(id=log_id, alert_status=new_status)
|
||||
|
||||
|
||||
@router.post("/alerts/{log_id}/ignore", response_model=AlertActionResponse)
|
||||
async def ignore_alert(
|
||||
log_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> AlertActionResponse:
|
||||
"""忽略告警:alert_status → ignored。"""
|
||||
new_status = await _admin_svc.ignore_alert(log_id)
|
||||
return AlertActionResponse(id=log_id, alert_status=new_status)
|
||||
164
apps/backend/app/routers/admin_db_health.py
Normal file
164
apps/backend/app/routers/admin_db_health.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理端 — 数据库健康监控 API
|
||||
|
||||
提供 1 个端点:
|
||||
- GET /api/admin/db-health — 返回 4 个数据库的健康状态
|
||||
|
||||
遍历 etl_feiqiu / test_etl_feiqiu / zqyy_app / test_zqyy_app,
|
||||
对每个库执行诊断 SQL(连接池、大小、慢查询)。
|
||||
连接失败时返回 disconnected 状态,不抛出 HTTP 错误。
|
||||
|
||||
需求: 6.1, 6.2, 6.3, 6.4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import psycopg2
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.config import (
|
||||
DB_HOST,
|
||||
DB_PASSWORD,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
ETL_DB_HOST,
|
||||
ETL_DB_PASSWORD,
|
||||
ETL_DB_PORT,
|
||||
ETL_DB_USER,
|
||||
)
|
||||
from app.schemas.admin_db_health import DbHealthItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/db-health", tags=["系统管理"])
|
||||
|
||||
# 4 个数据库的连接参数:业务库正式/测试 + ETL 库正式/测试
|
||||
DB_CONFIGS: list[dict] = [
|
||||
{
|
||||
"db_name": "zqyy_app",
|
||||
"host": DB_HOST,
|
||||
"port": DB_PORT,
|
||||
"user": DB_USER,
|
||||
"password": DB_PASSWORD,
|
||||
"dbname": "zqyy_app",
|
||||
},
|
||||
{
|
||||
"db_name": "test_zqyy_app",
|
||||
"host": DB_HOST,
|
||||
"port": DB_PORT,
|
||||
"user": DB_USER,
|
||||
"password": DB_PASSWORD,
|
||||
"dbname": "test_zqyy_app",
|
||||
},
|
||||
{
|
||||
"db_name": "etl_feiqiu",
|
||||
"host": ETL_DB_HOST,
|
||||
"port": ETL_DB_PORT,
|
||||
"user": ETL_DB_USER,
|
||||
"password": ETL_DB_PASSWORD,
|
||||
"dbname": "etl_feiqiu",
|
||||
},
|
||||
{
|
||||
"db_name": "test_etl_feiqiu",
|
||||
"host": ETL_DB_HOST,
|
||||
"port": ETL_DB_PORT,
|
||||
"user": ETL_DB_USER,
|
||||
"password": ETL_DB_PASSWORD,
|
||||
"dbname": "test_etl_feiqiu",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# 诊断 SQL:连接池状态
|
||||
_SQL_CONNECTIONS = """
|
||||
SELECT
|
||||
count(*) FILTER (WHERE state = 'active') AS active_connections,
|
||||
count(*) FILTER (WHERE state = 'idle') AS idle_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database();
|
||||
"""
|
||||
|
||||
# 诊断 SQL:数据库大小(MB)
|
||||
_SQL_DB_SIZE = """
|
||||
SELECT pg_database_size(current_database()) / (1024.0 * 1024.0) AS db_size_mb;
|
||||
"""
|
||||
|
||||
# 诊断 SQL:慢查询(最近 1 小时内执行时间超过 1 秒)
|
||||
_SQL_SLOW_QUERIES = """
|
||||
SELECT count(*) AS slow_query_count
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND state = 'active'
|
||||
AND query_start < now() - interval '1 second'
|
||||
AND query_start > now() - interval '1 hour';
|
||||
"""
|
||||
|
||||
|
||||
def _check_single_db(cfg: dict) -> DbHealthItem:
|
||||
"""对单个数据库执行诊断,连接失败时返回 disconnected。"""
|
||||
db_name = cfg["db_name"]
|
||||
try:
|
||||
# CHANGE 2026-03-29 | Windows GBK 环境下 psycopg2/libpq 构建连接字符串时
|
||||
# 会读取系统用户名/计算机名,含中文时触发 UnicodeDecodeError(0xd6 是 GBK 首字节)。
|
||||
# 用显式 DSN 字符串连接,避免 libpq 自动拼接时混入系统 locale 信息。
|
||||
dsn = (
|
||||
f"host={cfg['host']} port={cfg['port']} "
|
||||
f"dbname={cfg['dbname']} user={cfg['user']} "
|
||||
f"password={cfg['password']} "
|
||||
f"connect_timeout=5 client_encoding=UTF8 "
|
||||
f"application_name=neozqyy_health"
|
||||
)
|
||||
os.environ.setdefault("PGCLIENTENCODING", "UTF8")
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception:
|
||||
logger.warning("数据库 %s 连接失败", db_name, exc_info=True)
|
||||
return DbHealthItem(db_name=db_name, status="disconnected")
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 连接池状态
|
||||
cur.execute(_SQL_CONNECTIONS)
|
||||
row = cur.fetchone()
|
||||
active_connections = row[0] if row else 0
|
||||
idle_connections = row[1] if row else 0
|
||||
|
||||
# 数据库大小
|
||||
cur.execute(_SQL_DB_SIZE)
|
||||
row = cur.fetchone()
|
||||
db_size_mb = round(float(row[0]), 2) if row else 0.0
|
||||
|
||||
# 慢查询
|
||||
cur.execute(_SQL_SLOW_QUERIES)
|
||||
row = cur.fetchone()
|
||||
slow_query_count = row[0] if row else 0
|
||||
|
||||
return DbHealthItem(
|
||||
db_name=db_name,
|
||||
status="connected",
|
||||
active_connections=active_connections,
|
||||
idle_connections=idle_connections,
|
||||
db_size_mb=db_size_mb,
|
||||
slow_query_count=slow_query_count,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("数据库 %s 诊断 SQL 执行失败", db_name, exc_info=True)
|
||||
return DbHealthItem(db_name=db_name, status="disconnected")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DbHealthItem])
|
||||
async def get_db_health(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[DbHealthItem]:
|
||||
"""返回 4 个数据库的健康状态。
|
||||
|
||||
遍历 DB_CONFIGS 中的 4 个库,逐一执行诊断 SQL。
|
||||
连接失败时返回 disconnected 状态,不抛出 HTTP 错误。
|
||||
即使所有库都连接失败,仍返回 HTTP 200。
|
||||
"""
|
||||
return [_check_single_db(cfg) for cfg in DB_CONFIGS]
|
||||
374
apps/backend/app/routers/admin_dev_trace.py
Normal file
374
apps/backend/app/routers/admin_dev_trace.py
Normal file
@@ -0,0 +1,374 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端 — 开发调试全链路日志路由。
|
||||
|
||||
端点清单(8 个,全部需要 JWT + admin 角色):
|
||||
- GET /api/admin/dev-trace/dates — 有日志数据的日期列表
|
||||
- GET /api/admin/dev-trace/requests — 按条件分页查询请求列表
|
||||
- GET /api/admin/dev-trace/request/{id} — 指定 request_id 的完整 trace
|
||||
- POST /api/admin/dev-trace/cleanup — 按日期范围手动清理日志
|
||||
- GET /api/admin/dev-trace/settings — 当前设置
|
||||
- PUT /api/admin/dev-trace/settings — 更新运行时设置
|
||||
- GET /api/admin/dev-trace/coverage — 最近一次覆盖率扫描结果
|
||||
- POST /api/admin/dev-trace/coverage/scan — 手动触发覆盖率扫描
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.trace.cleanup import cleanup_date_range
|
||||
from app.trace.config import get_trace_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/dev-trace", tags=["开发调试日志"])
|
||||
|
||||
# 日期目录名格式
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
# ── 权限依赖 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""管理端依赖:仅要求 JWT 认证通过。
|
||||
|
||||
dev-trace 是开发调试工具,不涉及业务数据,无需检查业务角色
|
||||
(site_admin / tenant_admin)。只要是 admin-web 的已认证用户即可访问。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── Pydantic 请求/响应模型 ────────────────────────────────
|
||||
|
||||
|
||||
class CleanupRequest(BaseModel):
|
||||
"""手动清理请求体。"""
|
||||
start_date: str
|
||||
end_date: str
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""运行时设置更新请求体(所有字段可选)。"""
|
||||
enabled: Optional[bool] = None
|
||||
retention_days: Optional[int] = None
|
||||
log_sql: Optional[bool] = None
|
||||
log_params: Optional[bool] = None
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_base_dir() -> Path:
|
||||
"""获取日志根目录 Path 对象。"""
|
||||
return Path(get_trace_config().log_dir)
|
||||
|
||||
|
||||
def _is_date_dir(name: str) -> bool:
|
||||
"""判断目录名是否为 YYYY-MM-DD 格式。"""
|
||||
try:
|
||||
datetime.strptime(name, _DATE_FORMAT)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _read_jsonl_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
"""逐行读取 .jsonl 文件,跳过解析失败的行。"""
|
||||
records: list[dict[str, Any]] = []
|
||||
if not filepath.exists():
|
||||
return records
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def _match_filter(record: dict[str, Any], **filters: Any) -> bool:
|
||||
"""检查单条 trace 记录是否满足所有筛选条件。"""
|
||||
# trace_type
|
||||
if filters.get("trace_type") and record.get("trace_type") != filters["trace_type"]:
|
||||
return False
|
||||
|
||||
# method
|
||||
if filters.get("method") and record.get("method", "").upper() != filters["method"].upper():
|
||||
return False
|
||||
|
||||
# path_contains
|
||||
if filters.get("path_contains") and filters["path_contains"].lower() not in (record.get("path") or "").lower():
|
||||
return False
|
||||
|
||||
# status_code
|
||||
if filters.get("status_code") is not None and record.get("status_code") != filters["status_code"]:
|
||||
return False
|
||||
|
||||
# min_duration
|
||||
if filters.get("min_duration") is not None and (record.get("total_duration_ms") or 0) < filters["min_duration"]:
|
||||
return False
|
||||
|
||||
# has_error
|
||||
if filters.get("has_error") is not None:
|
||||
has_err = record.get("error") is not None
|
||||
if filters["has_error"] != has_err:
|
||||
return False
|
||||
|
||||
# span_type — 检查 spans 列表中是否包含指定类型
|
||||
if filters.get("span_type"):
|
||||
span_types = {s.get("span_type") for s in record.get("spans", [])}
|
||||
if filters["span_type"] not in span_types:
|
||||
return False
|
||||
|
||||
# start_time / end_time — 基于 record.timestamp
|
||||
ts_str = record.get("timestamp", "")
|
||||
if ts_str and (filters.get("start_time") or filters.get("end_time")):
|
||||
try:
|
||||
rec_dt = datetime.fromisoformat(ts_str)
|
||||
rec_time = rec_dt.time()
|
||||
if filters.get("start_time") and rec_time < filters["start_time"]:
|
||||
return False
|
||||
if filters.get("end_time") and rec_time > filters["end_time"]:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── 1. GET /dates — 有日志数据的日期列表 ─────────────────
|
||||
|
||||
|
||||
@router.get("/dates")
|
||||
async def list_dates(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, list[str]]:
|
||||
"""返回有日志数据的日期列表(降序排列)。"""
|
||||
base = _get_base_dir()
|
||||
if not base.exists():
|
||||
return {"dates": []}
|
||||
|
||||
dates = sorted(
|
||||
[d.name for d in base.iterdir() if d.is_dir() and _is_date_dir(d.name)],
|
||||
reverse=True,
|
||||
)
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
# ── 2. GET /requests — 按条件分页查询请求列表 ─────────────
|
||||
|
||||
|
||||
@router.get("/requests")
|
||||
async def list_requests(
|
||||
date: str = Query(..., description="日期,格式 YYYY-MM-DD"),
|
||||
start_time: Optional[str] = Query(None, description="起始时间 HH:MM:SS"),
|
||||
end_time: Optional[str] = Query(None, description="结束时间 HH:MM:SS"),
|
||||
trace_type: Optional[str] = Query(None, description="trace 类型:http/sse/ws/job"),
|
||||
method: Optional[str] = Query(None, description="HTTP 方法:GET/POST/PUT/DELETE"),
|
||||
path_contains: Optional[str] = Query(None, description="路径关键词"),
|
||||
status_code: Optional[int] = Query(None, description="HTTP 状态码"),
|
||||
min_duration: Optional[float] = Query(None, description="最小耗时(ms)"),
|
||||
has_error: Optional[bool] = Query(None, description="是否有错误"),
|
||||
span_type: Optional[str] = Query(None, description="包含的 span 类型"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""按条件分页查询指定日期的请求列表。"""
|
||||
# 解析时间参数
|
||||
parsed_start = None
|
||||
parsed_end = None
|
||||
if start_time:
|
||||
try:
|
||||
parsed_start = datetime.strptime(start_time, "%H:%M:%S").time()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="start_time 格式无效,应为 HH:MM:SS")
|
||||
if end_time:
|
||||
try:
|
||||
parsed_end = datetime.strptime(end_time, "%H:%M:%S").time()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="end_time 格式无效,应为 HH:MM:SS")
|
||||
|
||||
# 读取指定日期目录下所有 .jsonl 文件
|
||||
date_dir = _get_base_dir() / date
|
||||
if not date_dir.exists() or not date_dir.is_dir():
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
all_records: list[dict[str, Any]] = []
|
||||
for f in sorted(date_dir.glob("*.jsonl")):
|
||||
all_records.extend(_read_jsonl_file(f))
|
||||
|
||||
# 过滤
|
||||
filtered = [
|
||||
r for r in all_records
|
||||
if _match_filter(
|
||||
r,
|
||||
trace_type=trace_type,
|
||||
method=method,
|
||||
path_contains=path_contains,
|
||||
status_code=status_code,
|
||||
min_duration=min_duration,
|
||||
has_error=has_error,
|
||||
span_type=span_type,
|
||||
start_time=parsed_start,
|
||||
end_time=parsed_end,
|
||||
)
|
||||
]
|
||||
|
||||
# 按时间降序排列
|
||||
filtered.sort(key=lambda r: r.get("timestamp", ""), reverse=True)
|
||||
|
||||
total = len(filtered)
|
||||
start_idx = (page - 1) * page_size
|
||||
items = filtered[start_idx : start_idx + page_size]
|
||||
|
||||
# 列表项不返回完整 spans,只返回摘要字段
|
||||
summary_items = []
|
||||
for item in items:
|
||||
summary_items.append({
|
||||
"request_id": item.get("request_id"),
|
||||
"trace_type": item.get("trace_type"),
|
||||
"timestamp": item.get("timestamp"),
|
||||
"method": item.get("method"),
|
||||
"path": item.get("path"),
|
||||
"status_code": item.get("status_code"),
|
||||
"total_duration_ms": item.get("total_duration_ms"),
|
||||
"user_id": item.get("user_id"),
|
||||
"site_id": item.get("site_id"),
|
||||
"db_query_count": item.get("db_query_count"),
|
||||
"db_total_ms": item.get("db_total_ms"),
|
||||
"error": item.get("error"),
|
||||
"span_count": len(item.get("spans", [])),
|
||||
})
|
||||
|
||||
return {"items": summary_items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
# ── 3. GET /request/{request_id} — 完整 trace 记录 ──────
|
||||
|
||||
|
||||
@router.get("/request/{request_id}")
|
||||
async def get_request_detail(
|
||||
request_id: str,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""返回指定 request_id 的完整 trace 记录(含所有 spans)。"""
|
||||
base = _get_base_dir()
|
||||
if not base.exists():
|
||||
raise HTTPException(status_code=404, detail="未找到该 request_id 的 trace 记录")
|
||||
|
||||
# 搜索所有日期目录下的 .jsonl 文件
|
||||
for date_dir in sorted(base.iterdir(), reverse=True):
|
||||
if not date_dir.is_dir() or not _is_date_dir(date_dir.name):
|
||||
continue
|
||||
for f in date_dir.glob("*.jsonl"):
|
||||
for record in _read_jsonl_file(f):
|
||||
if record.get("request_id") == request_id:
|
||||
return record
|
||||
|
||||
raise HTTPException(status_code=404, detail="未找到该 request_id 的 trace 记录")
|
||||
|
||||
|
||||
# ── 4. POST /cleanup — 按日期范围手动清理 ────────────────
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_logs(
|
||||
body: CleanupRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""按日期范围手动清理日志目录。"""
|
||||
# 校验日期格式
|
||||
try:
|
||||
datetime.strptime(body.start_date, _DATE_FORMAT)
|
||||
datetime.strptime(body.end_date, _DATE_FORMAT)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式无效,应为 YYYY-MM-DD")
|
||||
|
||||
if body.start_date > body.end_date:
|
||||
raise HTTPException(status_code=400, detail="start_date 不能晚于 end_date")
|
||||
|
||||
result = cleanup_date_range(body.start_date, body.end_date)
|
||||
return {
|
||||
"deleted_dates": result["deleted_dirs"],
|
||||
"deleted_files": result["deleted_count"],
|
||||
}
|
||||
|
||||
|
||||
# ── 5. GET /settings — 当前设置 ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""返回当前 trace 运行时设置。"""
|
||||
return get_trace_config().get_settings()
|
||||
|
||||
|
||||
# ── 6. PUT /settings — 更新运行时设置 ────────────────────
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(
|
||||
body: SettingsUpdate,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""更新 trace 运行时设置(不需重启,重启后回退到 .env 值)。"""
|
||||
cfg = get_trace_config()
|
||||
cfg.update_settings(
|
||||
enabled=body.enabled,
|
||||
retention_days=body.retention_days,
|
||||
log_sql=body.log_sql,
|
||||
log_params=body.log_params,
|
||||
)
|
||||
return cfg.get_settings()
|
||||
|
||||
|
||||
# ── 7. GET /coverage — 最近一次覆盖率扫描结果 ────────────
|
||||
|
||||
|
||||
@router.get("/coverage")
|
||||
async def get_coverage(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""返回最近一次覆盖率扫描结果(缓存)。"""
|
||||
from app.trace.coverage import get_cached_coverage, run_coverage_scan
|
||||
|
||||
result = get_cached_coverage()
|
||||
if result is None:
|
||||
# 首次访问时自动扫描一次
|
||||
result = run_coverage_scan()
|
||||
return result
|
||||
|
||||
|
||||
# ── 8. POST /coverage/scan — 手动触发覆盖率扫描 ──────────
|
||||
|
||||
|
||||
@router.post("/coverage/scan")
|
||||
async def trigger_coverage_scan(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""手动触发覆盖率扫描,返回最新结果。"""
|
||||
from app.trace.coverage import run_coverage_scan
|
||||
|
||||
return run_coverage_scan()
|
||||
673
apps/backend/app/routers/admin_registry.py
Normal file
673
apps/backend/app/routers/admin_registry.py
Normal 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 — 内部 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}
|
||||
640
apps/backend/app/routers/admin_task_engine.py
Normal file
640
apps/backend/app/routers/admin_task_engine.py
Normal file
@@ -0,0 +1,640 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P18 任务引擎运营看板 API
|
||||
|
||||
提供转移日志查看、待审核任务管理、参数配置等端点。
|
||||
所有端点需要 JWT 认证;写操作仅限 super_admin。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_task_engine import (
|
||||
CandidateAssistant,
|
||||
CandidateListResponse,
|
||||
CloseRequest,
|
||||
CloseResponse,
|
||||
ConfigParam,
|
||||
ConfigParamCreate,
|
||||
ConfigParamList,
|
||||
ConfigParamResponse,
|
||||
ConfigParamUpdate,
|
||||
PendingReviewItem,
|
||||
PendingReviewPage,
|
||||
ReassignRequest,
|
||||
ReassignResponse,
|
||||
TransferLogItem,
|
||||
TransferLogPage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/task-engine", tags=["任务引擎管理"])
|
||||
|
||||
# ---- 任务类型中文映射 ----
|
||||
TASK_TYPE_LABELS = {
|
||||
"high_priority_recall": "高优先召回",
|
||||
"priority_recall": "优先召回",
|
||||
"follow_up_visit": "客户回访",
|
||||
"relationship_building": "关系构建",
|
||||
}
|
||||
|
||||
|
||||
# ---- 权限辅助函数 ----
|
||||
|
||||
def _require_super_admin(user: CurrentUser) -> None:
|
||||
"""写操作权限校验:仅超级管理员可执行。"""
|
||||
if "super_admin" not in user.roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅超级管理员可执行此操作",
|
||||
)
|
||||
|
||||
|
||||
def _filter_site_id(user: CurrentUser, query_site_id: int | None) -> int | None:
|
||||
"""读操作门店过滤:门店管理员强制按自身 site_id 过滤。"""
|
||||
if "super_admin" in user.roles:
|
||||
return query_site_id
|
||||
return user.site_id
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 1. 转移日志
|
||||
# =====================================================================
|
||||
|
||||
@router.get("/transfer-log", response_model=TransferLogPage)
|
||||
async def list_transfer_logs(
|
||||
site_id: int | None = Query(None, description="门店 ID"),
|
||||
from_date: date | None = Query(None, description="起始日期"),
|
||||
to_date: date | None = Query(None, description="截止日期"),
|
||||
assistant_id: int | None = Query(None, description="助教 ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> TransferLogPage:
|
||||
"""转移日志分页列表。"""
|
||||
effective_site_id = _filter_site_id(user, site_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions = []
|
||||
params: list = []
|
||||
|
||||
if effective_site_id is not None:
|
||||
conditions.append("t.site_id = %s")
|
||||
params.append(effective_site_id)
|
||||
if from_date is not None:
|
||||
conditions.append("t.created_at >= %s")
|
||||
params.append(from_date)
|
||||
if to_date is not None:
|
||||
conditions.append("t.created_at < %s::date + interval '1 day'")
|
||||
params.append(to_date)
|
||||
if assistant_id is not None:
|
||||
conditions.append("(t.from_assistant_id = %s OR t.to_assistant_id = %s)")
|
||||
params.extend([assistant_id, assistant_id])
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
# 总数
|
||||
cur.execute(
|
||||
f"SELECT count(*) AS cnt FROM biz.coach_task_transfer_log t WHERE {where_clause}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()["cnt"]
|
||||
|
||||
# 分页数据
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT t.*, s.site_name
|
||||
FROM biz.coach_task_transfer_log t
|
||||
LEFT JOIN biz.sites s ON s.site_id = t.site_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = [TransferLogItem(**row) for row in rows]
|
||||
return TransferLogPage(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询转移日志失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询转移日志失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/transfer-log/{member_id}/history", response_model=list[TransferLogItem])
|
||||
async def get_member_transfer_history(
|
||||
member_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[TransferLogItem]:
|
||||
"""某客户全部转移历史。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 门店管理员只能看自己门店的记录
|
||||
effective_site_id = _filter_site_id(user, None)
|
||||
if effective_site_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.*, s.site_name
|
||||
FROM biz.coach_task_transfer_log t
|
||||
LEFT JOIN biz.sites s ON s.site_id = t.site_id
|
||||
WHERE t.member_id = %s AND t.site_id = %s
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
[member_id, effective_site_id],
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.*, s.site_name
|
||||
FROM biz.coach_task_transfer_log t
|
||||
LEFT JOIN biz.sites s ON s.site_id = t.site_id
|
||||
WHERE t.member_id = %s
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
[member_id],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [TransferLogItem(**row) for row in rows]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询客户转移历史失败: member_id=%s", member_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询客户转移历史失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. 待审核任务
|
||||
# =====================================================================
|
||||
|
||||
@router.get("/pending-review", response_model=PendingReviewPage)
|
||||
async def list_pending_reviews(
|
||||
site_id: int | None = Query(None, description="门店 ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> PendingReviewPage:
|
||||
"""待审核任务列表。"""
|
||||
effective_site_id = _filter_site_id(user, site_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions = ["ct.status = 'pending_review'"]
|
||||
params: list = []
|
||||
|
||||
if effective_site_id is not None:
|
||||
conditions.append("ct.site_id = %s")
|
||||
params.append(effective_site_id)
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# 总数
|
||||
cur.execute(
|
||||
f"SELECT count(*) AS cnt FROM biz.coach_tasks ct WHERE {where_clause}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()["cnt"]
|
||||
|
||||
# 分页数据
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ct.*, s.site_name
|
||||
FROM biz.coach_tasks ct
|
||||
LEFT JOIN biz.sites s ON s.site_id = ct.site_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY ct.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
row["task_type_label"] = TASK_TYPE_LABELS.get(row.get("task_type", ""), "")
|
||||
items.append(PendingReviewItem(**row))
|
||||
|
||||
return PendingReviewPage(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询待审核任务失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询待审核任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/pending-review/{task_id}/reassign", response_model=ReassignResponse)
|
||||
async def reassign_task(
|
||||
task_id: int,
|
||||
body: ReassignRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ReassignResponse:
|
||||
"""重新分配待审核任务(仅超级管理员)。
|
||||
|
||||
逻辑:原任务 status → 'transferred',新建 active 任务,写 transfer_log。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 查询原任务
|
||||
cur.execute(
|
||||
"SELECT * FROM biz.coach_tasks WHERE id = %s FOR UPDATE",
|
||||
[task_id],
|
||||
)
|
||||
task = cur.fetchone()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task["status"] != "pending_review":
|
||||
raise HTTPException(status_code=400, detail="任务状态不是待审核,无法重新分配")
|
||||
|
||||
# 原任务标记为 transferred
|
||||
cur.execute(
|
||||
"UPDATE biz.coach_tasks SET status = 'transferred', updated_at = now() WHERE id = %s",
|
||||
[task_id],
|
||||
)
|
||||
|
||||
# 新建 active 任务
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, member_id, assistant_id, task_type, priority_score, status, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, 'active', now(), now())
|
||||
RETURNING id
|
||||
""",
|
||||
[task["site_id"], task["member_id"], body.to_assistant_id,
|
||||
task["task_type"], task.get("priority_score")],
|
||||
)
|
||||
new_task_id = cur.fetchone()["id"]
|
||||
|
||||
# 写转移日志
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_transfer_log
|
||||
(site_id, member_id, from_assistant_id, to_assistant_id,
|
||||
transfer_reason, transfer_score, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, now())
|
||||
""",
|
||||
[task["site_id"], task["member_id"], task["assistant_id"],
|
||||
body.to_assistant_id, "manual_reassign", task.get("priority_score")],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return ReassignResponse(success=True, new_task_id=new_task_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("重新分配任务失败: task_id=%s", task_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"重新分配任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/pending-review/{task_id}/close", response_model=CloseResponse)
|
||||
async def close_task(
|
||||
task_id: int,
|
||||
body: CloseRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CloseResponse:
|
||||
"""关闭待审核任务(仅超级管理员)。
|
||||
|
||||
逻辑:任务 status → 'inactive',记录 abandon_reason。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, status FROM biz.coach_tasks WHERE id = %s FOR UPDATE",
|
||||
[task_id],
|
||||
)
|
||||
task = cur.fetchone()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task["status"] != "pending_review":
|
||||
raise HTTPException(status_code=400, detail="任务状态不是待审核,无法关闭")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', abandon_reason = %s, updated_at = now()
|
||||
WHERE id = %s
|
||||
""",
|
||||
[body.reason, task_id],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return CloseResponse(success=True)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("关闭任务失败: task_id=%s", task_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"关闭任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. 参数管理
|
||||
# =====================================================================
|
||||
|
||||
# 权重参数 key 列表(联合校验用)
|
||||
_WEIGHT_KEYS = {"w_rs", "w_ms", "w_ml"}
|
||||
|
||||
|
||||
@router.get("/config", response_model=ConfigParamList)
|
||||
async def list_config_params(
|
||||
site_id: int | None = Query(None, description="门店 ID(不传则返回全部)"),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamList:
|
||||
"""参数列表。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if site_id is not None:
|
||||
# 返回指定门店覆盖 + 全局默认
|
||||
conditions.append("(p.site_id = %s OR p.site_id IS NULL)")
|
||||
params.append(site_id)
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT p.*, s.site_name
|
||||
FROM biz.cfg_task_generator_params p
|
||||
LEFT JOIN biz.sites s ON s.site_id = p.site_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY p.site_id NULLS FIRST, p.param_key
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return ConfigParamList(params=[ConfigParam(**row) for row in rows])
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询参数配置失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询参数配置失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.put("/config/{param_id}", response_model=ConfigParamResponse)
|
||||
async def update_config_param(
|
||||
param_id: int,
|
||||
body: ConfigParamUpdate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamResponse:
|
||||
"""更新参数值(仅超级管理员)。
|
||||
|
||||
权重参数(w_rs / w_ms / w_ml)更新后会校验三者之和是否为 1.0。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 查询当前参数
|
||||
cur.execute(
|
||||
"SELECT * FROM biz.cfg_task_generator_params WHERE id = %s FOR UPDATE",
|
||||
[param_id],
|
||||
)
|
||||
param = cur.fetchone()
|
||||
if param is None:
|
||||
raise HTTPException(status_code=404, detail="参数不存在")
|
||||
|
||||
# 更新
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.cfg_task_generator_params
|
||||
SET param_value = %s, updated_at = now()
|
||||
WHERE id = %s
|
||||
""",
|
||||
[body.param_value, param_id],
|
||||
)
|
||||
|
||||
# 权重参数联合校验:w_rs + w_ms + w_ml = 1.0
|
||||
if param["param_key"] in _WEIGHT_KEYS:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT param_key, param_value
|
||||
FROM biz.cfg_task_generator_params
|
||||
WHERE site_id IS NOT DISTINCT FROM %s
|
||||
AND param_key = ANY(%s)
|
||||
""",
|
||||
[param["site_id"], list(_WEIGHT_KEYS)],
|
||||
)
|
||||
weight_rows = cur.fetchall()
|
||||
weight_sum = sum(r["param_value"] for r in weight_rows)
|
||||
if abs(weight_sum - 1.0) > 0.001:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"权重参数之和必须为 1.0,当前为 {weight_sum:.4f}",
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return ConfigParamResponse(success=True, id=param_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("更新参数失败: param_id=%s", param_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新参数失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/config", response_model=ConfigParamResponse)
|
||||
async def create_config_param(
|
||||
body: ConfigParamCreate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamResponse:
|
||||
"""新增门店覆盖参数(仅超级管理员)。"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 检查是否已存在同 site_id + param_key 的记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM biz.cfg_task_generator_params
|
||||
WHERE site_id = %s AND param_key = %s
|
||||
""",
|
||||
[body.site_id, body.param_key],
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"门店 {body.site_id} 已存在参数 {body.param_key} 的覆盖配置",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.cfg_task_generator_params
|
||||
(site_id, param_key, param_value, updated_at)
|
||||
VALUES (%s, %s, %s, now())
|
||||
RETURNING id
|
||||
""",
|
||||
[body.site_id, body.param_key, body.param_value],
|
||||
)
|
||||
new_id = cur.fetchone()["id"]
|
||||
|
||||
conn.commit()
|
||||
return ConfigParamResponse(success=True, id=new_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("新增参数失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"新增参数失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/clear-all-tasks")
|
||||
async def clear_all_tasks(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""【测试用】清空所有 coach_tasks 及关联数据(仅超级管理员)。
|
||||
|
||||
用于开发/测试阶段重置任务数据,让 task_generator 重新生成。
|
||||
按外键依赖顺序删除:transfer_log → notes → history → tasks。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 按外键依赖顺序:先删引用表,再删主表
|
||||
cur.execute("DELETE FROM biz.coach_task_transfer_log")
|
||||
transfer_count = cur.rowcount
|
||||
cur.execute("DELETE FROM biz.notes WHERE task_id IS NOT NULL")
|
||||
notes_count = cur.rowcount
|
||||
cur.execute("DELETE FROM biz.coach_task_history")
|
||||
history_count = cur.rowcount
|
||||
# coach_tasks 有自引用 FK,先清 parent_task_id 和 transferred_from
|
||||
cur.execute("UPDATE biz.coach_tasks SET parent_task_id = NULL, transferred_from = NULL")
|
||||
cur.execute("DELETE FROM biz.coach_tasks")
|
||||
task_count = cur.rowcount
|
||||
conn.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已清空 {task_count} 条任务 + {history_count} 条历史 + {transfer_count} 条转移日志 + {notes_count} 条备注",
|
||||
"deleted_tasks": task_count,
|
||||
"deleted_history": history_count,
|
||||
"deleted_transfers": transfer_count,
|
||||
"deleted_notes": notes_count,
|
||||
}
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("清空任务失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"清空任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/config/{param_id}", response_model=ConfigParamResponse)
|
||||
async def delete_config_param(
|
||||
param_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamResponse:
|
||||
"""删除门店覆盖参数(仅超级管理员)。
|
||||
|
||||
不允许删除 site_id IS NULL 的全局默认参数。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, site_id FROM biz.cfg_task_generator_params WHERE id = %s",
|
||||
[param_id],
|
||||
)
|
||||
param = cur.fetchone()
|
||||
if param is None:
|
||||
raise HTTPException(status_code=404, detail="参数不存在")
|
||||
if param["site_id"] is None:
|
||||
raise HTTPException(status_code=400, detail="不允许删除全局默认参数")
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM biz.cfg_task_generator_params WHERE id = %s",
|
||||
[param_id],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return ConfigParamResponse(success=True, id=param_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("删除参数失败: param_id=%s", param_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除参数失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
405
apps/backend/app/routers/admin_tenant_admins.py
Normal file
405
apps/backend/app/routers/admin_tenant_admins.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端路由 — 租户管理员 CRUD。
|
||||
|
||||
端点清单:
|
||||
- GET /api/admin/tenant-admins — 管理员列表(分页 + 关键词搜索)
|
||||
- POST /api/admin/tenant-admins — 创建管理员
|
||||
- PATCH /api/admin/tenant-admins/{id} — 编辑管理员
|
||||
- DELETE /api/admin/tenant-admins/{id} — 软删除管理员
|
||||
- POST /api/admin/tenant-admins/{id}/reset-password — 重置密码
|
||||
|
||||
所有端点要求 JWT + site_admin 或 tenant_admin 角色。
|
||||
|
||||
需求: 14.1-14.7, A2.3, A2.6, A2.7, A2.8, A4.1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from psycopg2 import errors as pg_errors
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.auth.jwt import hash_password
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_tenant_admins import (
|
||||
ResetPasswordRequest,
|
||||
TenantAdminCreateRequest,
|
||||
TenantAdminEditRequest,
|
||||
TenantAdminListItem,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["管理端租户管理员"])
|
||||
|
||||
|
||||
# ── 管理端权限依赖:要求 site_admin 或 tenant_admin 角色 ──
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""
|
||||
管理端依赖:要求 JWT 中角色包含 site_admin 或 tenant_admin。
|
||||
|
||||
直接从 JWT 校验角色,不查 auth.users 表(管理员在 admin_users 表,
|
||||
不在 auth.users 中,require_permission 会报"用户不存在")。
|
||||
"""
|
||||
|
||||
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/tenant-admins ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/tenant-admins")
|
||||
async def list_tenant_admins(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索(用户名/显示名称)"),
|
||||
include_inactive: bool = Query(False, description="是否包含已禁用的管理员"),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
查询租户管理员列表,支持分页和关键词搜索。
|
||||
|
||||
默认只返回 is_active=true 的记录;include_inactive=true 时返回所有记录。
|
||||
JOIN biz.tenants 获取 tenant_name。
|
||||
|
||||
需求 14.1, A2.7, A2.6
|
||||
"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 构建查询
|
||||
where_clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 始终过滤已删除记录
|
||||
where_clauses.append("ta.deleted_at IS NULL")
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.7 | 默认过滤 is_active
|
||||
if not include_inactive:
|
||||
where_clauses.append("ta.is_active = true")
|
||||
|
||||
if keyword:
|
||||
where_clauses.append(
|
||||
"(ta.username ILIKE %s OR ta.display_name ILIKE %s)"
|
||||
)
|
||||
like_val = f"%{keyword}%"
|
||||
params.extend([like_val, like_val])
|
||||
|
||||
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
|
||||
# 查询总数
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM auth.tenant_admins ta
|
||||
{where_sql}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.6 | JOIN biz.tenants 获取 tenant_name
|
||||
# CHANGE 2026-03-23 | Prompt: 角色体系隔离 | 加入 admin_type 列
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ta.id, ta.username, ta.display_name, ta.tenant_id,
|
||||
ta.managed_site_ids,
|
||||
ta.is_active, ta.created_at, ta.last_login_at,
|
||||
bt.tenant_name, ta.admin_type
|
||||
FROM auth.tenant_admins ta
|
||||
LEFT JOIN biz.tenants bt ON bt.tenant_id = ta.tenant_id
|
||||
{where_sql}
|
||||
ORDER BY ta.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
TenantAdminListItem(
|
||||
id=r[0],
|
||||
username=r[1],
|
||||
display_name=r[2],
|
||||
tenant_id=r[3],
|
||||
managed_site_ids=list(r[4]) if r[4] else [],
|
||||
is_active=r[5],
|
||||
created_at=r[6].isoformat() if r[6] else None,
|
||||
last_login_at=r[7].isoformat() if r[7] else None,
|
||||
tenant_name=r[8],
|
||||
admin_type=r[9],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# 返回分页格式(由 ResponseWrapperMiddleware 包装为 {code:0, data:...})
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
# ── POST /api/admin/tenant-admins ─────────────────────────
|
||||
|
||||
|
||||
@router.post("/tenant-admins", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant_admin(
|
||||
body: TenantAdminCreateRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
创建租户管理员。
|
||||
|
||||
密码 bcrypt 哈希,username UNIQUE 冲突返回 409,记录 created_by。
|
||||
创建时校验 tenant_id 在 biz.tenants 中存在且 is_active=true。
|
||||
|
||||
需求 14.2, 14.3, A2.6
|
||||
"""
|
||||
password_hash = hash_password(body.password)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.6 | 校验 tenant_id 存在性
|
||||
cur.execute(
|
||||
"SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true",
|
||||
(body.tenant_id,),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户不存在",
|
||||
)
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | 存储时统一小写
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.tenant_admins
|
||||
(username, password_hash, display_name, tenant_id, managed_site_ids, created_by)
|
||||
VALUES (LOWER(%s), %s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
body.username,
|
||||
password_hash,
|
||||
body.display_name,
|
||||
body.tenant_id,
|
||||
body.managed_site_ids,
|
||||
user.user_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except pg_errors.UniqueViolation:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="用户名已存在",
|
||||
)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": row[0], "created_at": row[1].isoformat() if row[1] else None}
|
||||
|
||||
|
||||
# ── PATCH /api/admin/tenant-admins/{id} ───────────────────
|
||||
|
||||
|
||||
@router.patch("/tenant-admins/{admin_id}")
|
||||
async def edit_tenant_admin(
|
||||
admin_id: int,
|
||||
body: TenantAdminEditRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
编辑租户管理员信息(username / display_name / managed_site_ids / is_active)。
|
||||
|
||||
管理员 ID 不存在返回 404。
|
||||
修改 username 时校验全局唯一性(排除自身),冲突返回 409。
|
||||
|
||||
需求 14.4, 14.6, A2.8
|
||||
"""
|
||||
# 构建动态 SET 子句
|
||||
set_clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.8 | 支持修改 username
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | 存储时统一小写
|
||||
if body.username is not None:
|
||||
set_clauses.append("username = LOWER(%s)")
|
||||
params.append(body.username)
|
||||
if body.display_name is not None:
|
||||
set_clauses.append("display_name = %s")
|
||||
params.append(body.display_name)
|
||||
if body.managed_site_ids is not None:
|
||||
set_clauses.append("managed_site_ids = %s")
|
||||
params.append(body.managed_site_ids)
|
||||
if body.is_active is not None:
|
||||
set_clauses.append("is_active = %s")
|
||||
params.append(body.is_active)
|
||||
|
||||
if not set_clauses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="至少需要提供一个修改字段",
|
||||
)
|
||||
|
||||
params.append(admin_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.8 | username 唯一性校验(排除自身)
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 只在未删除记录中校验唯一性
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | LOWER() 比较 + 存储小写
|
||||
if body.username is not None:
|
||||
cur.execute(
|
||||
"SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER(%s) AND id != %s AND deleted_at IS NULL",
|
||||
(body.username, admin_id),
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="用户名已存在",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE auth.tenant_admins
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户管理员不存在",
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
|
||||
|
||||
# ── DELETE /api/admin/tenant-admins/{id} ──────────────────
|
||||
|
||||
|
||||
@router.delete("/tenant-admins/{admin_id}")
|
||||
async def delete_tenant_admin(
|
||||
admin_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
软删除租户管理员(设置 deleted_at=NOW())。
|
||||
|
||||
无论 is_active 状态如何,均可删除。
|
||||
管理员不存在或已删除返回 404;重复删除幂等返回 404。
|
||||
|
||||
需求 A2.3
|
||||
"""
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | deleted_at 软删除,不再检查 is_active
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE auth.tenant_admins SET deleted_at = NOW() "
|
||||
"WHERE id = %s AND deleted_at IS NULL "
|
||||
"RETURNING id",
|
||||
(admin_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="管理员不存在",
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
|
||||
|
||||
# ── POST /api/admin/tenant-admins/{id}/reset-password ─────
|
||||
|
||||
|
||||
@router.post("/tenant-admins/{admin_id}/reset-password")
|
||||
async def reset_password(
|
||||
admin_id: int,
|
||||
body: ResetPasswordRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
重置租户管理员密码。
|
||||
|
||||
新密码 bcrypt 哈希后更新 password_hash。管理员 ID 不存在返回 404。
|
||||
需求 14.5
|
||||
"""
|
||||
new_hash = hash_password(body.new_password)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.tenant_admins
|
||||
SET password_hash = %s
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(new_hash, admin_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户管理员不存在",
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
177
apps/backend/app/routers/admin_triggers.py
Normal file
177
apps/backend/app/routers/admin_triggers.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理端 — 触发器统一视图 API
|
||||
|
||||
提供 1 个端点:
|
||||
- GET /api/admin/triggers/unified — 聚合三张表的触发器数据
|
||||
|
||||
数据源:
|
||||
- biz.trigger_jobs(业务触发器)→ source="biz"
|
||||
- biz.ai_trigger_jobs(AI 事件链,最近 100 条)→ source="ai"
|
||||
- public.scheduled_tasks(ETL 调度)→ source="etl"
|
||||
|
||||
某数据源查询失败时记录日志,返回其他数据源数据。
|
||||
|
||||
需求: 4.1, 4.2, 4.3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_triggers import UnifiedTriggerItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/triggers", tags=["系统管理"])
|
||||
|
||||
|
||||
def _fetch_biz_triggers(conn) -> list[UnifiedTriggerItem]:
|
||||
"""查询 biz.trigger_jobs,映射 source='biz'。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_name, trigger_condition, status,
|
||||
last_run_at, next_run_at, last_error
|
||||
FROM biz.trigger_jobs
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
UnifiedTriggerItem(
|
||||
id=row[0],
|
||||
name=row[1],
|
||||
source="biz",
|
||||
trigger_condition=row[2] or "",
|
||||
status=row[3] or "",
|
||||
last_run_at=str(row[4]) if row[4] is not None else None,
|
||||
next_run_at=str(row[5]) if row[5] is not None else None,
|
||||
last_error=row[6],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _fetch_ai_triggers(conn) -> list[UnifiedTriggerItem]:
|
||||
"""查询 biz.ai_trigger_jobs(最近 100 条),映射 source='ai'。
|
||||
|
||||
字段映射(DDL 实际列 → UnifiedTriggerItem):
|
||||
- id → id
|
||||
- event_type → name(ai_trigger_jobs 无 job_name 列)
|
||||
- 'event' → trigger_condition(AI 触发器均为事件驱动)
|
||||
- status → status
|
||||
- started_at → last_run_at(ai_trigger_jobs 无 last_run_at 列)
|
||||
- None → next_run_at(事件驱动无预定下次执行时间)
|
||||
- error_message → last_error(ai_trigger_jobs 列名为 error_message)
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, event_type, status,
|
||||
started_at, error_message
|
||||
FROM biz.ai_trigger_jobs
|
||||
ORDER BY id DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
UnifiedTriggerItem(
|
||||
id=row[0],
|
||||
name=row[1] or "",
|
||||
source="ai",
|
||||
trigger_condition="event",
|
||||
status=row[2] or "",
|
||||
last_run_at=str(row[3]) if row[3] is not None else None,
|
||||
next_run_at=None,
|
||||
last_error=row[4],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _fetch_etl_triggers(conn) -> list[UnifiedTriggerItem]:
|
||||
"""查询 public.scheduled_tasks,映射 source='etl'。
|
||||
|
||||
字段映射(DDL 实际列 → UnifiedTriggerItem):
|
||||
- id → id(UUID,转为字符串后取 hashcode 作为 int 不合适,改用 row_number)
|
||||
- name → name
|
||||
- schedule_config->>'schedule_type' → trigger_condition
|
||||
- last_status / enabled → status(组合判断)
|
||||
- last_run_at → last_run_at
|
||||
- next_run_at → next_run_at
|
||||
- None → last_error(scheduled_tasks 无 last_error 列)
|
||||
|
||||
注意:scheduled_tasks.id 是 UUID 类型,UnifiedTriggerItem.id 是 int。
|
||||
使用 ROW_NUMBER() 生成临时整数 ID,加 100000 偏移避免与其他数据源冲突。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY created_at) + 100000 AS row_id,
|
||||
name,
|
||||
schedule_config->>'schedule_type' AS schedule_type,
|
||||
CASE
|
||||
WHEN enabled = FALSE THEN 'disabled'
|
||||
WHEN last_status IS NOT NULL THEN last_status
|
||||
ELSE 'idle'
|
||||
END AS status,
|
||||
last_run_at,
|
||||
next_run_at
|
||||
FROM scheduled_tasks
|
||||
ORDER BY created_at
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
UnifiedTriggerItem(
|
||||
id=int(row[0]),
|
||||
name=row[1] or "",
|
||||
source="etl",
|
||||
trigger_condition=row[2] or "unknown",
|
||||
status=row[3] or "idle",
|
||||
last_run_at=str(row[4]) if row[4] is not None else None,
|
||||
next_run_at=str(row[5]) if row[5] is not None else None,
|
||||
last_error=None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/unified", response_model=list[UnifiedTriggerItem])
|
||||
async def get_unified_triggers(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[UnifiedTriggerItem]:
|
||||
"""聚合三张表的触发器数据。
|
||||
|
||||
依次查询 biz.trigger_jobs、biz.ai_trigger_jobs、scheduled_tasks,
|
||||
某数据源查询失败时记录日志并跳过,返回其他数据源的数据。
|
||||
"""
|
||||
results: list[UnifiedTriggerItem] = []
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 数据源 1:biz.trigger_jobs
|
||||
try:
|
||||
results.extend(_fetch_biz_triggers(conn))
|
||||
except Exception:
|
||||
logger.warning("查询 biz.trigger_jobs 失败", exc_info=True)
|
||||
|
||||
# 数据源 2:biz.ai_trigger_jobs
|
||||
try:
|
||||
results.extend(_fetch_ai_triggers(conn))
|
||||
except Exception:
|
||||
logger.warning("查询 biz.ai_trigger_jobs 失败", exc_info=True)
|
||||
|
||||
# 数据源 3:public.scheduled_tasks
|
||||
try:
|
||||
results.extend(_fetch_etl_triggers(conn))
|
||||
except Exception:
|
||||
logger.warning("查询 scheduled_tasks 失败", exc_info=True)
|
||||
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -37,7 +37,7 @@ async def login(body: LoginRequest):
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, site_id, is_active "
|
||||
"SELECT id, password_hash, site_id, is_active, roles "
|
||||
"FROM admin_users WHERE username = %s",
|
||||
(body.username,),
|
||||
)
|
||||
@@ -51,7 +51,7 @@ async def login(body: LoginRequest):
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
user_id, password_hash, site_id, is_active = row
|
||||
user_id, password_hash, site_id, is_active, roles = row
|
||||
|
||||
if not is_active:
|
||||
raise HTTPException(
|
||||
@@ -65,7 +65,7 @@ async def login(body: LoginRequest):
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
tokens = create_token_pair(user_id, site_id)
|
||||
tokens = create_token_pair(user_id, site_id, roles=roles or [])
|
||||
return TokenResponse(**tokens)
|
||||
|
||||
|
||||
@@ -88,8 +88,22 @@ async def refresh(body: RefreshRequest):
|
||||
user_id = int(payload["sub"])
|
||||
site_id = payload["site_id"]
|
||||
|
||||
# CHANGE 2026-03-24 | Prompt: 修复 refresh 丢失 roles | 刷新前查询数据库获取最新 roles
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT roles FROM admin_users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
roles = row[0] if row else []
|
||||
|
||||
# 生成新的 access_token,refresh_token 原样返回
|
||||
new_access = create_access_token(user_id, site_id)
|
||||
new_access = create_access_token(user_id, site_id, roles=roles or [])
|
||||
return TokenResponse(
|
||||
access_token=new_access,
|
||||
refresh_token=body.refresh_token,
|
||||
|
||||
@@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection, get_etl_readonly_connection
|
||||
from app.database import get_connection, get_etl_global_readonly_connection
|
||||
from app.schemas.etl_status import CursorInfo, RecentRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +40,8 @@ async def list_cursors(
|
||||
查询 ETL 数据库中的 meta.etl_cursor 表。
|
||||
如果该表不存在,返回空列表而非报错。
|
||||
"""
|
||||
conn = get_etl_readonly_connection(user.site_id)
|
||||
# CHANGE 2026-03-23 | 系统管理后台全局视角,不按门店过滤
|
||||
conn = get_etl_global_readonly_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-02-15 | 对齐新库 etl_feiqiu 六层架构:etl_admin → meta
|
||||
@@ -60,9 +61,10 @@ async def list_cursors(
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT task_code, last_fetch_time, record_count
|
||||
FROM meta.etl_cursor
|
||||
ORDER BY task_code
|
||||
SELECT t.task_code, c.last_start, c.last_end
|
||||
FROM meta.etl_cursor c
|
||||
JOIN meta.etl_task t ON c.task_id = t.task_id
|
||||
ORDER BY t.task_code
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
@@ -70,8 +72,8 @@ async def list_cursors(
|
||||
return [
|
||||
CursorInfo(
|
||||
task_code=row[0],
|
||||
last_fetch_time=str(row[1]) if row[1] is not None else None,
|
||||
record_count=row[2],
|
||||
last_start=str(row[1]) if row[1] is not None else None,
|
||||
last_end=str(row[2]) if row[2] is not None else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -99,16 +101,16 @@ async def list_recent_runs(
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-23 | 系统管理后台全局视角,不按门店过滤
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_codes, status, started_at,
|
||||
finished_at, duration_ms, exit_code
|
||||
FROM task_execution_log
|
||||
WHERE site_id = %s
|
||||
ORDER BY started_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(user.site_id, _RECENT_RUNS_LIMIT),
|
||||
(_RECENT_RUNS_LIMIT,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.schemas.execution import (
|
||||
from app.schemas.tasks import TaskConfigSchema
|
||||
from app.services.task_executor import task_executor
|
||||
from app.services.task_queue import task_queue
|
||||
from app.services.output_cleanup import cleanup_output_dirs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -188,6 +189,142 @@ async def cancel_execution(
|
||||
return {"message": "已发送取消信号"}
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
def _parse_config_from_command(
|
||||
task_codes: list[str],
|
||||
command: str | None,
|
||||
site_id: int,
|
||||
) -> TaskConfigSchema:
|
||||
"""从旧记录的 command 字符串解析出原始 CLI 参数,构建 TaskConfigSchema。
|
||||
|
||||
旧记录没有 config JSONB 列,但 command 包含完整的 CLI 参数。
|
||||
"""
|
||||
kwargs: dict = {
|
||||
"tasks": task_codes or [],
|
||||
"store_id": site_id,
|
||||
}
|
||||
|
||||
if command:
|
||||
# 解析 --flow
|
||||
m = _re.search(r"--flow\s+(\S+)", command)
|
||||
if m:
|
||||
kwargs["flow"] = m.group(1)
|
||||
|
||||
# 解析 --processing-mode
|
||||
m = _re.search(r"--processing-mode\s+(\S+)", command)
|
||||
if m:
|
||||
kwargs["processing_mode"] = m.group(1)
|
||||
|
||||
# 解析 --lookback-hours
|
||||
m = _re.search(r"--lookback-hours\s+(\d+)", command)
|
||||
if m:
|
||||
kwargs["lookback_hours"] = int(m.group(1))
|
||||
|
||||
# 解析 --overlap-seconds
|
||||
m = _re.search(r"--overlap-seconds\s+(\d+)", command)
|
||||
if m:
|
||||
kwargs["overlap_seconds"] = int(m.group(1))
|
||||
|
||||
# 解析 --window-start / --window-end
|
||||
m = _re.search(r"--window-start\s+(\S+)", command)
|
||||
if m:
|
||||
kwargs["window_start"] = m.group(1)
|
||||
kwargs["window_mode"] = "custom"
|
||||
m = _re.search(r"--window-end\s+(\S+)", command)
|
||||
if m:
|
||||
kwargs["window_end"] = m.group(1)
|
||||
|
||||
# 解析 --dry-run
|
||||
if "--dry-run" in command:
|
||||
kwargs["dry_run"] = True
|
||||
|
||||
# 解析 --force-full
|
||||
if "--force-full" in command:
|
||||
kwargs["force_full"] = True
|
||||
|
||||
# 解析 --fetch-before-verify
|
||||
if "--fetch-before-verify" in command:
|
||||
kwargs["fetch_before_verify"] = True
|
||||
|
||||
return TaskConfigSchema(**kwargs)
|
||||
|
||||
|
||||
# ── POST /api/execution/{id}/rerun — 重新执行 ───────────────
|
||||
|
||||
# CHANGE 2026-03-22 | 支持对任意历史任务重新执行
|
||||
@router.post("/{execution_id}/rerun", response_model=ExecutionRunResponse)
|
||||
async def rerun_execution(
|
||||
execution_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ExecutionRunResponse:
|
||||
"""根据历史执行记录重新执行相同的任务。
|
||||
|
||||
优先从 config JSONB 列还原完整配置;若旧记录无 config 列,
|
||||
回退到 task_codes + 默认配置。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT task_codes, site_id, config, command
|
||||
FROM task_execution_log
|
||||
WHERE id = %s AND site_id = %s
|
||||
""",
|
||||
(execution_id, user.site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="执行记录不存在",
|
||||
)
|
||||
|
||||
task_codes = row[0] or []
|
||||
config_json = row[2] # JSONB,可能为 None(旧记录)
|
||||
command_str = row[3] # command 字符串,用于旧记录回退解析
|
||||
|
||||
if not task_codes and not config_json:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原执行记录无任务代码,无法重新执行",
|
||||
)
|
||||
|
||||
# CHANGE 2026-03-22 | 优先从存储的完整 config 还原,保留原始 processing_mode/lookback 等参数
|
||||
if config_json and isinstance(config_json, dict):
|
||||
# 覆盖 store_id 为当前用户的(安全)
|
||||
config_json["store_id"] = user.site_id
|
||||
config = TaskConfigSchema(**config_json)
|
||||
else:
|
||||
# 旧记录无 config 列,尝试从 command 字符串解析原始参数
|
||||
config = _parse_config_from_command(task_codes, command_str, user.site_id)
|
||||
|
||||
new_execution_id = str(uuid.uuid4())
|
||||
asyncio.create_task(
|
||||
task_executor.execute(
|
||||
config=config,
|
||||
execution_id=new_execution_id,
|
||||
site_id=user.site_id,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"重新执行 [%s] → [%s], tasks=%s",
|
||||
execution_id, new_execution_id, task_codes,
|
||||
)
|
||||
|
||||
return ExecutionRunResponse(
|
||||
execution_id=new_execution_id,
|
||||
message=f"已基于 {execution_id[:8]}… 重新执行",
|
||||
)
|
||||
|
||||
|
||||
# ── GET /api/execution/history — 执行历史 ────────────────────
|
||||
|
||||
@router.get("/history", response_model=list[ExecutionHistoryItem])
|
||||
@@ -281,3 +418,21 @@ async def get_execution_logs(
|
||||
output_log=row[0],
|
||||
error_log=row[1],
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/execution/cleanup-output — 清理输出目录 ────────
|
||||
|
||||
# CHANGE 2026-03-27 | 新增:执行前清理 EXPORT_ROOT 下旧运行记录,每类任务只保留最近 10 个
|
||||
@router.post("/cleanup-output")
|
||||
async def cleanup_output(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""清理 EXPORT_ROOT 下每个任务文件夹的旧运行记录,只保留最近 10 个。"""
|
||||
try:
|
||||
result = cleanup_output_dirs(keep=10)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(exc),
|
||||
)
|
||||
return result
|
||||
|
||||
139
apps/backend/app/routers/internal_ai.py
Normal file
139
apps/backend/app/routers/internal_ai.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
内部 AI 触发 API — ETL/内部服务调用入口。
|
||||
|
||||
端点:
|
||||
- POST /api/internal/ai/trigger — 接收事件触发请求,异步执行 AI 调用链
|
||||
|
||||
认证方式:Authorization: Internal-Token {token}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.ai.config import AIConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/internal/ai", tags=["internal-ai"])
|
||||
|
||||
|
||||
# ── 请求/响应模型 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TriggerRequest(BaseModel):
|
||||
"""内部触发请求体。"""
|
||||
|
||||
event_type: str = Field(..., description="事件类型: consumption / dws_completed / note_created / task_assigned")
|
||||
connector_type: str = Field("feiqiu", description="连接器类型")
|
||||
site_id: int = Field(..., description="门店 ID")
|
||||
member_id: int | None = Field(None, description="会员 ID(可选)")
|
||||
payload: dict | None = Field(None, description="附加数据")
|
||||
is_forced: bool = Field(False, description="是否强制执行(跳过去重)")
|
||||
|
||||
|
||||
class TriggerResponse(BaseModel):
|
||||
"""触发响应。"""
|
||||
|
||||
trigger_job_id: int
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
# ── 认证依赖 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def verify_internal_token(authorization: str = Header(...)) -> str:
|
||||
"""校验 Internal-Token 认证。
|
||||
|
||||
Header 格式:Authorization: Internal-Token {token}
|
||||
token 不匹配或缺失时返回 HTTP 401。
|
||||
"""
|
||||
prefix = "Internal-Token "
|
||||
if not authorization.startswith(prefix):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证格式,需要 Internal-Token",
|
||||
)
|
||||
|
||||
token = authorization[len(prefix):]
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 不能为空",
|
||||
)
|
||||
|
||||
# 从环境变量加载期望 token
|
||||
try:
|
||||
config = AIConfig.from_env()
|
||||
except ValueError:
|
||||
logger.error("AIConfig 加载失败,无法校验 internal token")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI 配置异常",
|
||||
)
|
||||
|
||||
if token != config.internal_api_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 不匹配",
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
|
||||
# ── 端点 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=TriggerResponse)
|
||||
async def trigger_ai_event(
|
||||
body: TriggerRequest,
|
||||
_token: str = Depends(verify_internal_token),
|
||||
) -> TriggerResponse:
|
||||
"""接收 ETL/内部事件,写 ai_trigger_jobs 后异步执行。
|
||||
|
||||
立即返回 trigger_job_id,调用链在后台异步执行。
|
||||
"""
|
||||
from app.ai.dispatcher import AIDispatcher, TriggerEvent
|
||||
|
||||
# 构建触发事件
|
||||
event = TriggerEvent(
|
||||
event_type=body.event_type,
|
||||
site_id=body.site_id,
|
||||
member_id=body.member_id,
|
||||
connector_type=body.connector_type,
|
||||
payload=body.payload or {},
|
||||
is_forced=body.is_forced,
|
||||
)
|
||||
|
||||
# 获取 dispatcher 实例并触发
|
||||
# 延迟导入避免循环依赖,dispatcher 实例由应用启动时创建
|
||||
dispatcher = _get_dispatcher()
|
||||
job_id = await dispatcher.handle_trigger(event)
|
||||
|
||||
return TriggerResponse(trigger_job_id=job_id, status="pending")
|
||||
|
||||
|
||||
# ── 辅助函数 ─────────────────────────────────────────────────
|
||||
|
||||
# 全局 dispatcher 实例(应用启动时初始化)
|
||||
_dispatcher_instance: AIDispatcher | None = None
|
||||
|
||||
|
||||
def set_dispatcher(dispatcher: "AIDispatcher") -> None:
|
||||
"""设置全局 dispatcher 实例(应用启动时调用)。"""
|
||||
global _dispatcher_instance
|
||||
_dispatcher_instance = dispatcher
|
||||
|
||||
|
||||
def _get_dispatcher() -> "AIDispatcher":
|
||||
"""获取全局 dispatcher 实例。"""
|
||||
if _dispatcher_instance is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AI Dispatcher 尚未初始化",
|
||||
)
|
||||
return _dispatcher_instance
|
||||
83
apps/backend/app/routers/internal_events.py
Normal file
83
apps/backend/app/routers/internal_events.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-29 | Prompt: DWS_TASK_ENGINE ETL 任务 | 新建文件。
|
||||
# 提供 POST /api/internal/run-job 端点,供 ETL 按 job_name 执行
|
||||
# biz.trigger_jobs 中的任务。Internal-Token 认证。
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
内部任务执行 API — ETL/内部服务调用入口。
|
||||
|
||||
端点:
|
||||
- POST /api/internal/run-job — 按 job_name 执行 biz.trigger_jobs 中的任务
|
||||
|
||||
认证方式:Authorization: Internal-Token {token}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth.internal_token import verify_internal_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/internal", tags=["internal-events"])
|
||||
|
||||
|
||||
class RunJobByNameRequest(BaseModel):
|
||||
"""按 job_name 执行任务的请求体。"""
|
||||
job_name: str = Field(..., description="任务名称,如 recall_completion_check")
|
||||
|
||||
|
||||
class RunJobByNameResponse(BaseModel):
|
||||
"""执行结果。"""
|
||||
success: bool
|
||||
message: str
|
||||
job_name: str
|
||||
|
||||
|
||||
@router.post("/run-job", response_model=RunJobByNameResponse)
|
||||
async def run_job_by_name_endpoint(
|
||||
body: RunJobByNameRequest,
|
||||
_token: str = Depends(verify_internal_token),
|
||||
) -> RunJobByNameResponse:
|
||||
"""按 job_name 查找并执行 biz.trigger_jobs 中的任务。
|
||||
|
||||
ETL DWS_TASK_ENGINE 任务通过此端点按顺序执行后端任务引擎的各个步骤。
|
||||
"""
|
||||
from app.database import get_connection
|
||||
from app.services.trigger_scheduler import run_job_by_id
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM biz.trigger_jobs WHERE job_name = %s",
|
||||
(body.job_name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务 '{body.job_name}' 不存在",
|
||||
)
|
||||
|
||||
job_id = row[0]
|
||||
result = run_job_by_id(job_id)
|
||||
|
||||
logger.info(
|
||||
"内部任务执行: job_name=%s, success=%s",
|
||||
body.job_name, result.get("success"),
|
||||
)
|
||||
return RunJobByNameResponse(
|
||||
success=result.get("success", False),
|
||||
message=result.get("message", ""),
|
||||
job_name=body.job_name,
|
||||
)
|
||||
@@ -67,6 +67,7 @@ async def get_retention_clues(member_id: int, site_id: int):
|
||||
recorded_by_assistant_id, recorded_by_name, recorded_at, site_id, source
|
||||
FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
AND is_hidden = false
|
||||
ORDER BY recorded_at DESC
|
||||
"""
|
||||
conn = get_connection()
|
||||
|
||||
@@ -43,6 +43,7 @@ def _row_to_response(row) -> ScheduleResponse:
|
||||
"""将数据库行转换为 ScheduleResponse。"""
|
||||
task_config = row[4] if isinstance(row[4], dict) else json.loads(row[4])
|
||||
schedule_config = row[5] if isinstance(row[5], dict) else json.loads(row[5])
|
||||
min_run_intervals = row[14] if isinstance(row[14], dict) else json.loads(row[14]) if row[14] else {}
|
||||
return ScheduleResponse(
|
||||
id=str(row[0]),
|
||||
site_id=row[1],
|
||||
@@ -55,8 +56,12 @@ def _row_to_response(row) -> ScheduleResponse:
|
||||
next_run_at=row[8],
|
||||
run_count=row[9],
|
||||
last_status=row[10],
|
||||
created_at=row[11],
|
||||
updated_at=row[12],
|
||||
min_run_interval_value=row[11] or 0,
|
||||
min_run_interval_unit=row[12] or "minutes",
|
||||
last_success_at=row[13],
|
||||
min_run_intervals=min_run_intervals,
|
||||
created_at=row[15],
|
||||
updated_at=row[16],
|
||||
)
|
||||
|
||||
|
||||
@@ -64,6 +69,8 @@ def _row_to_response(row) -> ScheduleResponse:
|
||||
_SELECT_COLS = """
|
||||
id, site_id, name, task_codes, task_config, schedule_config,
|
||||
enabled, last_run_at, next_run_at, run_count, last_status,
|
||||
min_run_interval_value, min_run_interval_unit, last_success_at,
|
||||
min_run_intervals,
|
||||
created_at, updated_at
|
||||
"""
|
||||
|
||||
@@ -107,8 +114,9 @@ async def create_schedule(
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO scheduled_tasks
|
||||
(site_id, name, task_codes, task_config, schedule_config, enabled, next_run_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
(site_id, name, task_codes, task_config, schedule_config, enabled, next_run_at,
|
||||
min_run_interval_value, min_run_interval_unit, min_run_intervals)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_SELECT_COLS}
|
||||
""",
|
||||
(
|
||||
@@ -119,6 +127,9 @@ async def create_schedule(
|
||||
body.schedule_config.model_dump_json(),
|
||||
body.schedule_config.enabled,
|
||||
next_run,
|
||||
body.min_run_interval_value,
|
||||
body.min_run_interval_unit,
|
||||
json.dumps({k: v.model_dump() for k, v in body.min_run_intervals.items()}) if body.min_run_intervals else "{}",
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -174,6 +185,16 @@ async def update_schedule(
|
||||
set_parts.append("next_run_at = %s")
|
||||
params.append(next_run)
|
||||
|
||||
if body.min_run_interval_value is not None:
|
||||
set_parts.append("min_run_interval_value = %s")
|
||||
params.append(body.min_run_interval_value)
|
||||
if body.min_run_interval_unit is not None:
|
||||
set_parts.append("min_run_interval_unit = %s")
|
||||
params.append(body.min_run_interval_unit)
|
||||
if body.min_run_intervals is not None:
|
||||
set_parts.append("min_run_intervals = %s")
|
||||
params.append(json.dumps({k: v.model_dump() for k, v in body.min_run_intervals.items()}))
|
||||
|
||||
if not set_parts:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
@@ -314,17 +335,22 @@ async def toggle_schedule(
|
||||
@router.post("/{schedule_id}/run")
|
||||
async def run_schedule_now(
|
||||
schedule_id: str,
|
||||
force: bool = Query(False),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""手动触发调度任务执行一次,不更新 last_run_at / next_run_at / run_count。
|
||||
|
||||
读取调度任务的 task_config,构造 TaskConfigSchema 后入队执行。
|
||||
force=true 时绕过并发和间隔检查,直接入队。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT task_config, site_id FROM scheduled_tasks WHERE id = %s AND site_id = %s",
|
||||
"""SELECT task_config, site_id,
|
||||
min_run_interval_value, min_run_interval_unit,
|
||||
last_run_at, last_status, min_run_intervals
|
||||
FROM scheduled_tasks WHERE id = %s AND site_id = %s""",
|
||||
(schedule_id, user.site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -338,6 +364,42 @@ async def run_schedule_now(
|
||||
detail="调度任务不存在",
|
||||
)
|
||||
|
||||
# force=false 时执行并发和间隔检查
|
||||
if not force:
|
||||
last_status = row[5]
|
||||
if last_status == "running":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="任务正在运行中,无法重复执行",
|
||||
)
|
||||
|
||||
min_interval_value = row[2] or 0
|
||||
min_interval_unit = row[3] or "minutes"
|
||||
last_run_at = row[4]
|
||||
min_run_intervals_raw = row[6] if isinstance(row[6], dict) else json.loads(row[6]) if row[6] else {}
|
||||
|
||||
# 计算有效间隔:per-task 最大值 vs schedule 级别,取较大者
|
||||
effective_interval_seconds = 0
|
||||
multipliers = {"minutes": 60, "hours": 3600, "days": 86400}
|
||||
if min_interval_value > 0:
|
||||
effective_interval_seconds = min_interval_value * multipliers.get(min_interval_unit, 60)
|
||||
for _task_code, interval_cfg in min_run_intervals_raw.items():
|
||||
if isinstance(interval_cfg, dict):
|
||||
v = interval_cfg.get("value", 0) or 0
|
||||
u = interval_cfg.get("unit", "minutes")
|
||||
task_seconds = v * multipliers.get(u, 60) if v > 0 else 0
|
||||
if task_seconds > effective_interval_seconds:
|
||||
effective_interval_seconds = task_seconds
|
||||
|
||||
if effective_interval_seconds > 0 and last_run_at is not None:
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed = (now - last_run_at).total_seconds()
|
||||
if elapsed < effective_interval_seconds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="未达到最小运行间隔,请稍后再试",
|
||||
)
|
||||
|
||||
task_config_raw = row[0] if isinstance(row[0], dict) else json.loads(row[0])
|
||||
config = TaskConfigSchema(**task_config_raw)
|
||||
config = config.model_copy(update={"store_id": user.site_id})
|
||||
|
||||
247
apps/backend/app/routers/tenant_auth.py
Normal file
247
apps/backend/app/routers/tenant_auth.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
租户管理员认证路由:登录与令牌刷新。
|
||||
|
||||
- POST /api/tenant/auth/login — 用户名+密码验证,签发 JWT(aud=tenant-admin)
|
||||
- POST /api/tenant/auth/refresh — 刷新令牌换取新令牌对
|
||||
|
||||
JWT payload 包含:sub=admin_id, tenant_id, managed_site_ids, aud=tenant-admin, type
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import config
|
||||
from app.auth.jwt import verify_password
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tenant/auth", tags=["租户认证"])
|
||||
|
||||
|
||||
# ── Pydantic 模型 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TenantLoginRequest(BaseModel):
|
||||
"""租户管理员登录请求。"""
|
||||
username: str = Field(..., min_length=1, max_length=100, description="用户名")
|
||||
password: str = Field(..., min_length=1, description="密码")
|
||||
|
||||
|
||||
class TenantRefreshRequest(BaseModel):
|
||||
"""刷新令牌请求。"""
|
||||
refresh_token: str = Field(..., min_length=1, description="刷新令牌")
|
||||
|
||||
|
||||
class TenantTokenResponse(BaseModel):
|
||||
"""令牌响应。"""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
# ── JWT 签发(租户管理员专用,含 aud=tenant-admin) ──────────
|
||||
|
||||
|
||||
def _create_tenant_access_token(
|
||||
admin_id: int,
|
||||
tenant_id: int,
|
||||
managed_site_ids: list[int],
|
||||
admin_type: str = "tenant_admin",
|
||||
display_name: str | None = None,
|
||||
) -> str:
|
||||
"""签发租户管理员 access_token(aud=tenant-admin)。"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
payload: dict = {
|
||||
"sub": str(admin_id),
|
||||
"tenant_id": tenant_id,
|
||||
"managed_site_ids": managed_site_ids,
|
||||
"admin_type": admin_type,
|
||||
"aud": "tenant-admin",
|
||||
"type": "access",
|
||||
"exp": expire,
|
||||
}
|
||||
if display_name is not None:
|
||||
payload["display_name"] = display_name
|
||||
return jose_jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def _create_tenant_refresh_token(
|
||||
admin_id: int,
|
||||
tenant_id: int,
|
||||
managed_site_ids: list[int],
|
||||
admin_type: str = "tenant_admin",
|
||||
) -> str:
|
||||
"""签发租户管理员 refresh_token(aud=tenant-admin)。"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
payload: dict = {
|
||||
"sub": str(admin_id),
|
||||
"tenant_id": tenant_id,
|
||||
"managed_site_ids": managed_site_ids,
|
||||
"admin_type": admin_type,
|
||||
"aud": "tenant-admin",
|
||||
"type": "refresh",
|
||||
"exp": expire,
|
||||
}
|
||||
return jose_jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
||||
|
||||
|
||||
# ── 路由端点 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/login", response_model=TenantTokenResponse)
|
||||
async def tenant_login(body: TenantLoginRequest):
|
||||
"""
|
||||
租户管理员登录。
|
||||
|
||||
查询 auth.tenant_admins 表验证用户名密码,成功后签发 JWT 令牌对。
|
||||
- 用户不存在或密码错误:401(统一消息,不区分)
|
||||
- 账号已禁用(is_active=false):403
|
||||
- 登录成功:更新 last_login_at
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 过滤已删除记录
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | LOWER() 比较
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, display_name, tenant_id, "
|
||||
"managed_site_ids, is_active, admin_type "
|
||||
"FROM auth.tenant_admins "
|
||||
"WHERE LOWER(username) = LOWER(%s) AND deleted_at IS NULL",
|
||||
(body.username,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 用户不存在 → 401(统一消息)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
admin_id, password_hash, display_name, tenant_id, managed_site_ids, is_active, admin_type = row
|
||||
|
||||
# 账号禁用 → 403
|
||||
if not is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
# 密码错误 → 401(统一消息)
|
||||
if not verify_password(body.password, password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
# 登录成功:更新 last_login_at
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE auth.tenant_admins SET last_login_at = NOW() WHERE id = %s",
|
||||
(admin_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("更新 last_login_at 失败(admin_id=%s)", admin_id, exc_info=True)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 签发令牌对
|
||||
access_token = _create_tenant_access_token(
|
||||
admin_id=admin_id,
|
||||
tenant_id=tenant_id,
|
||||
managed_site_ids=managed_site_ids,
|
||||
admin_type=admin_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
refresh_token = _create_tenant_refresh_token(
|
||||
admin_id=admin_id,
|
||||
tenant_id=tenant_id,
|
||||
managed_site_ids=managed_site_ids,
|
||||
admin_type=admin_type,
|
||||
)
|
||||
|
||||
return TenantTokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TenantTokenResponse)
|
||||
async def tenant_refresh(body: TenantRefreshRequest):
|
||||
"""
|
||||
刷新租户管理员令牌。
|
||||
|
||||
验证 refresh_token(aud=tenant-admin, type=refresh),签发新令牌对。
|
||||
"""
|
||||
try:
|
||||
payload = jose_jwt.decode(
|
||||
body.refresh_token,
|
||||
config.JWT_SECRET_KEY,
|
||||
algorithms=[config.JWT_ALGORITHM],
|
||||
audience="tenant-admin",
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的刷新令牌",
|
||||
)
|
||||
|
||||
# 验证 token type
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌类型不匹配",
|
||||
)
|
||||
|
||||
# 验证 aud(jose 在 aud 缺失时不会拒绝,需显式检查)
|
||||
if payload.get("aud") != "tenant-admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌类型不匹配",
|
||||
)
|
||||
|
||||
# 提取字段
|
||||
admin_id = int(payload["sub"])
|
||||
tenant_id = payload["tenant_id"]
|
||||
managed_site_ids = payload["managed_site_ids"]
|
||||
admin_type = payload.get("admin_type", "tenant_admin")
|
||||
|
||||
# 签发新令牌对
|
||||
access_token = _create_tenant_access_token(
|
||||
admin_id=admin_id,
|
||||
tenant_id=tenant_id,
|
||||
managed_site_ids=managed_site_ids,
|
||||
admin_type=admin_type,
|
||||
display_name=payload.get("display_name"),
|
||||
)
|
||||
refresh_token = _create_tenant_refresh_token(
|
||||
admin_id=admin_id,
|
||||
tenant_id=tenant_id,
|
||||
managed_site_ids=managed_site_ids,
|
||||
admin_type=admin_type,
|
||||
)
|
||||
|
||||
return TenantTokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
)
|
||||
312
apps/backend/app/routers/tenant_clues.py
Normal file
312
apps/backend/app/routers/tenant_clues.py
Normal file
@@ -0,0 +1,312 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
租户管理后台 — 维客线索管理路由。
|
||||
|
||||
端点清单:
|
||||
- GET /api/tenant/customers/search — 客户搜索(keyword + site_id)
|
||||
- GET /api/tenant/customers/{member_id}/clues — 线索列表(source / is_hidden 筛选)
|
||||
- PATCH /api/tenant/clues/{id} — 编辑线索
|
||||
- DELETE /api/tenant/clues/{id} — 物理删除线索
|
||||
- PATCH /api/tenant/clues/{id}/visibility — 切换隐藏/显示
|
||||
|
||||
需求: 9.1-9.4, 10.1, 11.1-11.3, 12.2-12.3, 13.1-13.4
|
||||
|
||||
AI_CHANGELOG
|
||||
- 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:_get_clue_with_site_check 签名改为接受 admin: CurrentTenantAdmin;search_customers 用 get_effective_site_ids;list_customer_clues 用 site_filter_clause(admin=admin);三个调用点改传 admin | Verify:维客线索管理覆盖新建店铺
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.tenant_admins import (
|
||||
CurrentTenantAdmin,
|
||||
get_effective_site_ids,
|
||||
require_tenant_admin,
|
||||
site_filter_clause,
|
||||
)
|
||||
from app.database import get_connection, get_etl_readonly_connection
|
||||
from app.schemas.tenant_clues import (
|
||||
ClueEditRequest,
|
||||
ClueListItem,
|
||||
ClueVisibilityRequest,
|
||||
CustomerSearchItem,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tenant", tags=["维客线索管理"])
|
||||
|
||||
|
||||
def _mask_mobile(mobile: str | None) -> str | None:
|
||||
"""手机号脱敏:中间 4 位替换为 ****,如 138****1234。"""
|
||||
if not mobile or len(mobile) < 7:
|
||||
return mobile
|
||||
return mobile[:3] + "****" + mobile[7:]
|
||||
|
||||
|
||||
def _get_clue_with_site_check(clue_id: int, admin: CurrentTenantAdmin):
|
||||
"""
|
||||
查询线索并校验 site_id 是否在管辖范围内。
|
||||
|
||||
不在管辖范围或不存在均返回 404(避免泄露线索存在性)。
|
||||
返回 (id, site_id, member_id, category, summary, detail,
|
||||
recorded_by_name, source, recorded_at, is_hidden)。
|
||||
"""
|
||||
site_sql, site_params = site_filter_clause(admin=admin)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, site_id, member_id, category, summary, detail,
|
||||
recorded_by_name, source, recorded_at::text, is_hidden
|
||||
FROM public.member_retention_clue
|
||||
WHERE id = %s AND {site_sql}
|
||||
""",
|
||||
(clue_id, *site_params),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线索不存在")
|
||||
return row
|
||||
|
||||
|
||||
# ── GET /api/tenant/customers/search ──────────────────────
|
||||
|
||||
|
||||
@router.get("/customers/search")
|
||||
async def search_customers(
|
||||
keyword: str = Query(..., min_length=1, description="搜索关键词(姓名模糊/手机号精确)"),
|
||||
site_id: Optional[int] = Query(None, description="指定门店 ID 筛选"),
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""
|
||||
客户搜索:在管辖门店范围内搜索 v_dim_member。
|
||||
|
||||
nickname 模糊匹配 OR mobile 精确匹配,scd2_is_current=1。
|
||||
手机号脱敏返回。
|
||||
"""
|
||||
# 确定要搜索的门店列表
|
||||
# [CHANGE P20260323-210000] intent: 使用 get_effective_site_ids 统一获取有效 site_ids
|
||||
effective_ids = get_effective_site_ids(admin)
|
||||
if site_id is not None:
|
||||
if site_id not in effective_ids:
|
||||
return {"items": []}
|
||||
search_site_ids = [site_id]
|
||||
else:
|
||||
search_site_ids = effective_ids
|
||||
|
||||
if not search_site_ids:
|
||||
return {"items": []}
|
||||
|
||||
# 逐 site_id 查询 FDW(RLS 要求逐个设置 current_site_id)
|
||||
all_items: list[dict] = []
|
||||
for sid in search_site_ids:
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(sid)
|
||||
try:
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, nickname, mobile
|
||||
FROM fdw_etl.v_dim_member
|
||||
WHERE scd2_is_current = 1
|
||||
AND (nickname ILIKE %s OR mobile = %s)
|
||||
LIMIT 50
|
||||
""",
|
||||
(f"%{keyword}%", keyword),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
all_items.append(
|
||||
CustomerSearchItem(
|
||||
member_id=row[0],
|
||||
nickname=row[1],
|
||||
mobile_masked=_mask_mobile(row[2]),
|
||||
site_id=sid,
|
||||
).model_dump(by_alias=True)
|
||||
)
|
||||
finally:
|
||||
etl_conn.close()
|
||||
except Exception:
|
||||
logger.warning("v_dim_member 搜索失败(site_id=%s)", sid, exc_info=True)
|
||||
|
||||
# 补充 site_name
|
||||
if all_items:
|
||||
site_ids_set = list({item.get("siteId") for item in all_items if item.get("siteId")})
|
||||
if site_ids_set:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
placeholders = ", ".join(["%s"] * len(site_ids_set))
|
||||
cur.execute(
|
||||
f"SELECT site_id, site_name FROM biz.sites WHERE site_id IN ({placeholders})",
|
||||
tuple(site_ids_set),
|
||||
)
|
||||
site_name_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
for item in all_items:
|
||||
sid_val = item.get("siteId")
|
||||
if sid_val and sid_val in site_name_map:
|
||||
item["siteName"] = site_name_map[sid_val]
|
||||
|
||||
return {"items": all_items}
|
||||
|
||||
|
||||
# ── GET /api/tenant/customers/{member_id}/clues ───────────
|
||||
|
||||
|
||||
@router.get("/customers/{member_id}/clues")
|
||||
async def list_customer_clues(
|
||||
member_id: int,
|
||||
source: Optional[str] = Query(None, description="按来源筛选:manual/ai_consumption/ai_note"),
|
||||
is_hidden: Optional[bool] = Query(None, description="按隐藏状态筛选"),
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""返回该客户在管辖门店范围内的全部线索,支持 source 和 is_hidden 筛选。"""
|
||||
site_sql, site_params = site_filter_clause(admin=admin)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
where_parts = [f"{site_sql}", "member_id = %s"]
|
||||
params: list = list(site_params) + [member_id]
|
||||
|
||||
if source is not None:
|
||||
where_parts.append("source = %s")
|
||||
params.append(source)
|
||||
|
||||
if is_hidden is not None:
|
||||
where_parts.append("is_hidden = %s")
|
||||
params.append(is_hidden)
|
||||
|
||||
where_clause = " AND ".join(where_parts)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, category, summary, detail,
|
||||
recorded_by_name, source, recorded_at::text, is_hidden
|
||||
FROM public.member_retention_clue
|
||||
WHERE {where_clause}
|
||||
ORDER BY recorded_at DESC
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
ClueListItem(
|
||||
id=r[0], category=r[1], summary=r[2], detail=r[3],
|
||||
recorded_by_name=r[4], source=r[5], recorded_at=r[6], is_hidden=r[7],
|
||||
).model_dump(by_alias=True)
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── PATCH /api/tenant/clues/{id} ──────────────────────────
|
||||
|
||||
|
||||
@router.patch("/clues/{clue_id}")
|
||||
async def edit_clue(
|
||||
clue_id: int,
|
||||
body: ClueEditRequest,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""编辑线索 category/summary/detail。校验 category 枚举和 summary 长度。"""
|
||||
# 先校验线索存在且在管辖范围内
|
||||
_get_clue_with_site_check(clue_id, admin)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE public.member_retention_clue
|
||||
SET category = %s, summary = %s, detail = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(body.category.value, body.summary, body.detail, clue_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.error("编辑线索失败(clue_id=%s)", clue_id, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="编辑操作失败")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
# ── DELETE /api/tenant/clues/{id} ─────────────────────────
|
||||
|
||||
|
||||
@router.delete("/clues/{clue_id}")
|
||||
async def delete_clue(
|
||||
clue_id: int,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""物理删除线索。线索不存在或不在管辖范围返回 404。"""
|
||||
_get_clue_with_site_check(clue_id, admin)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM public.member_retention_clue WHERE id = %s",
|
||||
(clue_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.error("删除线索失败(clue_id=%s)", clue_id, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="删除操作失败")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ── PATCH /api/tenant/clues/{id}/visibility ───────────────
|
||||
|
||||
|
||||
@router.patch("/clues/{clue_id}/visibility")
|
||||
async def toggle_clue_visibility(
|
||||
clue_id: int,
|
||||
body: ClueVisibilityRequest,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""切换线索 is_hidden 状态。"""
|
||||
_get_clue_with_site_check(clue_id, admin)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE public.member_retention_clue
|
||||
SET is_hidden = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(body.is_hidden, clue_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.error("切换线索可见性失败(clue_id=%s)", clue_id, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"message": "更新成功"}
|
||||
996
apps/backend/app/routers/tenant_excel.py
Normal file
996
apps/backend/app/routers/tenant_excel.py
Normal file
@@ -0,0 +1,996 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
租户管理后台 — Excel 上传/校验/冲突/写入路由。
|
||||
|
||||
端点清单:
|
||||
- POST /api/tenant/excel/upload — 上传解析 + 格式校验 + 人员匹配 + 冲突检测
|
||||
- POST /api/tenant/excel/confirm — 确认写入(单事务)
|
||||
- GET /api/tenant/excel/logs — 上传记录列表(分页)
|
||||
- GET /api/tenant/excel/template/{type} — 下载空白 Excel 模板
|
||||
|
||||
需求: 5.1-5.5, 6.1-6.5, 7.1-7.5, 8.1-8.5
|
||||
|
||||
AI_CHANGELOG
|
||||
- 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:两个 verify_site_access 改用 admin=admin;list_upload_logs 的 site_filter_clause 改用 admin=admin | Verify:Excel 上传/确认/日志覆盖新建店铺
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.tenant_admins import (
|
||||
CurrentTenantAdmin,
|
||||
require_tenant_admin,
|
||||
site_filter_clause,
|
||||
verify_site_access,
|
||||
)
|
||||
from app.database import get_connection, get_etl_readonly_connection
|
||||
from app.schemas.tenant_excel import (
|
||||
ConfirmRequest,
|
||||
ConflictDiff,
|
||||
FieldDiff,
|
||||
UploadLogItem,
|
||||
ValidationError as VError,
|
||||
ValidationResult,
|
||||
ValidationWarning as VWarning,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tenant/excel", tags=["租户Excel上传"])
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────
|
||||
|
||||
VALID_UPLOAD_TYPES = {"expense", "platform_income", "salary_adj", "recharge_commission"}
|
||||
|
||||
EXPENSE_CATEGORIES = [
|
||||
"房租", "水电", "物业", "食品饮料进货", "耗材", "报销", "固定人员工资", "其他费用",
|
||||
]
|
||||
|
||||
SALARY_ADJ_TYPES = {"扣款": "deduction", "奖金": "bonus"}
|
||||
|
||||
# 模板列定义(中文表头 → 内部字段名)
|
||||
TEMPLATE_COLUMNS: dict[str, list[tuple[str, str]]] = {
|
||||
"expense": [
|
||||
("月份", "expense_month"),
|
||||
("支出类别", "category"),
|
||||
("金额", "amount"),
|
||||
("备注", "remark"),
|
||||
],
|
||||
"platform_income": [
|
||||
("月份", "income_month"),
|
||||
("平台名称", "platform_name"),
|
||||
("金额", "amount"),
|
||||
("备注", "remark"),
|
||||
],
|
||||
"salary_adj": [
|
||||
("月份", "salary_month"),
|
||||
("助教姓名", "assistant_name"),
|
||||
("助教编号", "assistant_number"),
|
||||
("类型", "adjustment_type"),
|
||||
("金额", "amount"),
|
||||
("原因", "reason"),
|
||||
],
|
||||
"recharge_commission": [
|
||||
("充值日期", "recharge_date"),
|
||||
("会员名称", "member_name"),
|
||||
("充值金额", "recharge_amount"),
|
||||
("归属助教", "assigned_assistant"),
|
||||
("奖励金额", "reward_amount"),
|
||||
],
|
||||
}
|
||||
|
||||
# 冲突检测主键规则(不含 site_id,site_id 在查询时自动附加)
|
||||
CONFLICT_KEYS: dict[str, list[str]] = {
|
||||
"expense": ["expense_month", "category"],
|
||||
"platform_income": ["income_month", "platform_name"],
|
||||
"salary_adj": ["salary_month", "assistant_name", "assistant_number", "adjustment_type", "reason"],
|
||||
"recharge_commission": ["recharge_date", "member_name", "assigned_assistant"],
|
||||
}
|
||||
|
||||
# 目标表映射
|
||||
TARGET_TABLES: dict[str, str] = {
|
||||
"expense": "biz.stg_finance_expense",
|
||||
"platform_income": "biz.stg_platform_income",
|
||||
"salary_adj": "biz.salary_adjustments",
|
||||
"recharge_commission": "biz.stg_recharge_commission",
|
||||
}
|
||||
|
||||
# 各表写入字段(不含 id, upload_batch_id, created_at, synced_at 等自动字段)
|
||||
TABLE_WRITE_FIELDS: dict[str, list[str]] = {
|
||||
"expense": ["site_id", "expense_month", "category", "amount", "remark", "upload_batch_id", "created_at"],
|
||||
"platform_income": ["site_id", "income_month", "platform_name", "amount", "remark", "upload_batch_id", "created_at"],
|
||||
"salary_adj": ["site_id", "assistant_id", "assistant_name", "assistant_number", "salary_month", "adjustment_type", "amount", "reason", "upload_batch_id", "created_at", "created_by"],
|
||||
"recharge_commission": ["site_id", "recharge_date", "member_name", "recharge_amount", "assigned_assistant", "reward_amount", "upload_batch_id", "created_at"],
|
||||
}
|
||||
|
||||
|
||||
# ── 校验工具函数 ──────────────────────────────────────────
|
||||
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||
_DATE_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$")
|
||||
|
||||
|
||||
def _validate_month(value: str, current_month: str | None = None) -> str | None:
|
||||
"""校验月份格式 YYYY-MM,返回错误描述或 None。"""
|
||||
if not value or not _MONTH_RE.match(str(value).strip()):
|
||||
return "月份格式应为 YYYY-MM"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_date(value: str) -> str | None:
|
||||
"""校验日期格式 YYYY-MM-DD。"""
|
||||
if not value or not _DATE_RE.match(str(value).strip()):
|
||||
return "日期格式应为 YYYY-MM-DD"
|
||||
# 额外验证日期合法性
|
||||
try:
|
||||
datetime.strptime(str(value).strip(), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return "无效的日期"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_positive_amount(value: Any) -> str | None:
|
||||
"""校验金额 > 0,精度 2 位小数。"""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return "金额必须为有效数字"
|
||||
if d <= 0:
|
||||
return "金额必须大于 0"
|
||||
if d.as_tuple().exponent is not None and abs(d.as_tuple().exponent) > 2:
|
||||
return "金额精度不能超过 2 位小数"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_non_negative_amount(value: Any) -> str | None:
|
||||
"""校验金额 ≥ 0,精度 2 位小数。"""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return "金额必须为有效数字"
|
||||
if d < 0:
|
||||
return "金额不能为负数"
|
||||
if d.as_tuple().exponent is not None and abs(d.as_tuple().exponent) > 2:
|
||||
return "金额精度不能超过 2 位小数"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_not_empty(value: Any, max_len: int | None = None) -> str | None:
|
||||
"""校验非空字符串,可选最大长度。"""
|
||||
s = str(value).strip() if value is not None else ""
|
||||
if not s:
|
||||
return "不能为空"
|
||||
if max_len and len(s) > max_len:
|
||||
return f"长度不能超过 {max_len} 字符"
|
||||
return None
|
||||
|
||||
|
||||
def validate_rows(upload_type: str, rows: list[dict]) -> tuple[list[VError], list[dict]]:
|
||||
"""
|
||||
按模板类型校验数据行。
|
||||
|
||||
返回 (errors, passed_rows)。
|
||||
passed_rows 中的字段值已做类型转换(如金额转 float)。
|
||||
"""
|
||||
errors: list[VError] = []
|
||||
passed: list[dict] = []
|
||||
|
||||
for row in rows:
|
||||
row_idx = row.get("row_index", 0)
|
||||
row_errors: list[VError] = []
|
||||
|
||||
if upload_type == "expense":
|
||||
_validate_expense_row(row, row_idx, row_errors)
|
||||
elif upload_type == "platform_income":
|
||||
_validate_platform_income_row(row, row_idx, row_errors)
|
||||
elif upload_type == "salary_adj":
|
||||
_validate_salary_adj_row(row, row_idx, row_errors)
|
||||
elif upload_type == "recharge_commission":
|
||||
_validate_recharge_commission_row(row, row_idx, row_errors)
|
||||
|
||||
if row_errors:
|
||||
errors.extend(row_errors)
|
||||
else:
|
||||
passed.append(row)
|
||||
|
||||
return errors, passed
|
||||
|
||||
|
||||
def _validate_expense_row(row: dict, row_idx: int, errors: list[VError]):
|
||||
"""校验财务支出行。"""
|
||||
# 月份
|
||||
err = _validate_month(row.get("expense_month", ""))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="月份", message=err))
|
||||
|
||||
# 支出类别
|
||||
cat = str(row.get("category", "")).strip()
|
||||
if cat not in EXPENSE_CATEGORIES:
|
||||
errors.append(VError(
|
||||
row_index=row_idx, column="支出类别",
|
||||
message=f"无效的支出类别,可选值:{'、'.join(EXPENSE_CATEGORIES)}",
|
||||
))
|
||||
|
||||
# 金额
|
||||
err = _validate_positive_amount(row.get("amount"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="金额", message=err))
|
||||
|
||||
# 备注(可选,最长 500)
|
||||
remark = row.get("remark")
|
||||
if remark is not None and str(remark).strip():
|
||||
if len(str(remark).strip()) > 500:
|
||||
errors.append(VError(row_index=row_idx, column="备注", message="备注长度不能超过 500 字符"))
|
||||
|
||||
|
||||
def _validate_platform_income_row(row: dict, row_idx: int, errors: list[VError]):
|
||||
"""校验团购收入行。"""
|
||||
err = _validate_month(row.get("income_month", ""))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="月份", message=err))
|
||||
|
||||
err = _validate_not_empty(row.get("platform_name"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="平台名称", message=err))
|
||||
|
||||
err = _validate_positive_amount(row.get("amount"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="金额", message=err))
|
||||
|
||||
remark = row.get("remark")
|
||||
if remark is not None and str(remark).strip():
|
||||
if len(str(remark).strip()) > 500:
|
||||
errors.append(VError(row_index=row_idx, column="备注", message="备注长度不能超过 500 字符"))
|
||||
|
||||
|
||||
def _validate_salary_adj_row(row: dict, row_idx: int, errors: list[VError]):
|
||||
"""校验助教奖罚行。"""
|
||||
err = _validate_month(row.get("salary_month", ""))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="月份", message=err))
|
||||
|
||||
err = _validate_not_empty(row.get("assistant_name"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="助教姓名", message=err))
|
||||
|
||||
err = _validate_not_empty(row.get("assistant_number"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="助教编号", message=err))
|
||||
|
||||
adj_type = str(row.get("adjustment_type", "")).strip()
|
||||
if adj_type not in SALARY_ADJ_TYPES:
|
||||
errors.append(VError(
|
||||
row_index=row_idx, column="类型",
|
||||
message=f"无效的类型,可选值:{'、'.join(SALARY_ADJ_TYPES.keys())}",
|
||||
))
|
||||
|
||||
err = _validate_positive_amount(row.get("amount"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="金额", message=err))
|
||||
|
||||
err = _validate_not_empty(row.get("reason"), max_len=200)
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="原因", message=err))
|
||||
|
||||
|
||||
def _validate_recharge_commission_row(row: dict, row_idx: int, errors: list[VError]):
|
||||
"""校验充值业绩归属行。"""
|
||||
err = _validate_date(row.get("recharge_date", ""))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="充值日期", message=err))
|
||||
|
||||
err = _validate_not_empty(row.get("member_name"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="会员名称", message=err))
|
||||
|
||||
err = _validate_positive_amount(row.get("recharge_amount"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="充值金额", message=err))
|
||||
|
||||
err = _validate_not_empty(row.get("assigned_assistant"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="归属助教", message=err))
|
||||
|
||||
err = _validate_non_negative_amount(row.get("reward_amount"))
|
||||
if err:
|
||||
errors.append(VError(row_index=row_idx, column="奖励金额", message=err))
|
||||
|
||||
|
||||
# ── Excel 解析 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_excel(file_bytes: bytes, upload_type: str) -> list[dict]:
|
||||
"""
|
||||
解析 Excel 文件,返回行数据列表。
|
||||
|
||||
每行为 dict,包含 row_index(从 1 开始)和各字段值。
|
||||
"""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
return []
|
||||
|
||||
columns = TEMPLATE_COLUMNS.get(upload_type, [])
|
||||
if not columns:
|
||||
return []
|
||||
|
||||
rows_data: list[dict] = []
|
||||
header_row = True
|
||||
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
if header_row:
|
||||
header_row = False
|
||||
continue # 跳过表头行
|
||||
|
||||
# 跳过全空行
|
||||
if all(cell is None or str(cell).strip() == "" for cell in row):
|
||||
continue
|
||||
|
||||
row_dict: dict[str, Any] = {"row_index": len(rows_data) + 1}
|
||||
for i, (_, field_name) in enumerate(columns):
|
||||
val = row[i] if i < len(row) else None
|
||||
# 将值转为字符串(保留 None)
|
||||
if val is not None:
|
||||
row_dict[field_name] = str(val).strip()
|
||||
else:
|
||||
row_dict[field_name] = ""
|
||||
|
||||
rows_data.append(row_dict)
|
||||
|
||||
wb.close()
|
||||
return rows_data
|
||||
|
||||
|
||||
# ── 人员匹配 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def match_personnel(
|
||||
rows: list[dict],
|
||||
site_id: int,
|
||||
upload_type: str,
|
||||
) -> list[VWarning]:
|
||||
"""
|
||||
对 salary_adj / recharge_commission 模板执行人员匹配校验。
|
||||
|
||||
优先 v_dim_assistant(nickname + assistant_number),
|
||||
未匹配再查 v_dim_staff + v_dim_staff_ex(name + staff_number)。
|
||||
匹配成功填充 assistant_id,失败标记 warning 不阻断。
|
||||
"""
|
||||
if upload_type not in ("salary_adj", "recharge_commission"):
|
||||
return []
|
||||
|
||||
warnings: list[VWarning] = []
|
||||
|
||||
# 提取需要匹配的姓名+编号对
|
||||
if upload_type == "salary_adj":
|
||||
name_field = "assistant_name"
|
||||
number_field = "assistant_number"
|
||||
else:
|
||||
name_field = "assigned_assistant"
|
||||
number_field = None # recharge_commission 没有编号字段
|
||||
|
||||
# 批量查询 v_dim_assistant
|
||||
assistant_map: dict[str, int] = {}
|
||||
staff_map: dict[str, int] = {}
|
||||
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
try:
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT assistant_id, nickname, number FROM fdw_etl.v_dim_assistant WHERE scd2_is_current = 1",
|
||||
)
|
||||
for aid, nickname, number in cur.fetchall():
|
||||
if nickname and number:
|
||||
assistant_map[f"{nickname}|{number}"] = aid
|
||||
if nickname:
|
||||
assistant_map[f"{nickname}|"] = aid
|
||||
finally:
|
||||
etl_conn.close()
|
||||
except Exception:
|
||||
logger.warning("v_dim_assistant 查询失败(site_id=%s)", site_id, exc_info=True)
|
||||
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
try:
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT staff_id, name, number FROM fdw_etl.v_dim_staff",
|
||||
)
|
||||
for sid, name, number in cur.fetchall():
|
||||
if name and number:
|
||||
staff_map[f"{name}|{number}"] = sid
|
||||
if name:
|
||||
staff_map[f"{name}|"] = sid
|
||||
finally:
|
||||
etl_conn.close()
|
||||
except Exception:
|
||||
logger.warning("v_dim_staff 查询失败(site_id=%s)", site_id, exc_info=True)
|
||||
|
||||
for row in rows:
|
||||
name = str(row.get(name_field, "")).strip()
|
||||
number = str(row.get(number_field, "")).strip() if number_field else ""
|
||||
row_idx = row.get("row_index", 0)
|
||||
|
||||
# 优先 v_dim_assistant 匹配
|
||||
key_full = f"{name}|{number}"
|
||||
key_name = f"{name}|"
|
||||
|
||||
matched_id = assistant_map.get(key_full) or assistant_map.get(key_name)
|
||||
if not matched_id:
|
||||
matched_id = staff_map.get(key_full) or staff_map.get(key_name)
|
||||
|
||||
if matched_id:
|
||||
row["assistant_id"] = matched_id
|
||||
else:
|
||||
row["assistant_id"] = None
|
||||
warnings.append(VWarning(
|
||||
row_index=row_idx,
|
||||
column="助教姓名",
|
||||
message=f"未匹配到助教/员工:{name}" + (f"(编号 {number})" if number else ""),
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
# ── 冲突检测 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def detect_conflicts(
|
||||
upload_type: str,
|
||||
rows: list[dict],
|
||||
site_id: int,
|
||||
) -> tuple[list[ConflictDiff], list[dict], list[dict]]:
|
||||
"""
|
||||
按模板主键规则检测冲突。
|
||||
|
||||
返回 (conflicts, new_rows, conflict_rows_with_existing)。
|
||||
- conflicts: 冲突 diff 列表
|
||||
- new_rows: 无冲突的新增行
|
||||
- conflict_rows_with_existing: 冲突行(附带已有数据用于 confirm 时 UPDATE)
|
||||
"""
|
||||
keys = CONFLICT_KEYS.get(upload_type, [])
|
||||
table = TARGET_TABLES.get(upload_type, "")
|
||||
if not keys or not table:
|
||||
return [], rows, []
|
||||
|
||||
# 查询已有数据
|
||||
existing_map: dict[tuple, dict] = {}
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
key_cols = ", ".join(keys)
|
||||
# 获取所有字段用于 diff
|
||||
cur.execute(f"SELECT * FROM {table} WHERE site_id = %s LIMIT 0", (site_id,))
|
||||
col_names = [desc[0] for desc in cur.description] if cur.description else []
|
||||
|
||||
cur.execute(
|
||||
f"SELECT * FROM {table} WHERE site_id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
for row_data in cur.fetchall():
|
||||
row_dict = dict(zip(col_names, row_data))
|
||||
pk = tuple(str(row_dict.get(k, "")).strip() for k in keys)
|
||||
existing_map[pk] = row_dict
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conflicts: list[ConflictDiff] = []
|
||||
new_rows: list[dict] = []
|
||||
conflict_rows: list[dict] = []
|
||||
|
||||
# 对 salary_adj 类型,需要将中文类型映射为英文
|
||||
for row in rows:
|
||||
pk_values = []
|
||||
for k in keys:
|
||||
val = str(row.get(k, "")).strip()
|
||||
# salary_adj 的 adjustment_type 需要映射
|
||||
if upload_type == "salary_adj" and k == "adjustment_type":
|
||||
val = SALARY_ADJ_TYPES.get(val, val)
|
||||
pk_values.append(val)
|
||||
pk = tuple(pk_values)
|
||||
|
||||
if pk in existing_map:
|
||||
existing = existing_map[pk]
|
||||
# 生成逐字段 diff
|
||||
field_diffs: list[FieldDiff] = []
|
||||
# 比较可变字段(排除主键和系统字段)
|
||||
compare_fields = _get_compare_fields(upload_type)
|
||||
for field_name, display_name in compare_fields:
|
||||
old_val = str(existing.get(field_name, "")) if existing.get(field_name) is not None else ""
|
||||
new_val = str(row.get(field_name, "")).strip()
|
||||
# salary_adj 的 adjustment_type 需要映射
|
||||
if upload_type == "salary_adj" and field_name == "adjustment_type":
|
||||
new_val = SALARY_ADJ_TYPES.get(new_val, new_val)
|
||||
if old_val != new_val:
|
||||
field_diffs.append(FieldDiff(
|
||||
field=display_name, old_value=old_val, new_value=new_val,
|
||||
))
|
||||
|
||||
if field_diffs:
|
||||
conflicts.append(ConflictDiff(
|
||||
row_index=row.get("row_index", 0),
|
||||
field_diffs=field_diffs,
|
||||
))
|
||||
row["_existing_id"] = existing.get("id")
|
||||
conflict_rows.append(row)
|
||||
else:
|
||||
# 主键匹配但所有字段相同,视为无变化,跳过
|
||||
conflict_rows.append(row)
|
||||
row["_existing_id"] = existing.get("id")
|
||||
else:
|
||||
new_rows.append(row)
|
||||
|
||||
return conflicts, new_rows, conflict_rows
|
||||
|
||||
|
||||
def _get_compare_fields(upload_type: str) -> list[tuple[str, str]]:
|
||||
"""获取用于 diff 比较的字段列表 [(db_field, display_name)]。"""
|
||||
if upload_type == "expense":
|
||||
return [("amount", "金额"), ("remark", "备注")]
|
||||
elif upload_type == "platform_income":
|
||||
return [("amount", "金额"), ("remark", "备注")]
|
||||
elif upload_type == "salary_adj":
|
||||
return [("amount", "金额"), ("reason", "原因")]
|
||||
elif upload_type == "recharge_commission":
|
||||
return [("recharge_amount", "充值金额"), ("reward_amount", "奖励金额")]
|
||||
return []
|
||||
|
||||
|
||||
# ── POST /api/tenant/excel/upload ─────────────────────────
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_excel(
|
||||
file: UploadFile = File(...),
|
||||
upload_type: str = Form(...),
|
||||
site_id: int = Form(...),
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""
|
||||
上传 Excel 文件:解析 → 格式校验 → 人员匹配 → 冲突检测。
|
||||
|
||||
返回 upload_id + 校验结果 + 冲突 diff。
|
||||
"""
|
||||
# 校验 upload_type
|
||||
if upload_type not in VALID_UPLOAD_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"无效的模板类型,可选值:{', '.join(VALID_UPLOAD_TYPES)}")
|
||||
|
||||
# 校验门店权限
|
||||
verify_site_access(site_id, admin=admin)
|
||||
|
||||
# 校验文件格式
|
||||
filename = file.filename or ""
|
||||
if not filename.lower().endswith((".xlsx", ".xls")):
|
||||
raise HTTPException(status_code=400, detail="请上传有效的 Excel 文件(.xlsx/.xls)")
|
||||
|
||||
# 读取文件内容
|
||||
file_bytes = await file.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
# 解析 Excel
|
||||
try:
|
||||
rows = parse_excel(file_bytes, upload_type)
|
||||
except Exception as e:
|
||||
logger.warning("Excel 解析失败:%s", e, exc_info=True)
|
||||
raise HTTPException(status_code=400, detail="Excel 文件解析失败,请检查文件格式")
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="Excel 文件中没有数据行")
|
||||
|
||||
# 格式校验
|
||||
errors, passed_rows = validate_rows(upload_type, rows)
|
||||
|
||||
# 如果有格式错误,直接返回(不创建 upload_log)
|
||||
if errors:
|
||||
return ValidationResult(
|
||||
errors=errors,
|
||||
warnings=[],
|
||||
passed_rows=[],
|
||||
upload_id=None,
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
# 人员匹配校验(仅 salary_adj / recharge_commission)
|
||||
warnings = match_personnel(passed_rows, site_id, upload_type)
|
||||
|
||||
# 冲突检测
|
||||
conflicts, new_rows, conflict_rows = detect_conflicts(upload_type, passed_rows, site_id)
|
||||
|
||||
# 创建 excel_upload_log 记录
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.excel_upload_log
|
||||
(site_id, upload_type, file_name, uploaded_by, row_count, conflict_count, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'pending')
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, upload_type, filename, admin.admin_id, len(passed_rows), len(conflicts)),
|
||||
)
|
||||
upload_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.error("创建 upload_log 失败", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建上传记录失败")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 将通过的行数据临时存储到 upload_log 的 error_detail 字段(JSON)
|
||||
# 用于 confirm 时读取(避免二次上传)
|
||||
_cache_upload_data(upload_id, {
|
||||
"upload_type": upload_type,
|
||||
"site_id": site_id,
|
||||
"new_rows": new_rows,
|
||||
"conflict_rows": conflict_rows,
|
||||
})
|
||||
|
||||
return {
|
||||
**ValidationResult(
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
passed_rows=passed_rows,
|
||||
upload_id=upload_id,
|
||||
).model_dump(by_alias=True),
|
||||
"conflicts": [c.model_dump(by_alias=True) for c in conflicts],
|
||||
}
|
||||
|
||||
|
||||
def _cache_upload_data(upload_id: int, data: dict):
|
||||
"""将上传数据缓存到 upload_log.error_detail(JSON),供 confirm 时使用。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 序列化时处理 Decimal 等特殊类型
|
||||
json_str = json.dumps(data, ensure_ascii=False, default=str)
|
||||
cur.execute(
|
||||
"UPDATE biz.excel_upload_log SET error_detail = %s::jsonb WHERE id = %s",
|
||||
(json_str, upload_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.warning("缓存上传数据失败(upload_id=%s)", upload_id, exc_info=True)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── POST /api/tenant/excel/confirm ────────────────────────
|
||||
|
||||
|
||||
@router.post("/confirm")
|
||||
async def confirm_upload(
|
||||
body: ConfirmRequest,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""
|
||||
确认写入:单事务写入目标表。
|
||||
|
||||
替换行执行 UPDATE,新增行执行 INSERT。
|
||||
写入失败回滚整批,log status=failed。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 获取 upload_log 记录
|
||||
cur.execute(
|
||||
"SELECT site_id, upload_type, status, error_detail FROM biz.excel_upload_log WHERE id = %s",
|
||||
(body.upload_id,),
|
||||
)
|
||||
log_row = cur.fetchone()
|
||||
if log_row is None:
|
||||
raise HTTPException(status_code=404, detail="上传记录不存在")
|
||||
|
||||
site_id, upload_type, log_status, cached_data = log_row
|
||||
|
||||
if log_status != "pending":
|
||||
raise HTTPException(status_code=409, detail="该上传批次已被处理")
|
||||
|
||||
verify_site_access(site_id, admin=admin)
|
||||
|
||||
# 从缓存中读取数据
|
||||
if not cached_data:
|
||||
raise HTTPException(status_code=400, detail="上传数据已过期,请重新上传")
|
||||
|
||||
if isinstance(cached_data, str):
|
||||
cached_data = json.loads(cached_data)
|
||||
|
||||
new_rows = cached_data.get("new_rows", [])
|
||||
conflict_rows = cached_data.get("conflict_rows", [])
|
||||
|
||||
# 构建 resolution 映射
|
||||
resolution_map: dict[int, str] = {}
|
||||
for r in body.resolutions:
|
||||
resolution_map[r.row_index] = r.action
|
||||
|
||||
table = TARGET_TABLES[upload_type]
|
||||
write_fields = TABLE_WRITE_FIELDS[upload_type]
|
||||
|
||||
inserted_count = 0
|
||||
updated_count = 0
|
||||
resolved_count = 0
|
||||
|
||||
# 写入新增行
|
||||
for row in new_rows:
|
||||
_insert_row(cur, table, write_fields, row, upload_type, site_id, body.upload_id, admin.admin_id)
|
||||
inserted_count += 1
|
||||
|
||||
# 处理冲突行
|
||||
for row in conflict_rows:
|
||||
row_idx = row.get("row_index", 0)
|
||||
action = resolution_map.get(row_idx, "keep")
|
||||
existing_id = row.get("_existing_id")
|
||||
|
||||
if action == "replace" and existing_id:
|
||||
_update_row(cur, table, write_fields, row, upload_type, existing_id, site_id, body.upload_id, admin.admin_id)
|
||||
updated_count += 1
|
||||
resolved_count += 1
|
||||
elif action == "keep":
|
||||
resolved_count += 1
|
||||
else:
|
||||
# 无 existing_id 的冲突行按新增处理
|
||||
_insert_row(cur, table, write_fields, row, upload_type, site_id, body.upload_id, admin.admin_id)
|
||||
inserted_count += 1
|
||||
|
||||
# 更新 upload_log
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.excel_upload_log
|
||||
SET status = 'confirmed',
|
||||
row_count = %s,
|
||||
resolved_count = %s,
|
||||
confirmed_at = NOW(),
|
||||
error_detail = NULL
|
||||
WHERE id = %s
|
||||
""",
|
||||
(inserted_count + updated_count, resolved_count, body.upload_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
# 记录失败状态
|
||||
_mark_upload_failed(body.upload_id, str(e))
|
||||
logger.error("写入失败(upload_id=%s)", body.upload_id, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="数据写入失败,已回滚整批")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"message": "写入成功",
|
||||
"inserted": inserted_count,
|
||||
"updated": updated_count,
|
||||
"resolved": resolved_count,
|
||||
}
|
||||
|
||||
|
||||
def _insert_row(cur, table: str, fields: list[str], row: dict, upload_type: str, site_id: int, upload_id: int, admin_id: int):
|
||||
"""插入一行数据到目标表。"""
|
||||
values = _build_row_values(fields, row, upload_type, site_id, upload_id, admin_id)
|
||||
placeholders = ", ".join(["%s"] * len(fields))
|
||||
cols = ", ".join(fields)
|
||||
cur.execute(f"INSERT INTO {table} ({cols}) VALUES ({placeholders})", tuple(values))
|
||||
|
||||
|
||||
def _update_row(cur, table: str, fields: list[str], row: dict, upload_type: str, existing_id: int, site_id: int, upload_id: int, admin_id: int):
|
||||
"""更新已有行。"""
|
||||
values = _build_row_values(fields, row, upload_type, site_id, upload_id, admin_id)
|
||||
set_parts = [f"{f} = %s" for f in fields]
|
||||
cur.execute(
|
||||
f"UPDATE {table} SET {', '.join(set_parts)} WHERE id = %s",
|
||||
(*values, existing_id),
|
||||
)
|
||||
|
||||
|
||||
def _build_row_values(fields: list[str], row: dict, upload_type: str, site_id: int, upload_id: int, admin_id: int) -> list:
|
||||
"""根据字段列表构建值列表。"""
|
||||
values = []
|
||||
for f in fields:
|
||||
if f == "site_id":
|
||||
values.append(site_id)
|
||||
elif f == "upload_batch_id":
|
||||
values.append(upload_id)
|
||||
elif f == "created_at":
|
||||
values.append(datetime.now(timezone.utc))
|
||||
elif f == "created_by":
|
||||
values.append(admin_id)
|
||||
elif f == "adjustment_type":
|
||||
# 中文 → 英文映射
|
||||
raw = str(row.get(f, "")).strip()
|
||||
values.append(SALARY_ADJ_TYPES.get(raw, raw))
|
||||
elif f in ("amount", "recharge_amount", "reward_amount"):
|
||||
try:
|
||||
values.append(float(row.get(f, 0)))
|
||||
except (ValueError, TypeError):
|
||||
values.append(0.0)
|
||||
elif f == "assistant_id":
|
||||
values.append(row.get("assistant_id"))
|
||||
else:
|
||||
values.append(str(row.get(f, "")).strip() if row.get(f) is not None else None)
|
||||
return values
|
||||
|
||||
|
||||
def _mark_upload_failed(upload_id: int, error_msg: str):
|
||||
"""标记上传批次为失败状态。"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.excel_upload_log
|
||||
SET status = 'failed',
|
||||
error_detail = %s::jsonb
|
||||
WHERE id = %s
|
||||
""",
|
||||
(json.dumps({"error": error_msg}, ensure_ascii=False), upload_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.warning("标记上传失败状态失败(upload_id=%s)", upload_id, exc_info=True)
|
||||
|
||||
|
||||
# ── GET /api/tenant/excel/logs ────────────────────────────
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def list_upload_logs(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""上传记录列表,分页,附加 site_id IN 条件。"""
|
||||
site_sql, site_params = site_filter_clause(admin=admin)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM biz.excel_upload_log WHERE {site_sql}",
|
||||
site_params,
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, site_id, upload_type, file_name, uploaded_by,
|
||||
row_count, conflict_count, resolved_count, status,
|
||||
created_at::text, confirmed_at::text
|
||||
FROM biz.excel_upload_log
|
||||
WHERE {site_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*site_params, page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
UploadLogItem(
|
||||
id=r[0], site_id=r[1], upload_type=r[2], file_name=r[3],
|
||||
uploaded_by=r[4], row_count=r[5], conflict_count=r[6],
|
||||
resolved_count=r[7], status=r[8], created_at=r[9], confirmed_at=r[10],
|
||||
).model_dump(by_alias=True)
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items, "total": total, "page": page, "pageSize": page_size}
|
||||
|
||||
|
||||
# ── GET /api/tenant/excel/template/{type} ─────────────────
|
||||
|
||||
|
||||
@router.get("/template/{template_type}")
|
||||
async def download_template(template_type: str):
|
||||
"""返回空白 Excel 模板文件(含表头和格式说明)。"""
|
||||
if template_type not in VALID_UPLOAD_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"无效的模板类型,可选值:{', '.join(VALID_UPLOAD_TYPES)}")
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "数据模板"
|
||||
|
||||
columns = TEMPLATE_COLUMNS[template_type]
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
|
||||
# 写入表头
|
||||
for col_idx, (header_name, _) in enumerate(columns, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=header_name)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
ws.column_dimensions[cell.column_letter].width = 18
|
||||
|
||||
# 写入格式说明行(第 2 行,灰色字体)
|
||||
hint_font = Font(color="808080", italic=True)
|
||||
hints = _get_template_hints(template_type)
|
||||
for col_idx, hint in enumerate(hints, 1):
|
||||
cell = ws.cell(row=2, column=col_idx, value=hint)
|
||||
cell.font = hint_font
|
||||
|
||||
# 输出为字节流
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
from urllib.parse import quote as _url_quote
|
||||
|
||||
filename_map = {
|
||||
"expense": "财务支出模板.xlsx",
|
||||
"platform_income": "团购收入模板.xlsx",
|
||||
"salary_adj": "助教奖罚模板.xlsx",
|
||||
"recharge_commission": "充值业绩归属模板.xlsx",
|
||||
}
|
||||
raw_name = filename_map.get(template_type, "template.xlsx")
|
||||
encoded_name = _url_quote(raw_name)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _get_template_hints(template_type: str) -> list[str]:
|
||||
"""获取模板格式说明。"""
|
||||
if template_type == "expense":
|
||||
return [
|
||||
"格式:YYYY-MM",
|
||||
f"可选值:{'、'.join(EXPENSE_CATEGORIES)}",
|
||||
"大于0,保留2位小数",
|
||||
"可选,最长500字符",
|
||||
]
|
||||
elif template_type == "platform_income":
|
||||
return [
|
||||
"格式:YYYY-MM",
|
||||
"必填",
|
||||
"大于0,保留2位小数",
|
||||
"可选,最长500字符",
|
||||
]
|
||||
elif template_type == "salary_adj":
|
||||
return [
|
||||
"格式:YYYY-MM",
|
||||
"必填",
|
||||
"必填",
|
||||
"可选值:扣款、奖金",
|
||||
"大于0,保留2位小数",
|
||||
"必填,最长200字符",
|
||||
]
|
||||
elif template_type == "recharge_commission":
|
||||
return [
|
||||
"格式:YYYY-MM-DD",
|
||||
"必填",
|
||||
"大于0,保留2位小数",
|
||||
"必填",
|
||||
"≥0,保留2位小数",
|
||||
]
|
||||
return []
|
||||
354
apps/backend/app/routers/tenant_site_admins.py
Normal file
354
apps/backend/app/routers/tenant_site_admins.py
Normal file
@@ -0,0 +1,354 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
租户管理后台 — 店铺管理员 CRUD 路由。
|
||||
|
||||
仅 admin_type='tenant_admin' 的管理员可调用。
|
||||
店铺管理员复用 auth.tenant_admins 表,admin_type='site_admin'。
|
||||
|
||||
端点清单:
|
||||
- GET /api/tenant/site-admins — 店铺管理员列表
|
||||
- POST /api/tenant/site-admins — 创建店铺管理员
|
||||
- PATCH /api/tenant/site-admins/{id} — 编辑店铺管理员
|
||||
- DELETE /api/tenant/site-admins/{id} — 软删除店铺管理员
|
||||
- POST /api/tenant/site-admins/{id}/reset-password — 重置密码
|
||||
|
||||
AI_CHANGELOG
|
||||
- 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:create_site_admin 和 edit_site_admin 的权限子集校验改用 get_effective_site_ids(admin)(覆盖新建店铺)| Verify:创建/编辑店铺管理员时可选新建店铺
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from psycopg2 import errors as pg_errors
|
||||
from pydantic import Field
|
||||
|
||||
from app.auth.jwt import hash_password
|
||||
from app.auth.tenant_admins import CurrentTenantAdmin, get_effective_site_ids, require_tenant_admin
|
||||
from app.database import get_connection
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tenant", tags=["租户店铺管理员"])
|
||||
|
||||
|
||||
# ── 权限守卫:仅 tenant_admin 可调用 ─────────────────────
|
||||
|
||||
|
||||
def _require_tenant_admin_type(admin: CurrentTenantAdmin) -> CurrentTenantAdmin:
|
||||
"""校验当前登录者为租户管理员(非店铺管理员)。"""
|
||||
if admin.admin_type != "tenant_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅租户管理员可执行此操作",
|
||||
)
|
||||
return admin
|
||||
|
||||
|
||||
# ── Schema ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SiteAdminCreateRequest(CamelModel):
|
||||
"""创建店铺管理员请求。"""
|
||||
username: str = Field(..., max_length=56, description="用户名(site_code 前缀 + 最长 50 字符)")
|
||||
password: str = Field(..., min_length=6, description="初始密码")
|
||||
display_name: str | None = Field(None, max_length=100, description="显示名称")
|
||||
managed_site_ids: list[int] = Field(..., min_length=1, description="管辖门店 ID 列表")
|
||||
|
||||
|
||||
class SiteAdminEditRequest(CamelModel):
|
||||
"""编辑店铺管理员请求。"""
|
||||
display_name: str | None = Field(None, max_length=100)
|
||||
managed_site_ids: list[int] | None = Field(None, min_length=1)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class SiteAdminResetPasswordRequest(CamelModel):
|
||||
"""重置密码请求。"""
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
# ── GET /api/tenant/site-admins ───────────────────────────
|
||||
|
||||
|
||||
@router.get("/site-admins")
|
||||
async def list_site_admins(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
keyword: Optional[str] = Query(None, description="搜索用户名/显示名称"),
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""列出当前租户下的店铺管理员。"""
|
||||
_require_tenant_admin_type(admin)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
where_parts = [
|
||||
"ta.tenant_id = %s",
|
||||
"ta.admin_type = 'site_admin'",
|
||||
"ta.deleted_at IS NULL",
|
||||
]
|
||||
params: list = [admin.tenant_id]
|
||||
|
||||
if keyword:
|
||||
where_parts.append("(ta.username ILIKE %s OR ta.display_name ILIKE %s)")
|
||||
like_val = f"%{keyword}%"
|
||||
params.extend([like_val, like_val])
|
||||
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
cur.execute(f"SELECT COUNT(*) FROM auth.tenant_admins ta WHERE {where_sql}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ta.id, ta.username, ta.display_name, ta.managed_site_ids,
|
||||
ta.is_active, ta.created_at, ta.last_login_at
|
||||
FROM auth.tenant_admins ta
|
||||
WHERE {where_sql}
|
||||
ORDER BY ta.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r[0], "username": r[1], "displayName": r[2],
|
||||
"managedSiteIds": list(r[3]) if r[3] else [],
|
||||
"isActive": r[4],
|
||||
"createdAt": r[5].isoformat() if r[5] else None,
|
||||
"lastLoginAt": r[6].isoformat() if r[6] else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items, "total": total, "page": page, "pageSize": page_size}
|
||||
|
||||
|
||||
# ── POST /api/tenant/site-admins ──────────────────────────
|
||||
|
||||
|
||||
@router.post("/site-admins", status_code=status.HTTP_201_CREATED)
|
||||
async def create_site_admin(
|
||||
body: SiteAdminCreateRequest,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""
|
||||
创建店铺管理员。
|
||||
|
||||
用户名校验:必须以管辖店铺的 site_code 开头。
|
||||
managed_site_ids 必须是当前租户管理员管辖范围的子集。
|
||||
"""
|
||||
_require_tenant_admin_type(admin)
|
||||
|
||||
# 校验 managed_site_ids 是当前管理员有效管辖范围的子集
|
||||
# [CHANGE P20260323-210000] intent: 使用 get_effective_site_ids 替代 JWT managed_site_ids,
|
||||
# tenant_admin 按 tenant_id 查库获取有效范围(覆盖新建店铺)
|
||||
effective_ids = get_effective_site_ids(admin)
|
||||
if not set(body.managed_site_ids).issubset(set(effective_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="所选门店超出您的管辖范围",
|
||||
)
|
||||
|
||||
# 校验用户名以 site_code 开头
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询第一个管辖店铺的 site_code
|
||||
cur.execute(
|
||||
"SELECT site_code FROM biz.sites WHERE site_id = %s AND is_active = true",
|
||||
(body.managed_site_ids[0],),
|
||||
)
|
||||
site_row = cur.fetchone()
|
||||
if site_row is None or site_row[0] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="第一个管辖店铺未设置简写ID",
|
||||
)
|
||||
expected_prefix = site_row[0].upper()
|
||||
if not body.username.upper().startswith(expected_prefix):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"用户名必须以简写ID '{expected_prefix}' 开头",
|
||||
)
|
||||
|
||||
# 插入记录
|
||||
password_hash = hash_password(body.password)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.tenant_admins
|
||||
(username, password_hash, display_name, tenant_id,
|
||||
managed_site_ids, admin_type, created_by)
|
||||
VALUES (LOWER(%s), %s, %s, %s, %s, 'site_admin', %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
body.username,
|
||||
password_hash,
|
||||
body.display_name,
|
||||
admin.tenant_id,
|
||||
body.managed_site_ids,
|
||||
admin.admin_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except pg_errors.UniqueViolation:
|
||||
conn.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.error("创建店铺管理员失败", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建失败")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": row[0], "createdAt": row[1].isoformat() if row[1] else None}
|
||||
|
||||
|
||||
# ── PATCH /api/tenant/site-admins/{id} ────────────────────
|
||||
|
||||
|
||||
@router.patch("/site-admins/{admin_id}")
|
||||
async def edit_site_admin(
|
||||
admin_id: int,
|
||||
body: SiteAdminEditRequest,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""编辑店铺管理员(显示名称/管辖门店/启用状态)。"""
|
||||
_require_tenant_admin_type(admin)
|
||||
|
||||
if body.managed_site_ids is not None:
|
||||
effective_ids = get_effective_site_ids(admin)
|
||||
if not set(body.managed_site_ids).issubset(set(effective_ids)):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="所选门店超出您的管辖范围")
|
||||
|
||||
set_clauses: list[str] = []
|
||||
params: list = []
|
||||
if body.display_name is not None:
|
||||
set_clauses.append("display_name = %s")
|
||||
params.append(body.display_name)
|
||||
if body.managed_site_ids is not None:
|
||||
set_clauses.append("managed_site_ids = %s")
|
||||
params.append(body.managed_site_ids)
|
||||
if body.is_active is not None:
|
||||
set_clauses.append("is_active = %s")
|
||||
params.append(body.is_active)
|
||||
|
||||
if not set_clauses:
|
||||
raise HTTPException(status_code=422, detail="至少需要提供一个修改字段")
|
||||
|
||||
params.extend([admin_id, admin.tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE auth.tenant_admins
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
AND admin_type = 'site_admin' AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
params,
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise HTTPException(status_code=404, detail="店铺管理员不存在")
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
|
||||
|
||||
# ── DELETE /api/tenant/site-admins/{id} ───────────────────
|
||||
|
||||
|
||||
@router.delete("/site-admins/{admin_id}")
|
||||
async def delete_site_admin(
|
||||
admin_id: int,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""软删除店铺管理员。"""
|
||||
_require_tenant_admin_type(admin)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.tenant_admins SET deleted_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
AND admin_type = 'site_admin' AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_id, admin.tenant_id),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise HTTPException(status_code=404, detail="店铺管理员不存在")
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
|
||||
|
||||
# ── POST /api/tenant/site-admins/{id}/reset-password ──────
|
||||
|
||||
|
||||
@router.post("/site-admins/{admin_id}/reset-password")
|
||||
async def reset_site_admin_password(
|
||||
admin_id: int,
|
||||
body: SiteAdminResetPasswordRequest,
|
||||
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
|
||||
):
|
||||
"""重置店铺管理员密码。"""
|
||||
_require_tenant_admin_type(admin)
|
||||
|
||||
new_hash = hash_password(body.new_password)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.tenant_admins SET password_hash = %s
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
AND admin_type = 'site_admin' AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(new_hash, admin_id, admin.tenant_id),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise HTTPException(status_code=404, detail="店铺管理员不存在")
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
1154
apps/backend/app/routers/tenant_users.py
Normal file
1154
apps/backend/app/routers/tenant_users.py
Normal file
File diff suppressed because it is too large
Load Diff
154
apps/backend/app/routers/trigger_jobs.py
Normal file
154
apps/backend/app/routers/trigger_jobs.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""定时任务管理 API
|
||||
|
||||
提供 3 个端点:
|
||||
- GET /api/trigger-jobs — 列出所有定时任务
|
||||
- POST /api/trigger-jobs/{id}/run — 手动执行指定任务
|
||||
- PATCH /api/trigger-jobs/{id}/config — 编辑触发器配置
|
||||
|
||||
所有端点需要 JWT 认证(系统管理后台使用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
from app.schemas.trigger_jobs import TriggerJobItem, RunJobResult, UpdateTriggerConfigRequest
|
||||
from app.services.trigger_scheduler import list_trigger_jobs, run_job_by_id, _calculate_next_run
|
||||
from app.utils.cron_validator import validate_cron_expression
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trigger-jobs", tags=["定时任务"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TriggerJobItem])
|
||||
async def get_trigger_jobs(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[TriggerJobItem]:
|
||||
"""返回所有定时任务列表。"""
|
||||
try:
|
||||
jobs = list_trigger_jobs()
|
||||
return [TriggerJobItem(**j) for j in jobs]
|
||||
except Exception as exc:
|
||||
logger.exception("获取定时任务列表失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取定时任务列表失败: {str(exc)[:200]}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{job_id}/run", response_model=RunJobResult)
|
||||
async def run_trigger_job(
|
||||
job_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RunJobResult:
|
||||
"""手动执行指定定时任务。"""
|
||||
try:
|
||||
result = run_job_by_id(job_id)
|
||||
return RunJobResult(**result)
|
||||
except Exception as exc:
|
||||
logger.exception("手动执行任务 %s 失败", job_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"执行失败: {str(exc)[:200]}",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{job_id}/config", response_model=TriggerJobItem)
|
||||
async def update_trigger_config(
|
||||
job_id: int,
|
||||
body: UpdateTriggerConfigRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> TriggerJobItem:
|
||||
"""编辑触发器的 cron_expression 或 interval_seconds。
|
||||
|
||||
仅 merge 请求中非 None 的字段到 trigger_config JSONB,
|
||||
不覆盖其他已有字段。更新后重新计算 next_run_at。
|
||||
"""
|
||||
# --- 校验 cron_expression 格式 ---
|
||||
if body.cron_expression is not None and not validate_cron_expression(body.cron_expression):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="cron 表达式格式无效,需要 5 字段格式",
|
||||
)
|
||||
|
||||
# --- 校验 interval_seconds >= 1 ---
|
||||
if body.interval_seconds is not None and body.interval_seconds < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="interval_seconds 必须 >= 1",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询 trigger_job 是否存在,同时获取当前 trigger_condition 和 trigger_config
|
||||
cur.execute(
|
||||
"SELECT trigger_condition, trigger_config FROM biz.trigger_jobs WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务 {job_id} 不存在",
|
||||
)
|
||||
|
||||
trigger_condition, current_config = row
|
||||
current_config = current_config or {}
|
||||
|
||||
# 构建 config_updates:仅包含非 None 字段
|
||||
config_updates: dict = {}
|
||||
if body.cron_expression is not None:
|
||||
config_updates["cron_expression"] = body.cron_expression
|
||||
if body.interval_seconds is not None:
|
||||
config_updates["interval_seconds"] = body.interval_seconds
|
||||
|
||||
# 合并后的 trigger_config 用于计算 next_run_at
|
||||
merged_config = {**current_config, **config_updates}
|
||||
next_run_at = _calculate_next_run(trigger_condition, merged_config)
|
||||
|
||||
# 使用 || 操作符 merge JSONB,避免覆盖其他字段
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET trigger_config = trigger_config || %s::jsonb,
|
||||
next_run_at = %s
|
||||
WHERE id = %s
|
||||
RETURNING id, job_type, job_name, trigger_condition, trigger_config,
|
||||
last_run_at, next_run_at, status, description, last_error, created_at
|
||||
""",
|
||||
(json.dumps(config_updates), next_run_at, job_id),
|
||||
)
|
||||
updated = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
col_names = [
|
||||
"id", "job_type", "job_name", "trigger_condition", "trigger_config",
|
||||
"last_run_at", "next_run_at", "status", "description", "last_error", "created_at",
|
||||
]
|
||||
result = dict(zip(col_names, updated))
|
||||
# 日期时间字段转字符串(psycopg2 返回 datetime 对象)
|
||||
for dt_field in ("last_run_at", "next_run_at", "created_at"):
|
||||
if result[dt_field] is not None:
|
||||
result[dt_field] = str(result[dt_field])
|
||||
|
||||
return TriggerJobItem(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("更新任务 %s 触发器配置失败", job_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新触发器配置失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +23,7 @@ router = APIRouter(prefix="/api/ai", tags=["小程序 AI 缓存"])
|
||||
|
||||
|
||||
@router.get("/cache/{cache_type}")
|
||||
@trace_service("查询 AI 缓存", "Get AI cache")
|
||||
async def get_ai_cache(
|
||||
cache_type: str,
|
||||
target_id: str = Query(..., description="目标 ID(member_id / assistant_id_member_id / 时间维度编码)"),
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# AI_CHANGELOG
|
||||
# | 日期 | Prompt | 变更 |
|
||||
# |------|--------|------|
|
||||
# | 2026-03-23 | P20260323-190012 禁用→移除+鉴权两层模型 | login/refresh 移除 disabled 403 拦截;disabled 签发受限令牌由前端路由;cancel-application 接口;角色列表更新 |
|
||||
# | 2026-03-23 | 角色路由+页面权限守卫 | /api/xcx/me、/api/xcx/login、/api/xcx/dev-login 返回用户角色 |
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序认证路由 —— 微信登录、申请提交、状态查询、店铺切换、令牌刷新。
|
||||
@@ -37,17 +43,20 @@ from app.auth.jwt import (
|
||||
from app import config
|
||||
from app.database import get_connection
|
||||
from app.services.application import (
|
||||
cancel_application,
|
||||
create_application,
|
||||
get_user_applications,
|
||||
)
|
||||
from app.schemas.xcx_auth import (
|
||||
ApplicationRequest,
|
||||
ApplicationResponse,
|
||||
CancelApplicationResponse,
|
||||
DevLoginRequest,
|
||||
DevSwitchBindingRequest,
|
||||
DevSwitchRoleRequest,
|
||||
DevSwitchStatusRequest,
|
||||
DevContextResponse,
|
||||
LatestApplicationDetail,
|
||||
RefreshTokenRequest,
|
||||
SiteInfo,
|
||||
SwitchSiteRequest,
|
||||
@@ -57,6 +66,7 @@ from app.schemas.xcx_auth import (
|
||||
)
|
||||
from app.services.wechat import WeChatAuthError, code2session
|
||||
from app.services.role import get_user_permissions
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,6 +84,7 @@ def _get_user_roles_at_site(conn, user_id: int, site_id: int) -> list[str]:
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
WHERE usr.user_id = %s AND usr.site_id = %s
|
||||
AND usr.is_removed = false
|
||||
""",
|
||||
(user_id, site_id),
|
||||
)
|
||||
@@ -88,6 +99,7 @@ def _get_user_default_site(conn, user_id: int) -> int | None:
|
||||
SELECT DISTINCT site_id
|
||||
FROM auth.user_site_roles
|
||||
WHERE user_id = %s
|
||||
AND is_removed = false
|
||||
ORDER BY site_id
|
||||
LIMIT 1
|
||||
""",
|
||||
@@ -100,12 +112,13 @@ def _get_user_default_site(conn, user_id: int) -> int | None:
|
||||
# ── POST /api/xcx/login ──────────────────────────────────
|
||||
|
||||
@router.post("/login", response_model=WxLoginResponse)
|
||||
@trace_service("微信登录", "WeChat login")
|
||||
async def wx_login(body: WxLoginRequest):
|
||||
"""
|
||||
微信登录。
|
||||
|
||||
流程:code → code2session(openid) → 查找/创建 auth.users → 签发 JWT。
|
||||
- disabled 用户返回 403
|
||||
- disabled 用户签发受限令牌,由前端状态路由处理
|
||||
- 新用户自动创建(status=new),前端引导至申请页
|
||||
- approved 用户签发包含 site_id + roles 的完整令牌
|
||||
- new/pending/rejected 用户签发受限令牌
|
||||
@@ -157,23 +170,38 @@ async def wx_login(body: WxLoginRequest):
|
||||
(openid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
else:
|
||||
# CHANGE 2026-03-22 | #8: 已有用户登录时更新 wx_union_id(幂等保护)
|
||||
# intent: unionid 可能在首次登录时为空(未绑定开放平台),后续登录补全
|
||||
if unionid:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.users
|
||||
SET wx_union_id = %s
|
||||
WHERE id = %s
|
||||
AND (wx_union_id IS NULL OR wx_union_id <> %s)
|
||||
""",
|
||||
(unionid, row[0], unionid),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
conn.commit()
|
||||
|
||||
user_id, user_status = row
|
||||
|
||||
# 3. disabled 用户拒绝登录
|
||||
if user_status == "disabled":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
# CHANGE 2026-03-23 | disabled 不再拒绝登录
|
||||
# 第一层(微信身份)始终有效,disabled 只影响第二层(业务状态路由)
|
||||
# disabled/new/pending/rejected 统一签发受限令牌,由前端状态路由处理
|
||||
|
||||
# 4. 签发令牌
|
||||
# CHANGE 2026-03-23 | 角色路由:登录时查询角色并返回
|
||||
login_role: str | None = None
|
||||
if user_status == "approved":
|
||||
# 查找默认 site_id 和角色
|
||||
default_site_id = _get_user_default_site(conn, user_id)
|
||||
if default_site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
||||
login_role = roles[0] if roles else None
|
||||
else:
|
||||
# approved 但无 site 绑定(异常边界),签发受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
@@ -190,12 +218,14 @@ async def wx_login(body: WxLoginRequest):
|
||||
token_type=tokens["token_type"],
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
role=login_role,
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/xcx/apply ──────────────────────────────────
|
||||
|
||||
@router.post("/apply", response_model=ApplicationResponse)
|
||||
@trace_service("提交入驻申请", "Submit application")
|
||||
async def submit_application(
|
||||
body: ApplicationRequest,
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
@@ -217,9 +247,27 @@ async def submit_application(
|
||||
return ApplicationResponse(**result)
|
||||
|
||||
|
||||
# ── POST /api/xcx/cancel-application ─────────────────────
|
||||
|
||||
@router.post("/cancel-application", response_model=CancelApplicationResponse)
|
||||
@trace_service("取消申请", "Cancel application")
|
||||
async def cancel_my_application(
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
用户主动取消当前 pending 申请。
|
||||
|
||||
将申请 status 改为 cancelled,用户 status 回退 new。
|
||||
返回被取消申请的信息(用于前端预填重新申请表单)。
|
||||
"""
|
||||
result = await cancel_application(user_id=user.user_id)
|
||||
return CancelApplicationResponse(**result)
|
||||
|
||||
|
||||
# ── GET /api/xcx/me ───────────────────────────────────────
|
||||
|
||||
@router.get("/me", response_model=UserStatusResponse)
|
||||
@trace_service("查询自身状态", "Get my status")
|
||||
async def get_my_status(
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
@@ -232,8 +280,9 @@ async def get_my_status(
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询用户基本信息
|
||||
# CHANGE 2026-03-24 | 头像:新增 avatar_url 字段查询
|
||||
cur.execute(
|
||||
"SELECT id, status, nickname FROM auth.users WHERE id = %s",
|
||||
"SELECT id, status, nickname, avatar_url FROM auth.users WHERE id = %s",
|
||||
(user.user_id,),
|
||||
)
|
||||
user_row = cur.fetchone()
|
||||
@@ -243,25 +292,110 @@ async def get_my_status(
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
user_id, user_status, nickname = user_row
|
||||
user_id, user_status, nickname, avatar_url = user_row
|
||||
|
||||
# CHANGE 2026-03-23 | 角色路由:approved 用户查询当前门店角色
|
||||
role: str | None = None
|
||||
store_name: str | None = None
|
||||
coach_level: str | None = None
|
||||
if user_status == "approved":
|
||||
site_id = getattr(user, "site_id", None)
|
||||
# CHANGE 2026-03-24 | 受限 token 兼容:token 无 site_id 时从数据库查默认 site
|
||||
# 场景:用户从 pending→approved,旧的受限 token 不含 site_id
|
||||
if not site_id:
|
||||
site_id = _get_user_default_site(conn, user_id)
|
||||
if site_id:
|
||||
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
||||
# 用户在一个门店下仅一个角色
|
||||
role = roles[0] if roles else None
|
||||
# CHANGE 2026-03-23 | banner 数据修复:查询门店名
|
||||
cur.execute(
|
||||
"SELECT site_name FROM biz.sites WHERE site_id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
sn_row = cur.fetchone()
|
||||
store_name = sn_row[0] if sn_row else None
|
||||
# CHANGE 2026-03-23 | banner 数据修复:查询助教等级(coach_level)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE user_id = %s AND site_id = %s AND assistant_id IS NOT NULL
|
||||
AND is_removed = false
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, site_id),
|
||||
)
|
||||
bind_row = cur.fetchone()
|
||||
if bind_row:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
from app.services import fdw_queries
|
||||
_now = _dt.now()
|
||||
# CHANGE 2026-03-24 | coach_level 回退链:salary_calc → monthly_summary
|
||||
# salary_calc 月初结算前可能无数据,monthly_summary 每日更新更可靠
|
||||
salary = fdw_queries.get_salary_calc(
|
||||
conn, site_id, bind_row[0], _now.year, _now.month,
|
||||
)
|
||||
if salary:
|
||||
coach_level = salary.get("coach_level") or None
|
||||
if not coach_level:
|
||||
ms = fdw_queries.get_monthly_summary(
|
||||
conn, site_id, bind_row[0], _now.year, _now.month,
|
||||
)
|
||||
if ms:
|
||||
coach_level = ms.get("coach_level") or None
|
||||
except Exception:
|
||||
pass # 优雅降级:FDW 查询失败不影响主流程
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 委托 service 查询申请列表
|
||||
# CHANGE 2026-03-27 | 权限改造 W2:查询权限码列表
|
||||
# get_user_permissions 内部自行获取连接,无需外部 conn
|
||||
permissions: list[str] = []
|
||||
if user_status == "approved" and role:
|
||||
_perm_site_id = getattr(user, "site_id", None) or site_id
|
||||
if _perm_site_id:
|
||||
permissions = await get_user_permissions(user_id, _perm_site_id)
|
||||
|
||||
# 委托 service 查询申请列表(排除 cancelled)
|
||||
app_list = await get_user_applications(user_id)
|
||||
applications = [ApplicationResponse(**a) for a in app_list]
|
||||
applications = [ApplicationResponse(**a) for a in app_list if a["status"] != "cancelled"]
|
||||
|
||||
# 最新申请(含 phone/employee_number,用于前端展示和预填)
|
||||
latest = None
|
||||
if app_list:
|
||||
la = app_list[0] # 已按 created_at DESC 排序
|
||||
latest = LatestApplicationDetail(
|
||||
id=la["id"],
|
||||
site_code=la["site_code"],
|
||||
applied_role_text=la["applied_role_text"],
|
||||
phone=la.get("phone", ""),
|
||||
employee_number=la.get("employee_number"),
|
||||
status=la["status"],
|
||||
review_note=la.get("review_note"),
|
||||
created_at=la["created_at"],
|
||||
reviewed_at=la.get("reviewed_at"),
|
||||
)
|
||||
|
||||
return UserStatusResponse(
|
||||
user_id=user_id,
|
||||
status=user_status,
|
||||
nickname=nickname,
|
||||
avatar_url=avatar_url,
|
||||
role=role,
|
||||
permissions=permissions,
|
||||
store_name=store_name,
|
||||
coach_level=coach_level,
|
||||
applications=applications,
|
||||
latest_application=latest,
|
||||
)
|
||||
|
||||
|
||||
# ── GET /api/xcx/me/sites ────────────────────────────────
|
||||
|
||||
@router.get("/me/sites", response_model=list[SiteInfo])
|
||||
@trace_service("查询关联店铺", "Get my sites")
|
||||
async def get_my_sites(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
@@ -281,8 +415,9 @@ async def get_my_sites(
|
||||
r.name AS role_name
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
LEFT JOIN auth.site_code_mapping scm ON usr.site_id = scm.site_id
|
||||
LEFT JOIN biz.sites scm ON scm.site_id = usr.site_id
|
||||
WHERE usr.user_id = %s
|
||||
AND usr.is_removed = false
|
||||
ORDER BY usr.site_id, r.code
|
||||
""",
|
||||
(user.user_id,),
|
||||
@@ -306,6 +441,7 @@ async def get_my_sites(
|
||||
# ── POST /api/xcx/switch-site ────────────────────────────
|
||||
|
||||
@router.post("/switch-site", response_model=WxLoginResponse)
|
||||
@trace_service("切换当前店铺", "Switch site")
|
||||
async def switch_site(
|
||||
body: SwitchSiteRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
@@ -323,6 +459,7 @@ async def switch_site(
|
||||
"""
|
||||
SELECT 1 FROM auth.user_site_roles
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
AND is_removed = false
|
||||
LIMIT 1
|
||||
""",
|
||||
(user.user_id, body.site_id),
|
||||
@@ -360,13 +497,14 @@ async def switch_site(
|
||||
# ── POST /api/xcx/refresh ────────────────────────────────
|
||||
|
||||
@router.post("/refresh", response_model=WxLoginResponse)
|
||||
@trace_service("刷新令牌", "Refresh token")
|
||||
async def refresh_token(body: RefreshTokenRequest):
|
||||
"""
|
||||
刷新令牌。
|
||||
|
||||
解码 refresh_token → 根据用户当前状态签发新的令牌对。
|
||||
- 受限 refresh_token(limited=True)→ 签发新的受限令牌对
|
||||
- 完整 refresh_token → 签发新的完整令牌对(保持原 site_id)
|
||||
解码 refresh_token → 根据用户当前数据库状态签发新的令牌对。
|
||||
- approved 用户 → 签发完整令牌(即使旧 token 是受限的,也自动升级)
|
||||
- 其他状态 → 签发受限令牌
|
||||
"""
|
||||
try:
|
||||
payload = decode_refresh_token(body.refresh_token)
|
||||
@@ -396,26 +534,28 @@ async def refresh_token(body: RefreshTokenRequest):
|
||||
|
||||
_, user_status = user_row
|
||||
|
||||
if user_status == "disabled":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
if is_limited or user_status != "approved":
|
||||
# 受限令牌刷新 → 仍签发受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
# 完整令牌刷新 → 使用原 site_id 签发
|
||||
site_id = payload.get("site_id")
|
||||
if site_id is None:
|
||||
# 回退到默认 site
|
||||
# CHANGE 2026-03-23 | 令牌升级:根据数据库当前状态决定签发类型
|
||||
# 旧的受限 token 不应锁死用户——审核通过后 refresh 应自动升级为完整 token
|
||||
if user_status == "approved":
|
||||
# approved 用户:签发完整令牌(无论旧 token 是否 limited)
|
||||
if is_limited:
|
||||
# 受限 token 升级:查默认 site
|
||||
site_id = _get_user_default_site(conn, user_id)
|
||||
else:
|
||||
# 完整 token 刷新:优先保持原 site_id
|
||||
site_id = payload.get("site_id")
|
||||
if site_id is None:
|
||||
site_id = _get_user_default_site(conn, user_id)
|
||||
|
||||
if site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
||||
tokens = create_token_pair(user_id, site_id, roles=roles)
|
||||
else:
|
||||
# approved 但无 site 绑定(异常边界)
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
# new / pending / rejected / disabled → 受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -433,6 +573,7 @@ async def refresh_token(body: RefreshTokenRequest):
|
||||
if config.WX_DEV_MODE:
|
||||
|
||||
@router.post("/dev-login", response_model=WxLoginResponse)
|
||||
@trace_service("开发模式登录", "Dev mode login")
|
||||
async def dev_login(body: DevLoginRequest):
|
||||
"""
|
||||
开发模式 mock 登录。
|
||||
@@ -482,11 +623,14 @@ if config.WX_DEV_MODE:
|
||||
user_id, user_status = row
|
||||
|
||||
# 签发令牌(逻辑与正常登录一致)
|
||||
# CHANGE 2026-03-23 | 角色路由:dev-login 也返回角色
|
||||
dev_login_role: str | None = None
|
||||
if user_status == "approved":
|
||||
default_site_id = _get_user_default_site(conn, user_id)
|
||||
if default_site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
||||
dev_login_role = roles[0] if roles else None
|
||||
else:
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
@@ -501,11 +645,13 @@ if config.WX_DEV_MODE:
|
||||
token_type=tokens["token_type"],
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
role=dev_login_role,
|
||||
)
|
||||
|
||||
# ── GET /api/xcx/dev-context(仅开发模式) ────────────────
|
||||
|
||||
@router.get("/dev-context", response_model=DevContextResponse)
|
||||
@trace_service("查询调试上下文", "Get dev context")
|
||||
async def dev_context(
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
@@ -532,7 +678,7 @@ if config.WX_DEV_MODE:
|
||||
site_name = None
|
||||
if user.site_id:
|
||||
cur.execute(
|
||||
"SELECT site_name FROM auth.site_code_mapping WHERE site_id = %s",
|
||||
"SELECT site_name FROM biz.sites WHERE site_id = %s",
|
||||
(user.site_id,),
|
||||
)
|
||||
sn_row = cur.fetchone()
|
||||
@@ -552,6 +698,7 @@ if config.WX_DEV_MODE:
|
||||
SELECT assistant_id, staff_id, binding_type
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
AND is_removed = false
|
||||
LIMIT 1
|
||||
""",
|
||||
(user.user_id, user.site_id),
|
||||
@@ -572,8 +719,9 @@ if config.WX_DEV_MODE:
|
||||
r.code, r.name
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
LEFT JOIN auth.site_code_mapping scm ON usr.site_id = scm.site_id
|
||||
LEFT JOIN biz.sites scm ON scm.site_id = usr.site_id
|
||||
WHERE usr.user_id = %s
|
||||
AND usr.is_removed = false
|
||||
ORDER BY usr.site_id, r.code
|
||||
""",
|
||||
(user.user_id,),
|
||||
@@ -604,6 +752,7 @@ if config.WX_DEV_MODE:
|
||||
# ── POST /api/xcx/dev-switch-role(仅开发模式) ───────────
|
||||
|
||||
@router.post("/dev-switch-role", response_model=WxLoginResponse)
|
||||
@trace_service("切换角色", "Dev switch role")
|
||||
async def dev_switch_role(
|
||||
body: DevSwitchRoleRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
@@ -613,7 +762,8 @@ if config.WX_DEV_MODE:
|
||||
|
||||
删除旧角色绑定,插入新角色绑定,重签 token。
|
||||
"""
|
||||
valid_roles = ("coach", "staff", "site_admin", "tenant_admin")
|
||||
# CHANGE 2026-03-23 | 角色体系隔离:小程序端只有 4 个角色,site_admin/tenant_admin 已移至租户管理后台
|
||||
valid_roles = ("coach", "staff", "head_coach", "manager")
|
||||
if body.role_code not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -669,6 +819,7 @@ if config.WX_DEV_MODE:
|
||||
# ── POST /api/xcx/dev-switch-status(仅开发模式) ─────────
|
||||
|
||||
@router.post("/dev-switch-status", response_model=WxLoginResponse)
|
||||
@trace_service("切换用户状态", "Dev switch status")
|
||||
async def dev_switch_status(
|
||||
body: DevSwitchStatusRequest,
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
@@ -718,6 +869,7 @@ if config.WX_DEV_MODE:
|
||||
# ── POST /api/xcx/dev-switch-binding(仅开发模式) ────────
|
||||
|
||||
@router.post("/dev-switch-binding")
|
||||
@trace_service("切换人员绑定", "Dev switch binding")
|
||||
async def dev_switch_binding(
|
||||
body: DevSwitchBindingRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
|
||||
125
apps/backend/app/routers/xcx_avatar.py
Normal file
125
apps/backend/app/routers/xcx_avatar.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序头像上传路由。
|
||||
|
||||
端点清单:
|
||||
- POST /api/xcx/avatar/upload — 上传头像(chooseAvatar 临时文件 → 服务器持久化)
|
||||
- GET /api/xcx/avatar/{user_id} — 获取头像文件(静态文件服务)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app import config
|
||||
from app.auth.dependencies import CurrentUser, get_current_user_or_limited
|
||||
from app.database import get_connection
|
||||
from app.schemas.base import CamelModel
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/avatar", tags=["小程序头像"])
|
||||
|
||||
|
||||
def _get_avatar_dir() -> Path:
|
||||
"""获取头像存储目录,不存在则创建。"""
|
||||
avatar_path = config.AVATAR_EXPORT_PATH
|
||||
if not avatar_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AVATAR_EXPORT_PATH 未配置",
|
||||
)
|
||||
p = Path(avatar_path)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
class AvatarUploadResponse(CamelModel):
|
||||
"""头像上传响应。"""
|
||||
avatar_url: str
|
||||
|
||||
|
||||
# ── POST /api/xcx/avatar/upload ──────────────────────────
|
||||
|
||||
@router.post("/upload", response_model=AvatarUploadResponse)
|
||||
@trace_service("上传头像", "Upload avatar")
|
||||
async def upload_avatar(
|
||||
file: UploadFile = File(...),
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
接收小程序 chooseAvatar 上传的头像文件。
|
||||
|
||||
流程:
|
||||
1. 读取上传文件内容
|
||||
2. 保存到 AVATAR_EXPORT_PATH/{user_id}.jpg(覆盖式,幂等)
|
||||
3. 更新 auth.users.avatar_url
|
||||
4. 返回相对路径
|
||||
"""
|
||||
avatar_dir = _get_avatar_dir()
|
||||
# 固定 jpg 后缀,覆盖式保存
|
||||
filename = f"{user.user_id}.jpg"
|
||||
filepath = avatar_dir / filename
|
||||
relative_url = f"avatars/{filename}"
|
||||
|
||||
# 读取并保存文件
|
||||
content = await file.read()
|
||||
if len(content) == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="上传文件为空",
|
||||
)
|
||||
# 限制 2MB
|
||||
if len(content) > 2 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="头像文件不能超过 2MB",
|
||||
)
|
||||
|
||||
filepath.write_bytes(content)
|
||||
logger.info("头像已保存: user_id=%s, path=%s", user.user_id, filepath)
|
||||
|
||||
# 更新数据库
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET avatar_url = %s WHERE id = %s",
|
||||
(relative_url, user.user_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return AvatarUploadResponse(avatar_url=relative_url)
|
||||
|
||||
|
||||
# ── GET /api/xcx/avatar/{user_id} ────────────────────────
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_avatar(user_id: int):
|
||||
"""
|
||||
获取用户头像文件。
|
||||
|
||||
无需鉴权(头像为公开资源,通过 user_id 访问)。
|
||||
文件不存在时返回 404。
|
||||
"""
|
||||
avatar_dir = _get_avatar_dir()
|
||||
filepath = avatar_dir / f"{user_id}.jpg"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="头像不存在",
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(filepath),
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
@@ -27,25 +27,30 @@ from app.schemas.xcx_board import (
|
||||
SkillFilterEnum,
|
||||
)
|
||||
from app.services import board_service
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/board", tags=["xcx-board"])
|
||||
|
||||
|
||||
@router.get("/coaches", response_model=CoachBoardResponse)
|
||||
@trace_service("获取助教看板", "Get coach board")
|
||||
async def get_coach_board(
|
||||
sort: CoachSortEnum = Query(default=CoachSortEnum.perf_desc),
|
||||
skill: SkillFilterEnum = Query(default=SkillFilterEnum.ALL),
|
||||
time: BoardTimeEnum = Query(default=BoardTimeEnum.month),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
):
|
||||
"""助教看板(BOARD-1)。"""
|
||||
return await board_service.get_coach_board(
|
||||
sort=sort.value, skill=skill.value, time=time.value,
|
||||
site_id=user.site_id,
|
||||
page=page, page_size=page_size, site_id=user.site_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/customers", response_model=CustomerBoardResponse)
|
||||
@trace_service("获取客户看板", "Get customer board")
|
||||
async def get_customer_board(
|
||||
dimension: CustomerDimensionEnum = Query(default=CustomerDimensionEnum.recall),
|
||||
project: ProjectFilterEnum = Query(default=ProjectFilterEnum.ALL),
|
||||
@@ -65,6 +70,7 @@ async def get_customer_board(
|
||||
response_model=FinanceBoardResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
@trace_service("获取财务看板", "Get finance board")
|
||||
async def get_finance_board(
|
||||
time: FinanceTimeEnum = Query(default=FinanceTimeEnum.month),
|
||||
area: AreaFilterEnum = Query(default=AreaFilterEnum.all),
|
||||
|
||||
@@ -18,12 +18,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.config import AIConfig
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.database import get_connection
|
||||
from app.middleware.permission import require_approved
|
||||
@@ -39,6 +39,14 @@ from app.schemas.xcx_chat import (
|
||||
SendMessageResponse,
|
||||
)
|
||||
from app.services.chat_service import ChatService
|
||||
from app.trace.decorators import trace_service
|
||||
from app.trace.sse_wrapper import (
|
||||
record_ai_call,
|
||||
record_ai_error,
|
||||
record_sse_end,
|
||||
record_sse_start,
|
||||
record_sse_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +57,7 @@ router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
|
||||
|
||||
|
||||
@router.get("/history", response_model=ChatHistoryResponse)
|
||||
@trace_service("查询对话历史", "List chat history")
|
||||
async def list_chat_history(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
@@ -75,6 +84,7 @@ async def list_chat_history(
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ChatMessagesResponse)
|
||||
@trace_service("通过上下文查询消息", "Get messages by context")
|
||||
async def get_chat_messages_by_context(
|
||||
context_type: str = Query(..., alias="contextType"),
|
||||
context_id: str = Query(..., alias="contextId"),
|
||||
@@ -111,6 +121,7 @@ async def get_chat_messages_by_context(
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages", response_model=ChatMessagesResponse)
|
||||
@trace_service("查询对话消息", "Get chat messages")
|
||||
async def get_chat_messages(
|
||||
chat_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -140,6 +151,7 @@ async def get_chat_messages(
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
@trace_service("SSE 流式对话", "Chat stream SSE")
|
||||
async def chat_stream(
|
||||
body: ChatStreamRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
@@ -174,16 +186,74 @@ async def chat_stream(
|
||||
- event: done\\ndata: {"messageId": ..., "createdAt": "..."}\\n\\n
|
||||
- event: error\\ndata: {"message": "..."}\\n\\n
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
full_reply_parts: list[str] = []
|
||||
tokens_total = 0
|
||||
_sse_start_ts = _time.perf_counter()
|
||||
|
||||
# SSE trace: 流开始
|
||||
record_sse_start(
|
||||
endpoint="/api/xcx/chat/stream",
|
||||
user_id=user.user_id,
|
||||
chat_id=str(body.chat_id),
|
||||
)
|
||||
|
||||
try:
|
||||
bailian = _get_bailian_client()
|
||||
client = _get_dashscope_client()
|
||||
config = AIConfig.from_env()
|
||||
|
||||
# 获取历史消息作为上下文
|
||||
messages = _build_ai_messages(body.chat_id)
|
||||
# 构建 prompt(最近 20 条历史 + 当前消息已在历史中)
|
||||
prompt = _build_prompt(body.chat_id)
|
||||
|
||||
# 流式调用百炼 API
|
||||
async for chunk in bailian.chat_stream(messages):
|
||||
# 构建 biz_params(用户身份信息)
|
||||
biz_params = {
|
||||
"User_ID": str(user.user_id),
|
||||
"Role": getattr(user, "role", "coach"),
|
||||
"Nickname": getattr(user, "nickname", ""),
|
||||
}
|
||||
|
||||
# 看板入口:注入页面上下文到 prompt
|
||||
if body.source_page:
|
||||
try:
|
||||
from app.ai.data_fetchers import build_page_text
|
||||
|
||||
filters = {}
|
||||
if body.page_context:
|
||||
filters = body.page_context
|
||||
context_id = filters.pop("contextId", None)
|
||||
page_text = await build_page_text(
|
||||
source_page=body.source_page,
|
||||
context_id=context_id,
|
||||
site_id=user.site_id,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
if page_text:
|
||||
prompt = f"[页面上下文: {body.source_page}]\n{page_text}\n\n{prompt}"
|
||||
except Exception:
|
||||
logger.warning("页面上下文注入失败: source_page=%s", body.source_page, exc_info=True)
|
||||
|
||||
# 获取 session_id(对话复用)
|
||||
session_id = svc.get_session_id(body.chat_id) if hasattr(svc, "get_session_id") else None
|
||||
|
||||
# SSE trace: AI 调用
|
||||
record_ai_call(
|
||||
app_id=config.app_id_1_chat,
|
||||
prompt_length=len(prompt),
|
||||
session_id=session_id or "",
|
||||
)
|
||||
|
||||
# 流式调用 DashScope Application API
|
||||
async for chunk in client.call_app_stream(
|
||||
app_id=config.app_id_1_chat,
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
biz_params=biz_params,
|
||||
):
|
||||
full_reply_parts.append(chunk)
|
||||
tokens_total += 1
|
||||
# SSE trace: 每 10 个 token 记录一次
|
||||
record_sse_token(token_count=1, total_tokens=tokens_total)
|
||||
yield f"event: message\ndata: {json.dumps({'token': chunk}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 流结束:拼接完整回复并持久化
|
||||
@@ -202,9 +272,23 @@ async def chat_stream(
|
||||
)
|
||||
yield f"event: done\ndata: {done_data}\n\n"
|
||||
|
||||
# SSE trace: 流正常结束
|
||||
_sse_elapsed = (_time.perf_counter() - _sse_start_ts) * 1000
|
||||
record_sse_end(
|
||||
total_tokens=tokens_total,
|
||||
total_duration_ms=_sse_elapsed,
|
||||
completed=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SSE 流式对话异常: %s", e, exc_info=True)
|
||||
|
||||
# SSE trace: AI 错误
|
||||
record_ai_error(
|
||||
error_type=type(e).__name__,
|
||||
message=str(e),
|
||||
)
|
||||
|
||||
# 如果已有部分回复,仍然持久化
|
||||
if full_reply_parts:
|
||||
partial = "".join(full_reply_parts)
|
||||
@@ -220,6 +304,14 @@ async def chat_stream(
|
||||
)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
|
||||
# SSE trace: 流异常结束
|
||||
_sse_elapsed = (_time.perf_counter() - _sse_start_ts) * 1000
|
||||
record_sse_end(
|
||||
total_tokens=tokens_total,
|
||||
total_duration_ms=_sse_elapsed,
|
||||
completed=False,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
@@ -235,6 +327,7 @@ async def chat_stream(
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages", response_model=SendMessageResponse)
|
||||
@trace_service("发送消息", "Send message")
|
||||
async def send_message(
|
||||
chat_id: int,
|
||||
body: SendMessageRequest,
|
||||
@@ -280,27 +373,25 @@ def _to_message_item(msg: dict) -> ChatMessageItem:
|
||||
)
|
||||
|
||||
|
||||
def _get_bailian_client() -> BailianClient:
|
||||
"""从环境变量构建 BailianClient,缺失时报错。"""
|
||||
api_key = os.environ.get("BAILIAN_API_KEY")
|
||||
base_url = os.environ.get("BAILIAN_BASE_URL")
|
||||
model = os.environ.get("BAILIAN_MODEL")
|
||||
if not api_key or not base_url or not model:
|
||||
raise RuntimeError(
|
||||
"百炼 API 环境变量缺失,需要 BAILIAN_API_KEY、BAILIAN_BASE_URL、BAILIAN_MODEL"
|
||||
)
|
||||
return BailianClient(api_key=api_key, base_url=base_url, model=model)
|
||||
def _get_dashscope_client() -> DashScopeClient:
|
||||
"""从环境变量构建 DashScopeClient,缺失时报错。"""
|
||||
config = AIConfig.from_env()
|
||||
return DashScopeClient(api_key=config.api_key, workspace_id=config.workspace_id)
|
||||
|
||||
|
||||
def _build_ai_messages(chat_id: int) -> list[dict]:
|
||||
"""构建发送给 AI 的消息列表(含历史上下文)。"""
|
||||
def _build_prompt(chat_id: int) -> str:
|
||||
"""构建发送给 DashScope Application 的 prompt。
|
||||
|
||||
从 ai_messages 取最近 20 条历史,拼接为文本 prompt。
|
||||
百炼 Application API 的 System Prompt 在控制台配置,此处只传用户对话内容。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT role, content FROM biz.ai_messages
|
||||
WHERE conversation_id = %s
|
||||
WHERE conversation_id = %s AND role != 'system'
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(chat_id,),
|
||||
@@ -309,21 +400,22 @@ def _build_ai_messages(chat_id: int) -> list[dict]:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
messages: list[dict] = []
|
||||
# 取最近 20 条
|
||||
recent = history[-20:] if len(history) > 20 else history
|
||||
for role, msg_content in recent:
|
||||
messages.append({"role": role, "content": msg_content})
|
||||
|
||||
# 如果没有 system 消息,添加默认 system prompt
|
||||
if not messages or messages[0]["role"] != "system":
|
||||
system_prompt = {
|
||||
"role": "system",
|
||||
"content": json.dumps(
|
||||
{"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
messages.insert(0, system_prompt)
|
||||
# 如果只有一条(刚发送的用户消息),直接返回内容
|
||||
if len(recent) == 1:
|
||||
return recent[0][1]
|
||||
|
||||
return messages
|
||||
# 多条历史:拼接为对话格式,最后一条为当前用户消息
|
||||
parts: list[str] = []
|
||||
for role, msg_content in recent[:-1]:
|
||||
label = "用户" if role == "user" else "AI"
|
||||
parts.append(f"{label}: {msg_content}")
|
||||
|
||||
# 最后一条是当前用户消息,作为主 prompt
|
||||
current_msg = recent[-1][1] if recent else ""
|
||||
if parts:
|
||||
context = "\n".join(parts)
|
||||
return f"[历史对话]\n{context}\n\n[当前问题]\n{current_msg}"
|
||||
return current_msg
|
||||
|
||||
@@ -13,17 +13,20 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.xcx_coaches import CoachDetailResponse
|
||||
from app.services import coach_service
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/coaches", tags=["小程序助教"])
|
||||
|
||||
|
||||
@router.get("/{coach_id}", response_model=CoachDetailResponse)
|
||||
@trace_service("获取助教详情", "Get coach detail")
|
||||
async def get_coach_detail(
|
||||
coach_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
# CHANGE 2026-03-27 | 权限改造 W4:助教详情跟助教看板走
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
):
|
||||
"""助教详情(COACH-1)。"""
|
||||
return await coach_service.get_coach_detail(
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_config import SkillTypeItem
|
||||
from app.services import fdw_queries
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +22,7 @@ router = APIRouter(prefix="/api/xcx/config", tags=["xcx-config"])
|
||||
|
||||
|
||||
@router.get("/skill-types", response_model=list[SkillTypeItem])
|
||||
@trace_service("获取技能类型配置", "Get skill types config")
|
||||
async def get_skill_types(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
|
||||
@@ -14,20 +14,24 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.middleware.permission import require_approved, require_permission
|
||||
from app.schemas.xcx_customers import (
|
||||
CustomerDetailResponse,
|
||||
CustomerRecordsResponse,
|
||||
CustomerConsumptionRecordsResponse,
|
||||
)
|
||||
from app.services import customer_service
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/customers", tags=["小程序客户"])
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerDetailResponse)
|
||||
@trace_service("获取客户详情", "Get customer detail")
|
||||
async def get_customer_detail(
|
||||
customer_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
# CHANGE 2026-03-27 | 权限改造 W4:客户详情跟客户看板走
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
):
|
||||
"""客户详情(CUST-1)。"""
|
||||
return await customer_service.get_customer_detail(
|
||||
@@ -36,6 +40,7 @@ async def get_customer_detail(
|
||||
|
||||
|
||||
@router.get("/{customer_id}/records", response_model=CustomerRecordsResponse)
|
||||
@trace_service("获取客户服务记录", "Get customer records")
|
||||
async def get_customer_records(
|
||||
customer_id: int,
|
||||
year: int = Query(...),
|
||||
@@ -43,9 +48,24 @@ async def get_customer_records(
|
||||
table: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
):
|
||||
"""客户服务记录(CUST-2)。"""
|
||||
return await customer_service.get_customer_records(
|
||||
customer_id, user.site_id, year, month, table, page, page_size
|
||||
customer_id, user.site_id, user.user_id,
|
||||
year, month, table, page, page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{customer_id}/consumption-records", response_model=CustomerConsumptionRecordsResponse)
|
||||
@trace_service("获取客户消费记录", "Get customer consumption records")
|
||||
async def get_customer_consumption_records(
|
||||
customer_id: int,
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
):
|
||||
"""客户消费记录(CUST-3)。"""
|
||||
return await customer_service.get_customer_consumption_records(
|
||||
customer_id, user.site_id, year, month,
|
||||
)
|
||||
|
||||
@@ -18,11 +18,13 @@ from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_notes import NoteCreateRequest, NoteOut
|
||||
from app.services import note_service
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/notes", tags=["小程序备注"])
|
||||
|
||||
|
||||
@router.post("", response_model=NoteOut)
|
||||
@trace_service("创建备注", "Create note")
|
||||
async def create_note(
|
||||
body: NoteCreateRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
@@ -37,10 +39,12 @@ async def create_note(
|
||||
task_id=body.task_id,
|
||||
rating_service_willingness=body.rating_service_willingness,
|
||||
rating_revisit_likelihood=body.rating_revisit_likelihood,
|
||||
score=body.score,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@trace_service("查询备注列表", "Get notes")
|
||||
async def get_notes(
|
||||
target_type: str = Query("member", description="目标类型"),
|
||||
target_id: int = Query(..., description="目标 ID"),
|
||||
@@ -55,6 +59,7 @@ async def get_notes(
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
@trace_service("删除备注", "Delete note")
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
|
||||
@@ -14,21 +14,24 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.middleware.permission import require_approved, require_permission
|
||||
from app.schemas.xcx_performance import (
|
||||
PerformanceOverviewResponse,
|
||||
PerformanceRecordsResponse,
|
||||
)
|
||||
from app.services import performance_service
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])
|
||||
|
||||
|
||||
@router.get("", response_model=PerformanceOverviewResponse)
|
||||
@trace_service("获取绩效概览", "Get performance overview")
|
||||
async def get_performance_overview(
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
# CHANGE 2026-03-27 | 权限改造 W4:绩效跟任务走
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""绩效概览(PERF-1)。"""
|
||||
return await performance_service.get_overview(
|
||||
@@ -37,12 +40,13 @@ async def get_performance_overview(
|
||||
|
||||
|
||||
@router.get("/records", response_model=PerformanceRecordsResponse)
|
||||
@trace_service("获取绩效明细", "Get performance records")
|
||||
async def get_performance_records(
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""绩效明细(PERF-2)。"""
|
||||
return await performance_service.get_records(
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
小程序任务路由 —— 任务列表、任务详情、置顶、放弃、取消放弃。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/tasks — 获取任务列表 + 绩效概览(TASK-1)
|
||||
- GET /api/xcx/tasks/{task_id} — 获取任务详情完整版(TASK-2)
|
||||
- POST /api/xcx/tasks/{id}/pin — 置顶任务
|
||||
- POST /api/xcx/tasks/{id}/unpin — 取消置顶
|
||||
- POST /api/xcx/tasks/{id}/abandon — 放弃任务
|
||||
- POST /api/xcx/tasks/{id}/restore — 恢复任务
|
||||
- GET /api/xcx/tasks — 获取任务列表 + 绩效概览(TASK-1)
|
||||
- GET /api/xcx/tasks/by-member/{member_id} — 按会员查询最高优先级 active 任务详情
|
||||
- GET /api/xcx/tasks/{task_id} — 获取任务详情完整版(TASK-2)
|
||||
- POST /api/xcx/tasks/{id}/pin — 置顶任务
|
||||
- POST /api/xcx/tasks/{id}/unpin — 取消置顶
|
||||
- POST /api/xcx/tasks/{id}/abandon — 放弃任务
|
||||
- POST /api/xcx/tasks/{id}/restore — 恢复任务
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
@@ -18,23 +19,26 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.middleware.permission import require_approved, require_permission
|
||||
from app.schemas.xcx_tasks import (
|
||||
AbandonRequest,
|
||||
TaskDetailResponse,
|
||||
TaskListResponse,
|
||||
)
|
||||
from app.services import task_manager
|
||||
from app.trace.decorators import trace_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/tasks", tags=["小程序任务"])
|
||||
|
||||
|
||||
@router.get("", response_model=TaskListResponse)
|
||||
@trace_service("获取任务列表", "Get task list")
|
||||
async def get_tasks(
|
||||
status: str = Query("pending", pattern="^(pending|completed|abandoned)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
page_size: int = Query(20, ge=1, le=200),
|
||||
# CHANGE 2026-03-27 | 权限改造 W4:统一权限保护
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""获取任务列表 + 绩效概览。"""
|
||||
return await task_manager.get_task_list_v2(
|
||||
@@ -42,10 +46,23 @@ async def get_tasks(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-member/{member_id}", response_model=TaskDetailResponse)
|
||||
@trace_service("按会员查询任务详情", "Get task detail by member")
|
||||
async def get_task_by_member(
|
||||
member_id: int,
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""按 member_id 查询当前助教的最高优先级 active 任务详情。"""
|
||||
return await task_manager.get_task_by_member(
|
||||
member_id, user.user_id, user.site_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskDetailResponse)
|
||||
@trace_service("获取任务详情", "Get task detail")
|
||||
async def get_task_detail(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""获取任务详情完整版。"""
|
||||
return await task_manager.get_task_detail(
|
||||
@@ -54,9 +71,10 @@ async def get_task_detail(
|
||||
|
||||
|
||||
@router.post("/{task_id}/pin")
|
||||
@trace_service("置顶任务", "Pin task")
|
||||
async def pin_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""置顶任务。"""
|
||||
result = await task_manager.pin_task(task_id, user.user_id, user.site_id)
|
||||
@@ -64,9 +82,10 @@ async def pin_task(
|
||||
|
||||
|
||||
@router.post("/{task_id}/unpin")
|
||||
@trace_service("取消置顶", "Unpin task")
|
||||
async def unpin_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""取消置顶。"""
|
||||
result = await task_manager.unpin_task(task_id, user.user_id, user.site_id)
|
||||
@@ -74,10 +93,11 @@ async def unpin_task(
|
||||
|
||||
|
||||
@router.post("/{task_id}/abandon")
|
||||
@trace_service("放弃任务", "Abandon task")
|
||||
async def abandon_task(
|
||||
task_id: int,
|
||||
body: AbandonRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""放弃任务(需填写原因)。"""
|
||||
return await task_manager.abandon_task(
|
||||
@@ -86,9 +106,10 @@ async def abandon_task(
|
||||
|
||||
|
||||
@router.post("/{task_id}/restore")
|
||||
@trace_service("恢复任务", "Restore task")
|
||||
async def restore_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
user: CurrentUser = Depends(require_permission("view_tasks")),
|
||||
):
|
||||
"""取消放弃,恢复为活跃状态。"""
|
||||
return await task_manager.cancel_abandon(task_id, user.user_id, user.site_id)
|
||||
|
||||
Reference in New Issue
Block a user