feat: 接入运维真实数据与三角色权限链路

- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
郑龙捷
2026-09-02 15:02:47 +08:00
parent 6d044784ec
commit 74dd97428f
51 changed files with 2142 additions and 1062 deletions
+5 -3
View File
@@ -16,10 +16,11 @@
仅将库名切换为 `model_operations`
- `PLATFORM_READ_DATABASE_URL``DEPLOY_DATABASE_URL` 使用独立只读
SessionMySQL 事务会显式执行 `SET TRANSACTION READ ONLY`
- 套 SQLAlchemy engine/session factory 完全分离,运维路由只读写 `ops_*`
- 套 SQLAlchemy engine/session factory 完全分离:模型、版本和银行基础信息从
`model_deploy` 只读查询,监控结果与运维业务数据从 `model_operations` 查询或写入。
- 当前已落地 P0 ORM,以及模型列表、模型详情、指定月份监控结果三条接口。
- `OPERATIONS_DATA_MODE=mock` 时仍走真实鉴权、Workspace校验和API响应,
仅将业务查询替换为后端Mock数据;切换为 `database` 后读取运维库
- 运维模型接口联查 `model_deploy``model_operations`;模型方写入部署/银行/版本
信息和监控结果后,列表、模型详情、工作台及指定月份监控结果由后端统一返回
- Redis作为可选加速层接入:缓存聚合查询并承载通知 Streams;MySQL
Outbox仍是可靠事实源,Redis不可用时主接口继续运行。
@@ -42,4 +43,5 @@ GET /api/v1/operations/health
GET /api/v1/operations/models?workspace_id=<ULID>
GET /api/v1/operations/models/{model_id}?workspace_id=<ULID>
GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM&workspace_id=<ULID>
GET /api/v1/operations/workbench?workspace_id=<ULID>
```
@@ -2,9 +2,11 @@ from fastapi import APIRouter
from backend.api.operations.health import router as health_router
from backend.api.operations.models import router as models_router
from backend.api.operations.workbench import router as workbench_router
router = APIRouter(prefix="/api/v1/operations", tags=["operations"])
router.include_router(health_router)
router.include_router(models_router)
router.include_router(workbench_router)
__all__ = ["router"]
+41 -1
View File
@@ -6,8 +6,9 @@ from typing import Literal
from common.config import settings
from common.db import readonly_session_scope, session_scope
from common.db.models import Roles
from common.db.models import Permissions, RolePermissions, Roles
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import RequestContext, request_context
@@ -32,7 +33,10 @@ class OperationsContext:
request_id: str
user_id: str
workspace_id: str
workspace_code: str
workspace_name: str
role: OperationsRole
permissions: frozenset[str]
@property
def is_admin(self) -> bool:
@@ -120,19 +124,55 @@ async def operations_context(
"message": f"当前登录角色不支持运维模块:{raw_role}",
},
)
role_id = context.user.platform_role_id or context.role.role_id
permission_rows = await platform_session.execute(
select(Permissions.permission_code)
.join(
RolePermissions,
RolePermissions.permission_id == Permissions.permission_id,
)
.where(
RolePermissions.role_id == role_id,
RolePermissions.is_deleted == 0,
Permissions.is_deleted == 0,
)
)
return OperationsContext(
request_id=context.request_id,
user_id=context.user.user_id,
workspace_id=context.workspace.workspace_id,
workspace_code=context.workspace.workspace_code,
workspace_name=context.workspace.workspace_name,
role=normalized,
permissions=frozenset(row[0] for row in permission_rows),
)
def require_operations_permission(permission_code: str):
"""Create a dependency that enforces one operation page/action permission."""
async def dependency(
context: OperationsContext = Depends(operations_context),
) -> OperationsContext:
if context.is_admin or permission_code in context.permissions:
return context
raise HTTPException(
status.HTTP_403_FORBIDDEN,
{
"code": "OPERATIONS_PERMISSION_DENIED",
"message": f"缺少运维权限:{permission_code}",
},
)
return dependency
__all__ = [
"OperationsContext",
"deploy_database_session",
"operations_cache",
"operations_context",
"require_operations_permission",
"operations_database_session",
"operations_event_stream",
"operations_platform_database_session",
+48 -4
View File
@@ -9,6 +9,23 @@ from common.db import readonly_session_scope, session_scope
router = APIRouter()
_REQUIRED_TABLES = {
"model_operations": frozenset(
{
"ops_monitor_batches",
"ops_monitor_results",
}
),
"model_deploy": frozenset(
{
"model_bank",
"model_deploy",
"model_deploy_bank_map",
"model_version",
}
),
}
@router.get("/health")
async def operations_health(
@@ -30,10 +47,37 @@ async def operations_health(
try:
async with scope(factory) as session:
current_database = await session.scalar(text("SELECT DATABASE()"))
checks[database_name] = {
"status": "ok" if current_database == database_name else "error",
"mode": "read_only" if read_only else "read_write",
}
missing_tables: set[str] = set()
required_tables = _REQUIRED_TABLES.get(database_name)
if required_tables and current_database == database_name:
existing_tables = set(
(
await session.scalars(
text(
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE()"
)
)
).all()
)
missing_tables = required_tables - existing_tables
if current_database != database_name:
checks[database_name] = {
"status": "error",
"mode": "read_only" if read_only else "read_write",
"detail": "连接到的数据库名称不匹配",
}
elif missing_tables:
checks[database_name] = {
"status": "error",
"mode": "read_only" if read_only else "read_write",
"detail": f"缺少表:{', '.join(sorted(missing_tables))}",
}
else:
checks[database_name] = {
"status": "ok",
"mode": "read_only" if read_only else "read_write",
}
except Exception:
checks[database_name] = {
"status": "error",
+74 -33
View File
@@ -3,30 +3,45 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.operations._deps import (
OperationsContext,
operations_context,
deploy_database_session,
operations_database_session,
require_operations_permission,
)
from backend.schemas.operations import ModelCategoryCode, ModelStatusLabel
from backend.services.operations import (
get_model,
get_monthly_monitoring_result,
list_models,
get_deploy_model,
get_deploy_monthly_monitoring_result,
list_deploy_models,
parse_monitor_month,
)
from backend.services.operations.mock_data import (
get_mock_model,
get_mock_monthly_result,
list_mock_models,
)
from common.config import settings
router = APIRouter()
def _schema_error(exc: SQLAlchemyError) -> HTTPException | None:
"""Turn a missing operations table into an actionable service error."""
original = getattr(exc, "orig", None)
error_code = original.args[0] if getattr(original, "args", None) else None
if error_code != 1146: # MySQL ER_NO_SUCH_TABLE
return None
return HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
{
"code": "OPERATIONS_SCHEMA_NOT_READY",
"message": (
"模型部署库或运维监控结果表尚未完成初始化,请先确认"
" model_deploy、model_version、model_bank、model_deploy_bank_map "
"以及 model_operations 的监控批次/结果表"
),
},
)
def _envelope(context: OperationsContext, data: Any) -> dict[str, Any]:
return {"request_id": context.request_id, "data": data, "meta": {}}
@@ -37,32 +52,49 @@ async def list_operation_models(
category: ModelCategoryCode | None = Query(default=None),
model_status: ModelStatusLabel | None = Query(default=None, alias="status"),
keyword: str | None = Query(default=None, max_length=200),
context: OperationsContext = Depends(operations_context),
context: OperationsContext = Depends(require_operations_permission("operations:model-overview:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
models = list_mock_models(
bank=bank,
category=category,
status=model_status,
keyword=keyword,
) if settings.operations_data_mode == "mock" else await list_models(
session,
context.workspace_id,
bank=bank,
category=category,
status=model_status,
keyword=keyword,
)
try:
models = await list_deploy_models(
session,
deploy_session,
context.workspace_id,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
bank=bank,
category=category,
status=model_status,
keyword=keyword,
)
except SQLAlchemyError as exc:
if schema_error := _schema_error(exc):
raise schema_error from exc
raise
return _envelope(context, models)
@router.get("/models/{model_id}")
async def get_operation_model(
model_id: str,
context: OperationsContext = Depends(operations_context),
context: OperationsContext = Depends(require_operations_permission("operations:monitoring-detail:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
model = get_mock_model(model_id) if settings.operations_data_mode == "mock" else await get_model(session, context.workspace_id, model_id)
try:
model = await get_deploy_model(
session,
deploy_session,
context.workspace_id,
model_id,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
)
except SQLAlchemyError as exc:
if schema_error := _schema_error(exc):
raise schema_error from exc
raise
if model is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
@@ -75,8 +107,9 @@ async def get_operation_model(
async def get_operation_model_monitor_result(
model_id: str,
month: str = Query(..., pattern=r"^\d{4}-\d{2}$"),
context: OperationsContext = Depends(operations_context),
context: OperationsContext = Depends(require_operations_permission("operations:monitoring-detail:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
try:
monitor_month = parse_monitor_month(month)
@@ -85,12 +118,20 @@ async def get_operation_model_monitor_result(
status.HTTP_422_UNPROCESSABLE_ENTITY,
{"code": "INVALID_MONITOR_MONTH", "message": str(exc)},
) from exc
result = get_mock_monthly_result(model_id, month) if settings.operations_data_mode == "mock" else await get_monthly_monitoring_result(
session,
context.workspace_id,
model_id,
monitor_month,
)
try:
result = await get_deploy_monthly_monitoring_result(
session,
deploy_session,
context.workspace_id,
model_id,
monitor_month,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
)
except SQLAlchemyError as exc:
if schema_error := _schema_error(exc):
raise schema_error from exc
raise
if result is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.operations._deps import (
OperationsContext,
deploy_database_session,
operations_database_session,
require_operations_permission,
)
from backend.services.operations import get_workbench
router = APIRouter()
@router.get("/workbench")
async def get_operation_workbench(
context: OperationsContext = Depends(require_operations_permission("operations:workbench:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
data = await get_workbench(
session,
deploy_session,
context.workspace_id,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
role=context.role,
)
return {
"request_id": context.request_id,
"data": data,
"meta": {},
}
__all__ = ["router"]
@@ -1,4 +1,9 @@
from backend.services.operations.cache import OperationsCache
from backend.services.operations.deploy_model_queries import (
get_deploy_model,
get_deploy_monthly_monitoring_result,
list_deploy_models,
)
from backend.services.operations.model_queries import (
get_model,
get_monthly_monitoring_result,
@@ -6,12 +11,17 @@ from backend.services.operations.model_queries import (
parse_monitor_month,
)
from backend.services.operations.streams import OperationsEventStream
from backend.services.operations.workbench_queries import get_workbench
__all__ = [
"OperationsCache",
"OperationsEventStream",
"get_deploy_model",
"get_deploy_monthly_monitoring_result",
"get_workbench",
"get_model",
"get_monthly_monitoring_result",
"list_models",
"list_deploy_models",
"parse_monitor_month",
]
@@ -0,0 +1,482 @@
from __future__ import annotations
import datetime
from dataclasses import dataclass
from typing import Any
from sqlalchemy import and_, case, func, select, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.schemas.operations import MonthlyMonitoringResultDto, OperationsModelDto
from common.db.models.operations import (
OpsMonitorBatch,
OpsMonitorEvaluation,
OpsMonitorResult,
OpsMonitorReview,
)
_MODEL_STATUS = {
0: "下线", # 离线
1: "正常", # 在线
2: "正常", # 部署中;陪跑由 role=2 单独判断
3: "下线", # 已下线
}
_RANKING_STATUS = {
"matched": "相符",
"unmatched": "不符",
"not_applicable": "",
None: "",
}
_CATEGORY_CODES = {"std", "bai", "big", "afd"}
@dataclass(frozen=True)
class _MonitorSnapshot:
monitor_result_id: str
model_instance_id: str
model_version_id: str
monitor_month: datetime.date
ranking_result: str | None
ks_value: Any
psi_value: Any
ks_mom_drop_rate: Any
secondary_level2_hits_6m: int
action_snapshot: str | None
handled_at: datetime.datetime | None
source_result_ref: str | None
monitor_grade: str | None
abnormal_level: str | None
pending_model_review: bool
pending_business_review: bool
_DEPLOY_MODEL_SQL = text(
"""
SELECT
deploy.id AS deploy_id,
deploy.code AS deploy_code,
deploy.name AS deploy_name,
deploy.modelType AS model_type,
deploy.status AS deploy_status,
deploy.role AS deploy_role,
deploy.currentVersion AS current_version_no,
deploy.group_code AS group_code,
version.id AS version_id,
version.version AS version_no,
version.versionName AS version_name,
COALESCE(
version.gmtModified,
version.gmtCreated,
deploy.gmtModified,
deploy.gmtCreated
) AS last_iteration_at,
bank.bankNo AS bank_no,
bank.bankName AS bank_name,
bank.isWuji AS is_wuji_bank
FROM model_deploy AS deploy
INNER JOIN model_deploy_bank_map AS bank_map
ON bank_map.deployId = deploy.id
AND bank_map.status = 1
INNER JOIN model_bank AS bank
ON bank.bankNo = bank_map.bankNo
AND bank.status = 1
LEFT JOIN model_version AS version
ON version.id = (
SELECT candidate.id
FROM model_version AS candidate
WHERE candidate.deployId = deploy.id
ORDER BY candidate.isCurrent DESC, candidate.version DESC, candidate.id DESC
LIMIT 1
)
LEFT JOIN sys_project_space AS project_space
ON project_space.id = deploy.space_id
WHERE (
project_space.name = :workspace_name
OR project_space.name = :workspace_code
OR deploy.group_code = :workspace_code
)
AND (
:model_id = ''
OR deploy.code = :model_id
OR CAST(deploy.id AS CHAR) = :model_id
)
ORDER BY bank.bankName, deploy.name, deploy.id
"""
)
def _latest_result_statement(
workspace_id: str,
monitor_month: datetime.date | None = None,
):
filters = [
OpsMonitorBatch.workspace_id == workspace_id,
OpsMonitorBatch.batch_status == "published",
OpsMonitorBatch.is_deleted == 0,
OpsMonitorResult.workspace_id == workspace_id,
OpsMonitorResult.is_deleted == 0,
]
if monitor_month is not None:
filters.append(OpsMonitorResult.monitor_month == monitor_month)
ranked = (
select(
OpsMonitorResult.monitor_result_id.label("monitor_result_id"),
OpsMonitorResult.model_instance_id.label("model_instance_id"),
OpsMonitorResult.model_version_id.label("model_version_id"),
OpsMonitorResult.monitor_month.label("monitor_month"),
OpsMonitorResult.ranking_result.label("ranking_result"),
OpsMonitorResult.ks_value.label("ks_value"),
OpsMonitorResult.psi_value.label("psi_value"),
OpsMonitorResult.source_result_ref.label("source_result_ref"),
func.row_number()
.over(
partition_by=OpsMonitorResult.model_instance_id,
order_by=(
OpsMonitorResult.monitor_month.desc(),
OpsMonitorBatch.revision_no.desc(),
OpsMonitorBatch.published_at.desc(),
),
)
.label("row_no"),
)
.join(OpsMonitorBatch, OpsMonitorBatch.batch_id == OpsMonitorResult.batch_id)
.where(*filters)
.subquery("ranked_monitor_results")
)
latest_review = (
select(
OpsMonitorReview.monitor_result_id.label("monitor_result_id"),
func.max(OpsMonitorReview.handled_at).label("handled_at"),
func.max(
case(
(and_(
OpsMonitorReview.review_stage == "model_initial",
OpsMonitorReview.review_status == "pending",
), 1),
else_=0,
)
).label("pending_model_review"),
func.max(
case(
(and_(
OpsMonitorReview.review_stage == "business_final",
OpsMonitorReview.review_status == "pending",
), 1),
else_=0,
)
).label("pending_business_review"),
)
.where(
OpsMonitorReview.is_deleted == 0,
)
.group_by(OpsMonitorReview.monitor_result_id)
.subquery("latest_monitor_review")
)
return (
select(
ranked.c.monitor_result_id,
ranked.c.model_instance_id,
ranked.c.model_version_id,
ranked.c.monitor_month,
ranked.c.ranking_result,
ranked.c.ks_value,
ranked.c.psi_value,
ranked.c.source_result_ref,
OpsMonitorEvaluation.ks_mom_drop_rate,
OpsMonitorEvaluation.secondary_level2_hits_6m,
OpsMonitorEvaluation.action_snapshot,
latest_review.c.handled_at,
OpsMonitorEvaluation.monitor_grade,
OpsMonitorEvaluation.abnormal_level,
latest_review.c.pending_model_review,
latest_review.c.pending_business_review,
)
.outerjoin(
OpsMonitorEvaluation,
and_(
OpsMonitorEvaluation.monitor_result_id
== ranked.c.monitor_result_id,
OpsMonitorEvaluation.is_current == 1,
OpsMonitorEvaluation.is_deleted == 0,
),
)
.outerjoin(
latest_review,
latest_review.c.monitor_result_id == ranked.c.monitor_result_id,
)
.where(ranked.c.row_no == 1)
)
async def _load_deploy_rows(
deploy_session: AsyncSession,
*,
workspace_code: str,
workspace_name: str,
model_id: str = "",
) -> list[dict[str, Any]]:
try:
rows = (
await deploy_session.execute(
_DEPLOY_MODEL_SQL,
{
"workspace_code": workspace_code,
"workspace_name": workspace_name,
"model_id": model_id,
},
)
).mappings()
except SQLAlchemyError as exc:
if _is_missing_table_error(exc):
return []
raise
return [dict(row) for row in rows]
async def _load_monitor_snapshots(
operations_session: AsyncSession,
workspace_id: str,
monitor_month: datetime.date | None = None,
) -> dict[str, _MonitorSnapshot]:
try:
rows = (await operations_session.execute(
_latest_result_statement(workspace_id, monitor_month)
)).mappings()
except SQLAlchemyError as exc:
if _is_missing_table_error(exc):
return {}
raise
snapshots: dict[str, _MonitorSnapshot] = {}
for row in rows:
snapshot = _MonitorSnapshot(
monitor_result_id=str(row["monitor_result_id"]),
model_instance_id=str(row["model_instance_id"]),
model_version_id=str(row["model_version_id"]),
monitor_month=row["monitor_month"],
ranking_result=row["ranking_result"],
ks_value=row["ks_value"],
psi_value=row["psi_value"],
ks_mom_drop_rate=row["ks_mom_drop_rate"],
secondary_level2_hits_6m=int(row["secondary_level2_hits_6m"] or 0),
action_snapshot=row["action_snapshot"],
handled_at=row["handled_at"],
source_result_ref=row["source_result_ref"],
monitor_grade=row["monitor_grade"],
abnormal_level=row["abnormal_level"],
pending_model_review=bool(row["pending_model_review"]),
pending_business_review=bool(row["pending_business_review"]),
)
for alias in (
snapshot.monitor_result_id,
snapshot.model_instance_id,
snapshot.model_version_id,
snapshot.source_result_ref,
):
if alias:
snapshots[str(alias)] = snapshot
return snapshots
def _snapshot_for(
row: dict[str, Any], snapshots: dict[str, _MonitorSnapshot]
) -> _MonitorSnapshot | None:
aliases = (
row["deploy_id"],
row["version_id"],
row["deploy_code"],
f"deploy:{row['deploy_id']}",
f"version:{row['version_id']}" if row["version_id"] is not None else None,
)
for alias in aliases:
if alias is not None and str(alias) in snapshots:
return snapshots[str(alias)]
return None
def _category_code(row: dict[str, Any]) -> str:
candidates = (
str(row.get("group_code") or "").lower(),
str(row.get("deploy_code") or "").lower(),
str(row.get("deploy_name") or "").lower(),
)
for candidate in candidates:
if candidate in _CATEGORY_CODES:
return candidate
for code in _CATEGORY_CODES:
if code in candidate:
return code
# modelType=1 is the deployment platform's scoring-card type. Until the
# model side supplies a business category, keep it in the standard A-card
# bucket so the existing four-category API remains usable.
return "std"
def _model_status(row: dict[str, Any]) -> str:
if row.get("deploy_role") == 2 and row.get("deploy_status") not in {0, 3}:
return "陪跑"
return _MODEL_STATUS.get(row.get("deploy_status"), "正常")
def _percent(value: Any) -> float:
return round(float(value or 0) * 100, 4)
def _date_text(value: datetime.date | datetime.datetime | None) -> str:
if value is None:
return ""
return (value.date() if isinstance(value, datetime.datetime) else value).isoformat()
def _datetime_date_text(value: datetime.datetime | None) -> str | None:
return value.date().isoformat() if value else None
def _is_missing_table_error(exc: SQLAlchemyError) -> bool:
original = getattr(exc, "orig", None)
error_code = original.args[0] if getattr(original, "args", None) else None
return error_code == 1146 # MySQL ER_NO_SUCH_TABLE
def _model_dto(
row: dict[str, Any], snapshot: _MonitorSnapshot | None
) -> OperationsModelDto:
return OperationsModelDto(
model_instance_id=str(row["deploy_id"]),
bank_name=str(row["bank_name"]),
model_category=_category_code(row), # type: ignore[arg-type]
model_name=str(row["deploy_name"]),
model_id=str(row["deploy_code"] or row["deploy_id"]),
model_version=str(
row["version_name"]
or (f"v{row['version_no']}" if row["version_no"] is not None else "")
),
model_status=_model_status(row), # type: ignore[arg-type]
last_iteration_date=_date_text(row["last_iteration_at"]),
ranking_result=_RANKING_STATUS.get(
snapshot.ranking_result if snapshot else None, ""
),
ks=_percent(snapshot.ks_value if snapshot else None),
psi=_percent(snapshot.psi_value if snapshot else None),
ks_mom_drop=_percent(snapshot.ks_mom_drop_rate if snapshot else None),
secondary_hits_6m=snapshot.secondary_level2_hits_6m if snapshot else 0,
is_wuji_bank=bool(row["is_wuji_bank"]),
last_processed_at=(
_datetime_date_text(snapshot.handled_at) if snapshot else None
),
previous_advice=(
snapshot.action_snapshot if snapshot and snapshot.action_snapshot
else "暂无历史处理建议"
),
common_model_name=None,
)
async def list_deploy_models(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
*,
workspace_code: str,
workspace_name: str,
bank: str | None = None,
category: str | None = None,
status: str | None = None,
keyword: str | None = None,
) -> list[OperationsModelDto]:
rows = await _load_deploy_rows(
deploy_session,
workspace_code=workspace_code,
workspace_name=workspace_name,
)
snapshots = await _load_monitor_snapshots(operations_session, workspace_id)
term = keyword.strip().lower() if keyword else ""
result: list[OperationsModelDto] = []
for row in rows:
model = _model_dto(row, _snapshot_for(row, snapshots))
if bank and model.bank_name != bank:
continue
if category and model.model_category != category:
continue
if status and model.model_status != status:
continue
if term and term not in " ".join(
(model.bank_name, model.model_name, model.model_id, model.model_version)
).lower():
continue
result.append(model)
return result
async def get_deploy_model(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
model_id: str,
*,
workspace_code: str,
workspace_name: str,
) -> OperationsModelDto | None:
models = await list_deploy_models(
operations_session,
deploy_session,
workspace_id,
workspace_code=workspace_code,
workspace_name=workspace_name,
keyword=model_id,
)
return next((model for model in models if model.model_id == model_id), None)
async def get_deploy_monthly_monitoring_result(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
model_id: str,
monitor_month: datetime.date,
*,
workspace_code: str,
workspace_name: str,
) -> MonthlyMonitoringResultDto | None:
rows = await _load_deploy_rows(
deploy_session,
workspace_code=workspace_code,
workspace_name=workspace_name,
model_id=model_id,
)
row = next(
(
item
for item in rows
if str(item["deploy_code"] or item["deploy_id"]) == model_id
),
None,
)
if row is None:
return None
snapshots = await _load_monitor_snapshots(
operations_session,
workspace_id,
monitor_month,
)
snapshot = _snapshot_for(row, snapshots)
if snapshot is None:
return None
return MonthlyMonitoringResultDto(
model_instance_id=str(row["deploy_id"]),
monitor_month=snapshot.monitor_month.strftime("%Y-%m"),
ranking_result=_RANKING_STATUS.get(snapshot.ranking_result, ""),
ks=_percent(snapshot.ks_value),
psi=_percent(snapshot.psi_value),
ks_mom_drop=_percent(snapshot.ks_mom_drop_rate),
secondary_hits_6m=snapshot.secondary_level2_hits_6m,
)
__all__ = [
"get_deploy_model",
"get_deploy_monthly_monitoring_result",
"list_deploy_models",
]
@@ -1,105 +0,0 @@
from __future__ import annotations
import datetime
from backend.schemas.operations import MonthlyMonitoringResultDto, OperationsModelDto
_MODEL_SPECS = [
("江城银行", "std", "标准A卡", "JC-STD-001", "v2.3", "正常", "2026-06-18", "不符", 36.84, 27.40, 24.10, 3, True, "2026-06-18", "建议启动模型微调或重构评估", "标准A卡通用版 v2.3"),
("滨海银行", "std", "标准A卡", "BH-STD-001", "v2.3", "陪跑", "2026-07-28", "相符", 44.10, 6.20, 3.40, 0, False, "2026-06-03", "维持现状,继续监控", "标准A卡通用版 v2.3"),
("华东银行", "std", "标准A卡", "HD-STD-001", "v2.2", "正常", "2026-03-20", "相符", 43.50, 8.80, 6.10, 0, False, "2026-05-20", "维持现状,继续监控", "标准A卡通用版 v2.3"),
("南岭银行", "std", "标准A卡", "NL-STD-001", "v1.6", "正常", "2025-08-22", "相符", 41.12, 13.60, 22.50, 1, False, "2026-06-12", "建议重点关注 PSI 与 KS 环比变化", "标准A卡通用版 v2.3"),
("云岭银行", "std", "标准A卡", "YL-STD-001", "v2.1", "正常", "2026-01-09", "相符", 45.80, 5.10, 2.20, 0, False, "2026-04-28", "维持现状,继续监控", "标准A卡通用版 v2.3"),
("通汇银行", "std", "标准A卡", "TH-STD-001", "v2.0", "正常", "2025-11-30", "不符", 38.73, 9.40, 11.80, 4, False, "2026-05-16", "建议开展模型微调可行性评估", "标准A卡通用版 v2.3"),
("北岸银行", "std", "标准A卡", "BA-STD-001", "v1.9", "下线", "2024-12-11", "", 0.00, 0.00, 0.00, 0, False, "2026-03-02", "模型已下线,不再参与月度监控", None),
("江城银行", "big", "大额A卡", "JC-BIG-001", "v1.4", "正常", "2026-07-03", "相符", 46.30, 4.80, 1.50, 0, False, "2026-06-24", "维持现状,继续监控", "大额A卡通用版 v1.4"),
("华东银行", "big", "大额A卡", "HD-BIG-001", "v1.3", "正常", "2026-04-16", "相符", 44.90, 7.30, 4.90, 0, False, "2026-05-30", "维持现状,继续监控", "大额A卡通用版 v1.4"),
("云岭银行", "big", "大额A卡", "YL-BIG-001", "v1.2", "陪跑结束", "2026-08-05", "相符", 42.70, 9.60, 8.20, 0, False, None, "暂无历史处理建议", "大额A卡通用版 v1.4"),
("滨海银行", "big", "大额A卡", "BH-BIG-001", "v1.1", "正常", "2025-09-25", "相符", 39.40, 11.20, 9.70, 2, False, "2026-04-19", "建议持续跟踪,下期复核", "大额A卡通用版 v1.4"),
("南岭银行", "bai", "白户A卡", "NL-BAI-001", "v1.5", "正常", "2026-05-22", "不符", 34.20, 18.70, 15.30, 2, True, "2026-06-09", "建议启动模型微调或重构评估", None),
("通汇银行", "bai", "白户A卡", "TH-BAI-001", "v1.4", "正常", "2026-02-11", "相符", 37.60, 12.10, 7.40, 1, False, "2026-05-11", "建议重点关注并持续跟踪", "白户A卡通用版 v1.4"),
("江城银行", "bai", "白户A卡", "JC-BAI-001", "v1.3", "正常", "2025-12-05", "相符", 40.80, 9.90, 5.60, 0, False, "2026-03-26", "维持现状,继续监控", "白户A卡通用版 v1.4"),
("华东银行", "afd", "反欺诈评分", "HD-AFD-001", "v2.0", "正常", "2026-04-10", "不符", 28.60, 31.50, 18.90, 3, False, "2026-06-21", "建议启动模型微调或重构评估", None),
("滨海银行", "afd", "反欺诈评分", "BH-AFD-001", "v1.8", "正常", "2025-10-30", "相符", 36.40, 8.10, 4.20, 3, True, "2026-05-25", "建议重点关注并持续跟踪", "反欺诈评分通用版 v1.8"),
("云岭银行", "afd", "反欺诈评分", "YL-AFD-001", "v1.7", "正常", "2025-08-19", "相符", 38.20, 14.60, 12.70, 2, False, "2026-04-15", "建议持续跟踪,下期复核", None),
]
def _model(spec: tuple[object, ...]) -> OperationsModelDto:
bank, category, name, model_id, version, model_status, iteration_date, ranking, ks, psi, drop, hits, wuji, processed, advice, common = spec
return OperationsModelDto(
model_instance_id=f"mock-{model_id}",
bank_name=str(bank),
model_category=category, # type: ignore[arg-type]
model_name=str(name),
model_id=str(model_id),
model_version=str(version),
model_status=model_status, # type: ignore[arg-type]
last_iteration_date=str(iteration_date),
ranking_result=ranking, # type: ignore[arg-type]
ks=float(ks),
psi=float(psi),
ks_mom_drop=float(drop),
secondary_hits_6m=int(hits),
is_wuji_bank=bool(wuji),
last_processed_at=processed,
previous_advice=str(advice),
common_model_name=common,
)
MOCK_MODELS = tuple(_model(spec) for spec in _MODEL_SPECS)
_MODEL_BY_ID = {model.model_id: model for model in MOCK_MODELS}
def list_mock_models(
*,
bank: str | None = None,
category: str | None = None,
status: str | None = None,
keyword: str | None = None,
) -> list[OperationsModelDto]:
term = keyword.strip().lower() if keyword else ""
return [
model.model_copy()
for model in MOCK_MODELS
if not bank or model.bank_name == bank
if not category or model.model_category == category
if not status or model.model_status == status
if not term or term in " ".join((model.bank_name, model.model_name, model.model_id, model.model_version)).lower()
]
def get_mock_model(model_id: str) -> OperationsModelDto | None:
model = _MODEL_BY_ID.get(model_id)
return model.model_copy() if model else None
def get_mock_monthly_result(model_id: str, month: str) -> MonthlyMonitoringResultDto | None:
model = _MODEL_BY_ID.get(model_id)
if model is None:
return None
if model.model_status == "下线" and month != "2026-02":
return None
try:
index = max(0, min(5, (datetime.date.fromisoformat(f"{month}-01").year - 2026) * 12 + datetime.date.fromisoformat(f"{month}-01").month - 2))
except ValueError:
return None
distance = 5 - index
return MonthlyMonitoringResultDto(
model_instance_id=model.model_instance_id,
monitor_month=month,
ranking_result=model.ranking_result,
ks=round(max(0, model.ks - distance * 0.32), 2),
psi=round(max(0, model.psi - distance * 0.74), 2),
ks_mom_drop=round(max(0, model.ks_mom_drop - distance * 0.9), 2),
secondary_hits_6m=max(0, model.secondary_hits_6m - distance),
)
__all__ = [
"MOCK_MODELS",
"get_mock_model",
"get_mock_monthly_result",
"list_mock_models",
]
@@ -0,0 +1,303 @@
from __future__ import annotations
import datetime
from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.services.operations.deploy_model_queries import (
_MonitorSnapshot,
_load_monitor_snapshots,
list_deploy_models,
)
OperationsRole = Literal["admin", "model_team", "business_team"]
_REPORTS_SQL = text(
"""
SELECT report_id, monitor_result_id, report_status, report_month, output_date, updated_at
FROM ops_reports
WHERE workspace_id = :workspace_id AND is_deleted = 0
ORDER BY updated_at DESC, report_id DESC
"""
)
_WORKFLOWS_SQL = text(
"""
SELECT
workflow.workflow_id,
workflow.workflow_title,
workflow.current_stage,
workflow.workflow_status,
stage.stage_name_snapshot,
stage.stage_status,
stage.owner_role_code,
stage.confirmation_required,
stage.confirmation_status,
stage.due_at,
stage.updated_at
FROM ops_workflows AS workflow
LEFT JOIN ops_workflow_stages AS stage
ON stage.workflow_id = workflow.workflow_id
AND stage.stage_no = workflow.current_stage
AND stage.is_deleted = 0
WHERE workflow.workspace_id = :workspace_id
AND workflow.is_deleted = 0
ORDER BY workflow.updated_at DESC, workflow.workflow_id DESC
"""
)
_ACTIVITY_SQL = text(
"""
SELECT user_id, event_type, target_type, target_id, event_metadata, occurred_at
FROM ops_usage_events
WHERE workspace_id = :workspace_id AND is_deleted = 0
ORDER BY occurred_at DESC, usage_event_id DESC
LIMIT 7
"""
)
async def _optional_rows(
session: AsyncSession,
statement: Any,
workspace_id: str,
) -> list[dict[str, Any]]:
try:
rows = (await session.execute(statement, {"workspace_id": workspace_id})).mappings()
except SQLAlchemyError as exc:
original = getattr(exc, "orig", None)
code = original.args[0] if getattr(original, "args", None) else None
if code == 1146: # MySQL ER_NO_SUCH_TABLE: an uninitialised optional module
return []
raise
return [dict(row) for row in rows]
def _kpi(
key: str,
label: str,
value: int,
detail: str,
target: str,
tone: str = "normal",
) -> dict[str, Any]:
return {
"key": key,
"label": label,
"value": value,
"detail": detail,
"target": target,
"tone": tone,
}
def _model_for_snapshot(
snapshot: _MonitorSnapshot | None,
models_by_instance: dict[str, Any],
) -> Any | None:
return models_by_instance.get(snapshot.model_instance_id) if snapshot else None
def _review_todos(
snapshots: dict[str, _MonitorSnapshot],
models_by_instance: dict[str, Any],
role: OperationsRole,
) -> list[dict[str, Any]]:
todos: list[dict[str, Any]] = []
unique_snapshots = {
snapshot.monitor_result_id: snapshot for snapshot in snapshots.values()
}
for snapshot in unique_snapshots.values():
if snapshot.model_instance_id not in models_by_instance:
continue
pending = (
snapshot.pending_business_review
if role == "business_team"
else snapshot.pending_model_review
)
if role == "admin":
pending = snapshot.pending_model_review or snapshot.pending_business_review
if not pending:
continue
model = _model_for_snapshot(snapshot, models_by_instance)
stage = "业务团队终审" if role == "business_team" else "模型团队初审"
if role == "admin":
stage = "监控结果处理"
todos.append(
{
"id": f"review:{snapshot.monitor_result_id}:{stage}",
"tone": "danger" if snapshot.monitor_grade == "C" else "warning",
"title": f"监控结果待处理 · {model.bank} {model.model_name}",
"detail": f"{model.model_id} · {stage} · 等级 {snapshot.monitor_grade or ''}",
"action_label": "选择处理建议",
"target": f"/operations/monitoring/{model.model_id}",
}
)
return todos
def _report_todos(
reports: list[dict[str, Any]],
snapshots: dict[str, _MonitorSnapshot],
models_by_instance: dict[str, Any],
role: OperationsRole,
) -> list[dict[str, Any]]:
expected_status = "sent_business" if role == "business_team" else "pending_model_read"
todos: list[dict[str, Any]] = []
now = datetime.datetime.now()
for report in reports:
if report["report_status"] != expected_status:
continue
snapshot = snapshots.get(str(report["monitor_result_id"]))
model = _model_for_snapshot(snapshot, models_by_instance)
label = f"{model.bank} {model.model_name}" if model else "待关联模型报告"
updated_at = report.get("updated_at")
days = (now - updated_at).days if isinstance(updated_at, datetime.datetime) else 0
overdue = days >= 5
todos.append(
{
"id": f"report:{report['report_id']}",
"tone": "danger" if overdue else "warning",
"title": f"报告待阅读 · {label}",
"detail": f"{report['report_month'].strftime('%Y-%m')} · {'已超 5 个工作日' if overdue else '待阅读'}",
"action_label": "去阅读",
"target": f"/operations/reports?report={report['report_id']}",
}
)
return todos
def _workflow_items(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
todos: list[dict[str, Any]] = []
watches: list[dict[str, Any]] = []
now = datetime.datetime.now()
for row in rows:
if row.get("workflow_status") != "active":
continue
target = f"/operations/workflows?workflow={row['workflow_id']}"
due_at = row.get("due_at")
overdue_days = (now - due_at).days if isinstance(due_at, datetime.datetime) else 0
if overdue_days > 0 and row.get("stage_status") not in {"completed", "skipped"}:
watches.append(
{
"id": f"workflow:{row['workflow_id']}",
"tone": "primary",
"title": f"流程进度关注 · {row['workflow_title']}",
"detail": f"停在「{row.get('stage_name_snapshot') or '当前环节'}」已 {overdue_days}",
"action_label": "查看进度",
"target": target,
}
)
if (
row.get("owner_role_code") in {"business", "business_team", "biz"}
and row.get("confirmation_required") == 1
and row.get("confirmation_status") == "pending"
):
todos.append(
{
"id": f"workflow-feedback:{row['workflow_id']}",
"tone": "warning",
"title": f"流程待反馈 · {row['workflow_title']}",
"detail": f"当前环节:{row.get('stage_name_snapshot') or '待确认'}",
"action_label": "去反馈",
"target": target,
}
)
return todos, watches
def _activities(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
labels = {
"login": "登录平台",
"request_submit": "发起模型需求",
"report_read": "阅读报告",
"report_download": "下载报告",
}
return [
{
"occurred_at": row["occurred_at"].isoformat() if row.get("occurred_at") else "",
"actor": f"用户 {row['user_id']}" if row.get("user_id") else "系统",
"text": labels.get(row.get("event_type"), str(row.get("event_type") or "平台行为")),
}
for row in rows
]
async def get_workbench(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
*,
workspace_code: str,
workspace_name: str,
role: OperationsRole,
) -> dict[str, Any]:
models = await list_deploy_models(
operations_session,
deploy_session,
workspace_id,
workspace_code=workspace_code,
workspace_name=workspace_name,
)
snapshots = await _load_monitor_snapshots(operations_session, workspace_id)
models_by_instance = {model.model_instance_id: model for model in models}
live_models = [model for model in models if model.model_status != "下线"]
monitored = [
model for model in live_models
if snapshots.get(model.model_instance_id) is not None
]
grades = {
grade: sum(
1 for model in monitored
if snapshots[model.model_instance_id].monitor_grade == grade
)
for grade in ("A", "B", "C")
}
bc_count = grades["B"] + grades["C"]
intervention = [model for model in monitored if model.ks < 30 or model.psi > 50]
reports = await _optional_rows(operations_session, _REPORTS_SQL, workspace_id)
workflows = await _optional_rows(operations_session, _WORKFLOWS_SQL, workspace_id)
activities = await _optional_rows(operations_session, _ACTIVITY_SQL, workspace_id)
workflow_todos, watches = _workflow_items(workflows)
todos = _review_todos(snapshots, models_by_instance, role)
todos.extend(_report_todos(reports, snapshots, models_by_instance, role))
if role == "business_team":
todos.extend(workflow_todos)
todos.sort(key=lambda item: (item["tone"] != "danger", item["id"]))
alerts = []
overdue_reports = len([item for item in todos if item["id"].startswith("report:") and item["tone"] == "danger"])
if overdue_reports:
alerts.append(f"{overdue_reports} 份报告已超 5 个工作日未阅读,已进入催办范围。")
if intervention:
alerts.append(f"另有 {len(intervention)} 个模型触发主动干预条件。")
category_count = len({model.model_category for model in live_models})
common = [
_kpi("models", "在管模型", len(live_models), f"{len({model.bank_name for model in live_models})} 家银行 · {category_count} 个大类", "/operations/models"),
_kpi("grades", "B / C 等级", bc_count, f"C {grades['C']} · B {grades['B']}", "/operations/monitoring?grade=B,C", "warning" if bc_count else "normal"),
_kpi("pending_reviews", "待处理结果", len(_review_todos(snapshots, models_by_instance, role)), "到期未处理即默认暂不处理", "/operations/monitoring", "warning" if todos else "normal"),
_kpi("intervention", "需主动干预", len(intervention), "KS < 30% 或 PSI > 50%", "/operations/monitoring", "danger" if intervention else "normal"),
]
if role == "business_team":
common.extend([
_kpi("feedback", "待我反馈", len(workflow_todos), "方案设计与评审确认", "/operations/workflows", "warning" if workflow_todos else "normal"),
_kpi("business_reports", "待阅读报告", len(_report_todos(reports, snapshots, models_by_instance, role)), "报告发送后待阅读", "/operations/reports", "warning" if reports else "normal"),
])
else:
model_reports = _report_todos(reports, snapshots, models_by_instance, role)
common.extend([
_kpi("model_reports", "待我阅读报告", len(model_reports), "超过 5 个工作日进入催办", "/operations/reports", "warning" if model_reports else "normal"),
_kpi("stalled_workflows", "流程停滞", len(watches), "超过节点期限未更新", "/operations/workflows", "warning" if watches else "normal"),
])
return {
"role": role,
"alerts": alerts,
"kpis": common,
"todos": todos[:20],
"watches": watches[:20],
"activities": _activities(activities),
}
__all__ = ["get_workbench"]
@@ -0,0 +1,73 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import ProgrammingError
from backend.api.operations._deps import OperationsContext, require_operations_permission
from backend.services.operations.deploy_model_queries import (
_load_deploy_rows,
_load_monitor_snapshots,
)
@pytest.mark.asyncio
async def test_missing_deploy_table_degrades_to_empty_models() -> None:
class MissingTableSession:
async def execute(self, *args, **kwargs):
raise ProgrammingError(
"SELECT ...",
{},
Exception(1146, "table does not exist"),
)
rows = await _load_deploy_rows(
MissingTableSession(),
workspace_code="001",
workspace_name="001",
)
assert rows == []
@pytest.mark.asyncio
async def test_missing_monitoring_table_degrades_to_empty_snapshots() -> None:
class MissingTableSession:
async def execute(self, *args, **kwargs):
raise ProgrammingError(
"SELECT ...",
{},
Exception(1146, "table does not exist"),
)
snapshots = await _load_monitor_snapshots(MissingTableSession(), "W" * 26)
assert snapshots == {}
@pytest.mark.asyncio
async def test_operations_permission_dependency_checks_effective_permissions() -> None:
allowed = OperationsContext(
request_id="R" * 26,
user_id="U" * 26,
workspace_id="W" * 26,
workspace_code="001",
workspace_name="001",
role="business_team",
permissions=frozenset({"operations:model-overview:view"}),
)
denied = OperationsContext(
request_id="R" * 26,
user_id="U" * 26,
workspace_id="W" * 26,
workspace_code="001",
workspace_name="001",
role="business_team",
permissions=frozenset(),
)
dependency = require_operations_permission("operations:model-overview:view")
assert await dependency(allowed) is allowed
with pytest.raises(HTTPException) as error:
await dependency(denied)
assert error.value.status_code == 403
@@ -0,0 +1,44 @@
from __future__ import annotations
import pytest
from backend.services.operations import get_workbench
class _EmptyResult:
def mappings(self):
return self
def __iter__(self):
return iter(())
class _EmptySession:
async def execute(self, *args, **kwargs):
return _EmptyResult()
@pytest.mark.asyncio
async def test_workbench_returns_empty_role_aware_shape_without_data() -> None:
data = await get_workbench(
_EmptySession(),
_EmptySession(),
"W" * 26,
workspace_code="001",
workspace_name="001",
role="business_team",
)
assert [item["label"] for item in data["kpis"]] == [
"在管模型",
"B / C 等级",
"待处理结果",
"需主动干预",
"待我反馈",
"待阅读报告",
]
assert all(item["value"] == 0 for item in data["kpis"])
assert data["alerts"] == []
assert data["todos"] == []
assert data["watches"] == []
assert data["activities"] == []