feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
+5
-3
@@ -16,10 +16,11 @@
|
||||
仅将库名切换为 `model_operations`。
|
||||
- `PLATFORM_READ_DATABASE_URL` 与 `DEPLOY_DATABASE_URL` 使用独立只读
|
||||
Session;MySQL 事务会显式执行 `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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] == []
|
||||
Reference in New Issue
Block a user