feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本

包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -0,0 +1,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_jobis_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)

View 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 构建连接字符串时
# 会读取系统用户名/计算机名,含中文时触发 UnicodeDecodeError0xd6 是 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]

View 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()

View File

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

View 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()

View 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}

View File

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""管理端 — 触发器统一视图 API
提供 1 个端点:
- GET /api/admin/triggers/unified — 聚合三张表的触发器数据
数据源:
- biz.trigger_jobs业务触发器→ source="biz"
- biz.ai_trigger_jobsAI 事件链,最近 100 条)→ source="ai"
- public.scheduled_tasksETL 调度)→ 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 → nameai_trigger_jobs 无 job_name 列)
- 'event' → trigger_conditionAI 触发器均为事件驱动)
- status → status
- started_at → last_run_atai_trigger_jobs 无 last_run_at 列)
- None → next_run_at事件驱动无预定下次执行时间
- error_message → last_errorai_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 → idUUID转为字符串后取 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_errorscheduled_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:
# 数据源 1biz.trigger_jobs
try:
results.extend(_fetch_biz_triggers(conn))
except Exception:
logger.warning("查询 biz.trigger_jobs 失败", exc_info=True)
# 数据源 2biz.ai_trigger_jobs
try:
results.extend(_fetch_ai_triggers(conn))
except Exception:
logger.warning("查询 biz.ai_trigger_jobs 失败", exc_info=True)
# 数据源 3public.scheduled_tasks
try:
results.extend(_fetch_etl_triggers(conn))
except Exception:
logger.warning("查询 scheduled_tasks 失败", exc_info=True)
return results
finally:
conn.close()

View File

@@ -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_tokenrefresh_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,

View File

@@ -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()

View File

@@ -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

View 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

View 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,
)

View File

@@ -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()

View File

@@ -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})

View File

@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
"""
租户管理员认证路由:登录与令牌刷新。
- POST /api/tenant/auth/login — 用户名+密码验证,签发 JWTaud=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_tokenaud=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_tokenaud=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=false403
- 登录成功:更新 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_tokenaud=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="令牌类型不匹配",
)
# 验证 audjose 在 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",
)

View 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 causeJWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary_get_clue_with_site_check 签名改为接受 admin: CurrentTenantAdminsearch_customers 用 get_effective_site_idslist_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 查询 FDWRLS 要求逐个设置 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": "更新成功"}

View 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 causeJWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary两个 verify_site_access 改用 admin=adminlist_upload_logs 的 site_filter_clause 改用 admin=admin | VerifyExcel 上传/确认/日志覆盖新建店铺
"""
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_idsite_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_assistantnickname + assistant_number
未匹配再查 v_dim_staff + v_dim_staff_exname + 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_detailJSON供 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 []

View 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 causeJWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summarycreate_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}

File diff suppressed because it is too large Load Diff

View 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()

View File

@@ -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="目标 IDmember_id / assistant_id_member_id / 时间维度编码)"),

View File

@@ -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_tokenlimited=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),

View 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"},
)

View File

@@ -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),

View File

@@ -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

View File

@@ -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(

View File

@@ -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()),
):

View File

@@ -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,
)

View File

@@ -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()),

View File

@@ -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(

View File

@@ -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 — 恢复任务
所有端点均需 JWTapproved 状态)。
"""
@@ -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)