diff --git a/.env.example b/.env.example index 165660e..4f70119 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,6 @@ OPERATIONS_CACHE_TTL_SECONDS=300 OPERATIONS_REDIS_PREFIX=model-platform:operations OPERATIONS_EVENT_STREAM=model-platform:operations:events OPERATIONS_EVENT_STREAM_MAXLEN=10000 -OPERATIONS_DATA_MODE=database JWT_SECRET=change-this-development-secret # AES-256 configuration decryption key used only when a value is wrapped in ENC(...). diff --git a/backend/README.md b/backend/README.md index f75347c..e7d42f1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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= GET /api/v1/operations/models/{model_id}?workspace_id= GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM&workspace_id= +GET /api/v1/operations/workbench?workspace_id= ``` diff --git a/backend/src/backend/api/operations/__init__.py b/backend/src/backend/api/operations/__init__.py index b152e3f..2fbce3b 100644 --- a/backend/src/backend/api/operations/__init__.py +++ b/backend/src/backend/api/operations/__init__.py @@ -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"] diff --git a/backend/src/backend/api/operations/_deps.py b/backend/src/backend/api/operations/_deps.py index 64f42cb..632c519 100644 --- a/backend/src/backend/api/operations/_deps.py +++ b/backend/src/backend/api/operations/_deps.py @@ -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", diff --git a/backend/src/backend/api/operations/health.py b/backend/src/backend/api/operations/health.py index f149bda..a3c55fe 100644 --- a/backend/src/backend/api/operations/health.py +++ b/backend/src/backend/api/operations/health.py @@ -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", diff --git a/backend/src/backend/api/operations/models.py b/backend/src/backend/api/operations/models.py index 61a720a..a9d8e09 100644 --- a/backend/src/backend/api/operations/models.py +++ b/backend/src/backend/api/operations/models.py @@ -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, diff --git a/backend/src/backend/api/operations/workbench.py b/backend/src/backend/api/operations/workbench.py new file mode 100644 index 0000000..99435f5 --- /dev/null +++ b/backend/src/backend/api/operations/workbench.py @@ -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"] diff --git a/backend/src/backend/services/operations/__init__.py b/backend/src/backend/services/operations/__init__.py index e5338f6..917272a 100644 --- a/backend/src/backend/services/operations/__init__.py +++ b/backend/src/backend/services/operations/__init__.py @@ -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", ] diff --git a/backend/src/backend/services/operations/deploy_model_queries.py b/backend/src/backend/services/operations/deploy_model_queries.py new file mode 100644 index 0000000..9352bca --- /dev/null +++ b/backend/src/backend/services/operations/deploy_model_queries.py @@ -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", +] diff --git a/backend/src/backend/services/operations/mock_data.py b/backend/src/backend/services/operations/mock_data.py deleted file mode 100644 index 87b2ff2..0000000 --- a/backend/src/backend/services/operations/mock_data.py +++ /dev/null @@ -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", -] diff --git a/backend/src/backend/services/operations/workbench_queries.py b/backend/src/backend/services/operations/workbench_queries.py new file mode 100644 index 0000000..7e2ea1f --- /dev/null +++ b/backend/src/backend/services/operations/workbench_queries.py @@ -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"] diff --git a/backend/tests/test_operations_api_schema.py b/backend/tests/test_operations_api_schema.py new file mode 100644 index 0000000..ee35fbe --- /dev/null +++ b/backend/tests/test_operations_api_schema.py @@ -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 diff --git a/backend/tests/test_operations_workbench.py b/backend/tests/test_operations_workbench.py new file mode 100644 index 0000000..166ca75 --- /dev/null +++ b/backend/tests/test_operations_workbench.py @@ -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"] == [] diff --git a/common/src/common/config.py b/common/src/common/config.py index 0cfcfd5..56f1fce 100644 --- a/common/src/common/config.py +++ b/common/src/common/config.py @@ -97,11 +97,6 @@ class Settings(BaseSettings): default="model-platform:operations:events" ) operations_event_stream_maxlen: int = Field(default=10000, ge=100) - operations_data_mode: str = Field( - default="database", - description="Operations data source: database or mock.", - ) - # ── JWT ─────────────────────────────────────────────────────── jwt_secret: str = Field( default="dev-only-not-for-production", diff --git a/docs/architecture/A卡模型运维模块-后端架构与数据库设计-V0.3.md b/docs/architecture/A卡模型运维模块-后端架构与数据库设计-V0.3.md new file mode 100644 index 0000000..346d150 --- /dev/null +++ b/docs/architecture/A卡模型运维模块-后端架构与数据库设计-V0.3.md @@ -0,0 +1,151 @@ +# A 卡模型运维模块后端架构与数据库设计 V0.3 + +> 状态:工程实现基线;首条接口链路已实现,真实非空数据联调和三角色验收待完成 +> 日期:2026-09-02 +> 依据:当前 `feature/a-card-operations` 代码、`origin/develop` 基础工程、现有三库结构和当前运维前端原型 + +## 1. 本版本结论 + +本版本将 V0.2 设计稿与当前代码实现对齐,作为本周开发和联调基线。 + +1. 同一工程内继续保留平台能力和运维能力,不拆分微服务。 +2. 登录、Workspace 和平台级 RBAC 继续复用 `model_platform`。 +3. 模型部署、银行、模型版本的首条查询链路当前读取 `model_deploy`。 +4. 月度监控结果、判级和复核快照当前读取 `model_operations`。 +5. React 运维页面已切换为真实 API;没有数据时保留页面结构并显示 0、— 或空列表。 +6. 当前已经完成接口代码、空数据验证和前端纵向调用;尚未用非空业务数据完成端到端验收。 + +## 2. 总体架构 + +```text +浏览器 + │ 同源请求 /api/v1/* + ▼ +Nginx / Vite 代理 + ▼ +FastAPI + ├─ 平台上下文:model_platform + │ ├─ 登录、Cookie、用户、角色、权限、Workspace + │ └─ 平台已有脚本、对象存储和调度能力 + ├─ 模型来源查询:model_deploy(只读) + │ ├─ model_deploy + │ ├─ model_bank + │ ├─ model_deploy_bank_map + │ ├─ model_version + │ └─ sys_project_space + └─ 运维数据查询/写入:model_operations + ├─ ops_monitor_batches + ├─ ops_monitor_results + ├─ ops_monitor_evaluations + ├─ ops_monitor_reviews + └─ 后续报告、流程、知识库、配置和 Outbox +``` + +当前工作区代码目录: + +- `backend/src/backend/api/operations/`:运维 API、Workspace/角色依赖和健康检查。 +- `backend/src/backend/services/operations/deploy_model_queries.py`:跨 `model_deploy` 与 `model_operations` 的首条模型查询服务。 +- `backend/src/backend/services/operations/workbench_queries.py`:工作台聚合查询。 +- `frontend/app/services/operationsApi.ts`:运维 API 请求和 DTO 转换。 +- `frontend/app/features/operations/OperationsDataContext.tsx`:统一加载真实接口数据并处理空数据。 +- `frontend/app/features/operations/`:运维页面和展示组件。 + +## 3. 数据责任边界 + +### 3.1 平台库 `model_platform` + +由现有平台和权限模块负责。运维模块只复用登录和 Workspace 上下文,不创建新的账号或 RBAC 表。 + +- 用户、角色、权限和 Workspace:现有认证/系统管理模块维护。 +- `password_hash`:运维模块禁止读取。 +- 运维页面的登录角色只使用统一认证返回的 `role_code`。 + +### 3.2 模型库 `model_deploy` + +首条链路作为模型来源库只读查询。模型方负责维护: + +- 银行:`model_bank`。 +- 模型部署实例:`model_deploy`。 +- 模型与银行关系:`model_deploy_bank_map`。 +- 模型版本:`model_version`。 +- Workspace/项目空间映射:`sys_project_space` 或 `deploy.group_code`。 + +### 3.3 运维库 `model_operations` + +监控和运维模块维护: + +- 月度批次、原始监控结果、特征指标和分布。 +- 判级快照、模型团队初审、业务团队终审。 +- 报告、流程、文档、Prompt、规则、配置、使用统计和 Outbox。 + +## 4. 当前首条真实接口链路 + +### 4.1 模型列表 + +`GET /api/v1/operations/models?workspace_id=` + +后端先从 `model_deploy` 查询模型、银行、版本和 Workspace 映射,再从 `model_operations` 查询最新已发布监控结果和当前判级,最后组装前端 DTO。 + +### 4.2 模型详情 + +`GET /api/v1/operations/models/{model_id}?workspace_id=` + +按 `deploy.code` 或部署 ID 识别模型,返回与模型列表相同的数据口径,避免列表和详情出现两套计算结果。 + +### 4.3 单月监控结果 + +`GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM&workspace_id=` + +只读取 `batch_status='published'` 的批次,并按监控月份和修订号取当前结果。月份不存在或模型没有该月结果时返回业务层的“结果不存在”,前端保持详情页面结构。 + +### 4.4 结果关联要求 + +`ops_monitor_results` 需要能与 `model_deploy` 的部署行关联。当前查询服务支持以下任一关联标识: + +- `model_instance_id = model_deploy.id`; +- `source_result_ref = model_deploy.code`; +- `source_result_ref = model_deploy.id`; +- 版本标识与 `model_version.id` 对应。 + +如果模型方采用其他来源 ID,需要在写入协议中明确映射,不能让前端自行猜测。 + +## 5. 当前 API 实现状态 + +已经实现: + +1. `GET /api/v1/operations/health` +2. `GET /api/v1/operations/models` +3. `GET /api/v1/operations/models/{model_id}` +4. `GET /api/v1/operations/models/{model_id}/monitor-results` +5. `GET /api/v1/operations/workbench` + +尚未实现的后续 API 包括模型大类汇总、银行汇总、监控明细筛选、特征分布、处理复核、报告、流程、知识库、规则、Prompt 和系统配置写接口。 + +## 6. 登录与 RBAC + +- 登录继续使用现有 `/api/v1/auth/login` 和 Cookie 会话。 +- `/api/v1/auth/me` 返回当前用户、Workspace、`role_code` 和权限集合。 +- 前端将 `admin` 映射为管理员,将 `developer/model_team` 映射为模型团队,将 `business/business_team/biz` 映射为业务团队。 +- 运维查询接口使用 `operations_context` 校验登录状态和 Workspace 访问范围。 +- 平台用户、角色、权限接口继续使用现有 `system_admin_context`,不由运维模块重新实现。 +- 前端隐藏菜单只负责展示,服务端仍必须对写接口进行授权校验。 + +## 7. 当前缺口与决策项 + +1. **表模型口径尚未完全统一**:V0.2 设计过 `ops_banks/ops_model_instances/ops_model_versions` 作为模型方外部写入表,但当前首条接口实际查询的是 `model_deploy` 及其关联表。需要模型方确认最终以哪套表为主,并在确认后统一代码、DDL和文档。 +2. **非空数据缺口**:当前联调库模型和监控结果为空,尚未完成非空链路验收。 +3. **Workspace 映射缺口**:需要确认 `sys_project_space.name`、`sys_project_space.id`、`deploy.group_code` 与认证 Workspace 的最终映射。 +4. **监控结果关联缺口**:需要确认 `model_instance_id/model_version_id/source_result_ref` 的来源 ID 规则。 +5. **三角色验收缺口**:需要准备三个真实账号并完成菜单、页面、查询和写操作验收。 +6. **完整 API 文档缺口**:当前接口已注册,但请求/响应/错误码尚未形成独立对外清单。 + +## 8. 本周验收口径 + +本周目标只有在以下条件全部满足后才可标记完成: + +1. 架构、数据库、API 和工程骨架文档与当前代码一致。 +2. 三角色可以登录,角色展示和基础访问边界正确。 +3. 模型方提供最小非空数据。 +4. 列表、详情、单月结果三次请求成功。 +5. 三个页面的模型、版本、月份、排序性、KS、PSI 等字段一致。 +6. 空数据、无结果、不存在模型、无权限和跨 Workspace 请求均有明确结果。 diff --git a/docs/database/A卡运维平台-角色权限矩阵初始化-V0.1.sql b/docs/database/A卡运维平台-角色权限矩阵初始化-V0.1.sql new file mode 100644 index 0000000..9c8c03f --- /dev/null +++ b/docs/database/A卡运维平台-角色权限矩阵初始化-V0.1.sql @@ -0,0 +1,179 @@ +-- A 卡运维平台角色权限矩阵初始化 V0.1 +-- 目标库:model_platform +-- 说明: +-- 1. 本脚本只新增/恢复运维权限和业务团队角色,不删除既有权限。 +-- 2. 重复执行幂等;已有软删除权限/关联会被恢复。 +-- 3. admin、developer 使用现有角色;developer 对应模型团队。 +-- 4. 业务账号不会在本脚本中被批量改角色,需确认用户名后再分配 business_team。 + +USE model_platform; +SET NAMES utf8mb4; + +START TRANSACTION; + +-- 业务团队角色:不存在时创建,存在时恢复并更新展示信息。 +INSERT INTO roles ( + role_id, role_code, role_name, role_scope, is_builtin, + description, is_deleted, deleted_at +) VALUES ( + '0000000000VKFJ85E90FABHPGT', + 'business_team', + '业务团队', + 'platform', + 1, + 'A 卡运维平台业务团队角色', + 0, + NULL +) AS incoming_role +ON DUPLICATE KEY UPDATE + role_name = incoming_role.role_name, + role_scope = incoming_role.role_scope, + is_builtin = incoming_role.is_builtin, + description = incoming_role.description, + is_deleted = 0, + deleted_at = NULL; + +-- 运维页面和操作权限。 +INSERT INTO permissions ( + permission_id, permission_code, permission_name, module_code, + description, is_deleted, deleted_at +) VALUES + ('10000000000000000000000001', 'operations:workbench:view', '查看运维工作台', 'operations', '查看运维工作台、KPI、待办和关注', 0, NULL), + ('10000000000000000000000002', 'operations:usage:view', '查看平台使用统计', 'operations', '查看平台使用统计', 0, NULL), + ('10000000000000000000000003', 'operations:model-overview:view', '查看模型大类概览', 'operations', '查看模型大类概览', 0, NULL), + ('10000000000000000000000004', 'operations:bank-overview:view', '查看细分银行概览', 'operations', '查看细分银行概览', 0, NULL), + ('10000000000000000000000005', 'operations:deployed-models:view', '查看已上线模型详情', 'operations', '查看已上线模型详情', 0, NULL), + ('10000000000000000000000006', 'operations:monitoring-overview:view', '查看模型监控概览', 'operations', '查看模型监控概览和监控明细', 0, NULL), + ('10000000000000000000000007', 'operations:monitoring-detail:view', '查看模型监控详情', 'operations', '查看模型监控详情和指标分布', 0, NULL), + ('10000000000000000000000008', 'operations:monitoring-detail:model-review', '模型团队初审监控结果', 'operations', '模型团队处理监控结果并提交初审', 0, NULL), + ('10000000000000000000000009', 'operations:monitoring-detail:business-review', '业务团队终审监控结果', 'operations', '业务团队确认监控处理结果', 0, NULL), + ('1000000000000000000000000A', 'operations:report:view', '查看监控诊断报告', 'operations', '查看监控诊断报告', 0, NULL), + ('1000000000000000000000000B', 'operations:report:edit', '编辑监控诊断报告', 'operations', '编辑监控诊断报告内容', 0, NULL), + ('1000000000000000000000000D', 'operations:report:export', '导出监控诊断报告', 'operations', '导出监控诊断报告', 0, NULL), + ('1000000000000000000000000E', 'operations:report:send', '发送监控诊断报告', 'operations', '发送监控诊断报告', 0, NULL), + ('1000000000000000000000000F', 'operations:report-summary:view', '查看历史报告汇总', 'operations', '查看历史报告汇总', 0, NULL), + ('10000000000000000000000010', 'operations:workflow:view', '查看全流程进度', 'operations', '查看全流程进度', 0, NULL), + ('10000000000000000000000011', 'operations:workflow:create', '发起模型需求', 'operations', '业务团队发起模型开发或迭代需求', 0, NULL), + ('10000000000000000000000012', 'operations:workflow:feedback', '流程反馈', 'operations', '业务团队填写方案设计等反馈', 0, NULL), + ('10000000000000000000000013', 'operations:workflow:confirm', '流程确认', 'operations', '业务团队确认流程材料和结果', 0, NULL), + ('10000000000000000000000014', 'operations:workflow:submit-material', '提交流程材料', 'operations', '模型团队提交流程材料', 0, NULL), + ('10000000000000000000000015', 'operations:workflow:advance', '推进流程', 'operations', '模型团队推进流程环节', 0, NULL), + ('10000000000000000000000016', 'operations:workflow:online', '模型上线', 'operations', '模型团队提交模型上线信息', 0, NULL), + ('10000000000000000000000017', 'operations:knowledge:view', '查看文档知识库', 'operations', '按模型、名称和环节查看文档知识库', 0, NULL), + ('10000000000000000000000018', 'operations:rules:view', '查看监控等级规则', 'operations', '查看监控等级规则', 0, NULL), + ('10000000000000000000000019', 'operations:rules:publish', '发布监控等级规则', 'operations', '发布监控等级规则版本', 0, NULL), + ('1000000000000000000000001A', 'operations:rules:rollback', '回滚监控等级规则', 'operations', '回滚监控等级规则版本', 0, NULL), + ('1000000000000000000000001B', 'operations:rules:simulate', '测算监控等级规则', 'operations', '测算阈值变化影响', 0, NULL), + ('1000000000000000000000001C', 'operations:prompt:view', '查看报告 Prompt', 'operations', '查看报告 Prompt 管理页面', 0, NULL), + ('1000000000000000000000001D', 'operations:prompt:edit', '编辑报告 Prompt', 'operations', '编辑报告 Prompt', 0, NULL), + ('1000000000000000000000001E', 'operations:prompt:regression', '提交 Prompt 回归', 'operations', '提交 Prompt 回归测试', 0, NULL), + ('1000000000000000000000001F', 'operations:settings:view', '查看系统配置', 'operations', '查看和维护运维系统配置', 0, NULL) AS incoming_permission +ON DUPLICATE KEY UPDATE + permission_name = incoming_permission.permission_name, + module_code = incoming_permission.module_code, + description = incoming_permission.description, + is_deleted = 0, + deleted_at = NULL; + +-- 业务团队:我的工作台、上线/监控/报告/流程的业务侧能力。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.role_id, p.permission_id +FROM roles AS r +JOIN permissions AS p + ON p.permission_code IN ( + 'dashboard:view', + 'operations:workbench:view', + 'operations:model-overview:view', + 'operations:bank-overview:view', + 'operations:monitoring-overview:view', + 'operations:monitoring-detail:view', + 'operations:monitoring-detail:business-review', + 'operations:report:view', + 'operations:report:export', + 'operations:report-summary:view', + 'operations:workflow:view', + 'operations:workflow:create', + 'operations:workflow:feedback', + 'operations:workflow:confirm' + ) +WHERE r.role_code = 'business_team' + AND r.is_deleted = 0 + AND p.is_deleted = 0 +ON DUPLICATE KEY UPDATE + is_deleted = 0, + deleted_at = NULL; + +-- 模型团队:developer 角色对应模型团队。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.role_id, p.permission_id +FROM roles AS r +JOIN permissions AS p + ON p.permission_code IN ( + 'dashboard:view', + 'operations:workbench:view', + 'operations:model-overview:view', + 'operations:bank-overview:view', + 'operations:deployed-models:view', + 'operations:monitoring-overview:view', + 'operations:monitoring-detail:view', + 'operations:monitoring-detail:model-review', + 'operations:report:view', + 'operations:report:edit', + 'operations:report:export', + 'operations:report:send', + 'operations:report-summary:view', + 'operations:workflow:view', + 'operations:workflow:submit-material', + 'operations:workflow:advance', + 'operations:workflow:online', + 'operations:knowledge:view', + 'operations:rules:view', + 'operations:prompt:view', + 'operations:prompt:edit', + 'operations:prompt:regression' + ) +WHERE r.role_code = 'developer' + AND r.is_deleted = 0 + AND p.is_deleted = 0 +ON DUPLICATE KEY UPDATE + is_deleted = 0, + deleted_at = NULL; + +-- 管理员:拥有全部运维页面和操作权限;既有系统权限不受影响。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.role_id, p.permission_id +FROM roles AS r +JOIN permissions AS p + ON p.module_code = 'operations' + AND p.is_deleted = 0 +WHERE r.role_code = 'admin' + AND r.is_deleted = 0 +ON DUPLICATE KEY UPDATE + is_deleted = 0, + deleted_at = NULL; + +COMMIT; + +-- 执行后核验:应看到 admin、developer、business_team 三个角色的运维权限。 +SELECT + r.role_code, + r.role_name, + COUNT(p.permission_id) AS operations_permission_count +FROM roles AS r +LEFT JOIN role_permissions AS rp + ON rp.role_id = r.role_id + AND rp.is_deleted = 0 +LEFT JOIN permissions AS p + ON p.permission_id = rp.permission_id + AND p.module_code = 'operations' + AND p.is_deleted = 0 +WHERE r.role_code IN ('admin', 'developer', 'business_team') +GROUP BY r.role_code, r.role_name +ORDER BY FIELD(r.role_code, 'admin', 'developer', 'business_team'); + +-- 如需给某个业务账号分配业务团队角色,请先确认用户名,再单独执行: +-- UPDATE users +-- SET platform_role_id = (SELECT role_id FROM roles WHERE role_code = 'business_team'), +-- updated_at = CURRENT_TIMESTAMP(3) +-- WHERE username = '<确认后的业务账号>' +-- AND is_deleted = 0; diff --git a/docs/项目推进/A卡运维模块-本周目标完成情况-V0.1.md b/docs/项目推进/A卡运维模块-本周目标完成情况-V0.1.md new file mode 100644 index 0000000..9ce4750 --- /dev/null +++ b/docs/项目推进/A卡运维模块-本周目标完成情况-V0.1.md @@ -0,0 +1,165 @@ +# A 卡模型运维模块:本周目标完成情况 + +> 版本:V0.1 +> 日期:2026-09-02 +> 用途:内部开发推进、联调和周目标验收基线 + +## 一、结论 + +本周目标目前属于“工程基础已具备,真实非空数据验收未完成”。 + +技术架构、数据库设计初稿、前后端工程骨架和首条接口代码已经形成;登录与角色适配能力已有基础;“模型列表—模型详情—单月监控结果”三条接口也已经接入前端真实请求。 + +当前不能直接宣称本周目标全部完成,主要原因是联调数据库中的模型、版本和监控结果目前没有非空业务数据。因此现在验证到的是接口注册、响应结构、空数据降级和前端调用链路,还没有验证真实业务数据下的完整页面展示及数值一致性。 + +## 二、目标逐项状态 + +### 1. 技术架构 + +状态:已完成初稿,进入实现校准阶段。 + +已具备: + +1. 同一工程、平台库与运维库双数据库边界。 +2. `model_platform` 作为现有认证、Workspace、平台基础能力来源。 +3. `model_deploy` 作为模型部署、银行、版本等模型侧数据来源。 +4. `model_operations` 作为监控结果和运维业务数据来源。 +5. FastAPI、React、MySQL、对象存储和后续 Outbox/队列的职责边界。 +6. 架构图、数据域关系图和数据库设计稿。 + +仍需补齐: + +1. 把设计稿中的“计划实现”与当前已经实现的真实代码逐项对齐。 +2. 明确模型方写入 `model_deploy`、`model_operations` 的最终字段及批次协议。 +3. 补充部署环境的连接配置、账号权限和回滚方式。 + +### 2. 数据库模型初稿 + +状态:DDL 和表关系初稿已完成,等待 DBA/模型方确认和非空数据验证。 + +已具备: + +1. `model_operations` 建库建表脚本及校验脚本。 +2. 模型、版本、监控批次、监控结果、特征指标、分布、判级、复核、报告、流程和 Outbox 等表的初稿。 +3. 页面—表—数据责任关系说明。 +4. 模型方外部写入表的范围说明。 + +仍需补齐: + +1. 确认 `model_deploy` 和 `model_operations` 的实际表结构与字段类型。 +2. 确认银行表最终来源;当前银行主数据仍待模型方补充确认。 +3. DBA 执行建库/授权,或确认现有表已满足接口查询。 +4. 由模型方提供一组可追溯的非空测试数据。 +5. 对模型、版本、监控月份、修订号和发布状态补充唯一性与幂等约束。 + +### 3. API 清单 + +状态:首条链路接口已实现,完整 API 契约文档尚未完成。 + +当前已注册并可从 OpenAPI 查到: + +1. `GET /api/v1/operations/health` +2. `GET /api/v1/operations/models` +3. `GET /api/v1/operations/models/{model_id}` +4. `GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM` +5. `GET /api/v1/operations/workbench` + +仍需补齐: + +1. 将运维接口从通用 `API.md` 中拆出独立清单。 +2. 补充每个接口的请求参数、响应字段、状态码、错误码和权限要求。 +3. 明确百分比单位、月份格式、空值规则和分页规则。 +4. 增加模型大类、银行概览、监控明细、报告、流程、知识库和配置接口清单。 + +### 4. 前后端工程骨架 + +状态:已完成首期骨架。 + +已具备: + +1. FastAPI 运维路由、依赖、服务和查询层目录。 +2. React 运维路由、数据上下文、API service 和页面骨架。 +3. 前端运维模块已关闭生产 Mock 数据,统一请求后端接口。 +4. 缺少真实数据时,页面保留结构并显示 0、— 或空列表。 +5. 5173 当前功能分支和 5174 `develop` 对照环境均可独立启动。 + +仍需补齐: + +1. 其余页面从占位/空状态逐步接入后端资源接口。 +2. 报告、流程、处理、导出和配置等写操作接口。 +3. 前端统一处理接口错误、权限不足、空数据和加载状态。 +4. CI 中固定执行前端 typecheck/build 和后端测试。 + +### 5. 三角色登录与 RBAC 基础能力 + +状态:登录和角色适配已有,三角色真实账号验收未完成。 + +已具备: + +1. 继续复用现有登录接口和 Cookie 会话。 +2. 登录返回 `role_code`、用户信息、Workspace 和权限集合。 +3. 前端可区分业务团队、模型团队和管理员。 +4. 系统管理的用户、角色、项目等既有功能继续复用。 +5. 运维 API 使用 Workspace 访问校验和角色映射。 + +仍需补齐: + +1. 分别使用业务团队、模型团队、管理员账号完成登录验收。 +2. 验证三种角色的菜单可见范围、页面可见范围和按钮可见范围。 +3. 验证后端不能仅依赖前端隐藏按钮,而是对写接口再次进行角色校验。 +4. 明确运维模块与既有权限模块的最终责任边界。 + +### 6. 首条真实接口纵向链路 + +状态:代码链路已跑通,真实非空业务链路未验收。 + +目标链路: + +`model_deploy / model_operations` 数据 → 后端模型查询 → 模型列表 → 模型详情 → 单月监控结果 → 前端详情展示。 + +已验证: + +1. 三个接口已注册。 +2. 前端已从 Mock 切换为真实 API 请求。 +3. 缺表时后端可降级为空数据,页面不再跳转到说明页。 +4. 空库时列表、工作台和相关页面能够保留结构并显示空状态。 +5. 前端类型检查和生产构建通过。 + +未完成: + +1. 模型方写入至少 1 个银行、1 个模型、1 个版本和 1 个月监控结果。 +2. 使用登录会话从列表点击进入详情。 +3. 从详情进入指定月份监控结果。 +4. 核对列表、详情和监控结果中的模型名称、版本、KS、PSI、排序性和处理状态一致。 +5. 验证不同 Workspace、无权限和不存在模型的返回结果。 + +## 三、当前最关键的三个缺口 + +1. **没有非空真实数据**:这是首条纵向链路还不能验收的首要原因。 +2. **三角色没有完成真实账号矩阵验收**:现有代码具备基础,但还没有形成可签字的验收记录。 +3. **设计文档与实现状态未同步**:现有架构 V0.2 仍写着“接口尚未实现”,需要新版本勘误。 + +## 四、现在可以先写的文档 + +以下内容不依赖全部业务功能完成,可以立即起草: + +1. **技术架构与实现说明 V0.3**:以当前双数据库、FastAPI、React 和真实接口代码为准,标出已实现/待实现边界。 +2. **数据库模型与外部写入协议 V0.2**:列出模型方需要写入的表、字段、写入时机、批次状态、幂等和发布规则。 +3. **运维 API 清单 V0.1**:先固化已实现的 5 个接口,再把后续接口标为规划项。 +4. **三角色登录/RBAC 验收说明 V0.1**:列出角色、菜单、页面、接口和写操作验收项。 +5. **首条纵向链路联调手册 V0.1**:写清测试数据准备、请求顺序、预期响应和数值核对点。 +6. **本周目标验收记录 V0.1**:等模型方提供非空数据后,直接补充实际请求和截图结果。 + +## 五、建议本周剩余顺序 + +1. 先让模型方补一套最小非空数据,不等待全部页面完成。 +2. 先完成列表—详情—单月结果的真实联调和数值核对。 +3. 同时用三种角色做登录、菜单和接口权限验收。 +4. 根据联调结果更新架构 V0.3、数据库外部写入协议 V0.2 和 API 清单 V0.1。 +5. 最后再扩展工作台、监控明细、报告和流程等页面的接口。 + +## 六、当前不可对外承诺的内容 + +在非空数据和三角色验收完成前,不建议对外表述为“首条真实接口链路已完整跑通”。更准确的表述是: + +> 已完成首条真实接口链路的工程实现、接口注册、前端接入和空数据验证;待模型方提供最小非空数据后完成业务数据联调验收。 diff --git a/frontend/.env.example b/frontend/.env.example index fa18d9f..c5fa16e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,5 @@ # Model-platform gateway used by the Vite development proxy. VITE_GATEWAY_URL=http://127.0.0.1:8890 -# "mock" keeps the A-card operations pages on local frontend fixtures. -# Switch to "api" when /api/v1/operations endpoints are available. -VITE_OPERATIONS_API_MODE=mock +# 运维页面只允许通过后端真实接口取数。 +VITE_OPERATIONS_API_MODE=api diff --git a/frontend/README.md b/frontend/README.md index fa2aa1f..80505e4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -19,7 +19,7 @@ pnpm install pnpm dev ``` -运维模块默认支持前端 Mock;当前本地 `frontend/.env` 已切换为后端 API: +运维模块只通过后端真实 API 取数;当前本地 `frontend/.env` 配置为: ```bash VITE_OPERATIONS_API_MODE=api diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index 5d7f981..192c8ee 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -13,6 +13,7 @@ type TopbarProps = { onSetWorkspaceMenuOpen: (open: boolean) => void; onSetCurrentWorkspace: (workspaceId: string) => void; onLogout: () => void; + onBack: () => void; }; export function Topbar({ @@ -25,6 +26,7 @@ export function Topbar({ onSetWorkspaceMenuOpen, onSetCurrentWorkspace, onLogout, + onBack, }: TopbarProps) { const [userMenuOpen, setUserMenuOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); @@ -57,6 +59,8 @@ export function Topbar({ diff --git a/frontend/app/features/admin/PermissionCheckboxList.tsx b/frontend/app/features/admin/PermissionCheckboxList.tsx index 1ae2c0b..d0b6b01 100644 --- a/frontend/app/features/admin/PermissionCheckboxList.tsx +++ b/frontend/app/features/admin/PermissionCheckboxList.tsx @@ -15,6 +15,7 @@ const MODULE_LABELS: Record = { script: "构建脚本", schedule: "调度配置", system: "系统管理", + operations: "运维工作台", }; /** 勾选框样式:复刻原生 checkbox 外观(白底、灰边、蓝色勾选),并对齐 permission item 的 3px 顶部偏移。 */ diff --git a/frontend/app/features/admin/UserFormDialog.tsx b/frontend/app/features/admin/UserFormDialog.tsx index 12ad2c3..9fea60a 100644 --- a/frontend/app/features/admin/UserFormDialog.tsx +++ b/frontend/app/features/admin/UserFormDialog.tsx @@ -18,7 +18,7 @@ export type UserFormState = { username: string; display_name: string; email: string; - role_code: "admin" | "developer"; + role_code: string; password: string; status: "active" | "disabled" | "locked"; }; diff --git a/frontend/app/features/operations/BankOverviewPage.tsx b/frontend/app/features/operations/BankOverviewPage.tsx index 9eb04cb..a452858 100644 --- a/frontend/app/features/operations/BankOverviewPage.tsx +++ b/frontend/app/features/operations/BankOverviewPage.tsx @@ -1,7 +1,6 @@ import { useMemo, useRef, useState } from "react"; import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react"; import { useNavigate, useSearchParams } from "react-router"; -import { toast } from "sonner"; import { Button } from "~/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; @@ -37,13 +36,12 @@ type BankCategoryCard = { modelCount: number; versions: number; latestIteration: string; - averageCycle: number; + averageCycle: number | null; ownKs: number; ownPsi: number; peerKs: number; peerPsi: number; grades: Record; - demo?: boolean; }; function isCategoryId(value: string | null): value is ModelCategoryId { @@ -114,14 +112,13 @@ export default function BankOverviewPage() { const grades = bankModels.reduce>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 }); return { ...item, models: bankModels, modelCount: bankModels.length, versions: new Set(bankModels.map((model) => model.version)).size, latestIteration: latestIterationDate(bankModels), averageCycle: averageIterationCycle(bankModels), ownKs: average(bankModels.map((model) => model.ks)), ownPsi: average(bankModels.map((model) => model.psi)), peerKs: average(allPeers.map((model) => model.ks)), peerPsi: average(allPeers.map((model) => model.psi)), grades }; }); - return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], modelCount: 1, versions: 1, latestIteration: "2026-07-18", averageCycle: 11, ownKs: 37.9, ownPsi: 16.6, peerKs: 39.6, peerPsi: 14.2, grades: { A: 0, B: 1, C: 0 }, demo: true }]; + return realCards; }, [bank, models]); const syncParams = (nextBank: string, nextCategory: ModelCategoryId) => setSearchParams({ bank: nextBank, category: nextCategory }); const selectBank = (value: string) => { setBank(value); syncParams(value, category); }; const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); syncParams(bank, value); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); }; const openGrade = (item: BankCategoryCard, grade: ModelGrade) => { - if (item.demo) return toast.info("该卡片仅用于展示细分银行第 5 个大类的纵向滚动效果"); const gradeModels = item.models.filter((model) => gradeOf(model) === grade); if (gradeModels.length === 1) return navigate(`/operations/monitoring/${gradeModels[0]?.modelId}`); setDrilldown({ category: item.id as ModelCategoryId, grade, models: gradeModels }); @@ -129,7 +126,7 @@ export default function BankOverviewPage() { return (
-
+
navigate("/operations/deployed-models")}>查看已上线模型 } />

选择银行后,卡片展示本行指标及同业对比

@@ -137,12 +134,12 @@ export default function BankOverviewPage() { {cards.map((item) => { const total = item.grades.A + item.grades.B + item.grades.C || 1; - const selected = !item.demo && item.id === category; + const selected = item.id === category; const hasModels = item.modelCount > 0; - const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同结构") : selectCategory(item.id as ModelCategoryId, true); - return { if (event.key === "Enter" || event.key === " ") activate(); }}>{item.name}{item.demo ? "扩展示意" : `银行-${bank}`} + const activate = () => selectCategory(item.id as ModelCategoryId, true); + return { if (event.key === "Enter" || event.key === " ") activate(); }}>{item.name}银行-{bank || "全部"}
{[["银行数", hasModels ? 1 : 0], ["模型数", item.modelCount], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
-
最近迭代日期
{item.latestIteration}
平均迭代周期
{hasModels ? `${item.averageCycle.toFixed(1)}月` : "—"}
+
最近迭代日期
{item.latestIteration}
平均迭代周期
{hasModels && item.averageCycle !== null ? `${item.averageCycle.toFixed(1)}月` : "—"}
本行平均 KS
{hasModels ? `${item.ownKs.toFixed(1)}%` : "—"}
本行平均 PSI
{hasModels ? `${item.ownPsi.toFixed(1)}%` : "—"}
同业平均 KS
{item.peerKs.toFixed(1)}%
同业平均 PSI
{item.peerPsi.toFixed(1)}%
event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => openGrade(item, grade)} />)}
diff --git a/frontend/app/features/operations/DeployedModelsPage.tsx b/frontend/app/features/operations/DeployedModelsPage.tsx index 34ea784..21737ba 100644 --- a/frontend/app/features/operations/DeployedModelsPage.tsx +++ b/frontend/app/features/operations/DeployedModelsPage.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from "react"; -import { ArrowRight, Download, FileSpreadsheet, FolderOpen, Upload } from "lucide-react"; +import { ArrowRight, Download, FileSpreadsheet, Upload } from "lucide-react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; @@ -10,7 +10,7 @@ import { usePersistentState } from "~/hooks/use-persistent-state"; import { exportRowsToExcel } from "~/lib/exportExcel"; import { FilterSelect, OperationsPageHeader, StatusBadge } from "./OperationsUi"; import { useOperationsData } from "./OperationsDataContext"; -import { MODEL_LIFECYCLE, type ModelRecord } from "./modelData"; +import type { ModelRecord } from "./modelData"; type Filters = { bank: string; @@ -23,11 +23,6 @@ function unique(values: string[]): string[] { return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN")); } -function metricSeries(model: ModelRecord, base: readonly number[], variance: number): number[] { - const seed = [...model.modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0); - return base.map((value, index) => Number((value * (1 + ((seed >> index) % 7 - 3) / variance)).toFixed(2))); -} - function BarList({ values, labels, @@ -43,13 +38,13 @@ function BarList({ const colorClass = tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : "bg-brand"; return (
- {values.map((value, index) => ( + {values.length ? values.map((value, index) => (
{labels[index]} {value.toFixed(2)}{suffix}
- ))} + )) :
暂无指标数据
}
); } @@ -69,11 +64,10 @@ export default function DeployedModelsPage() { && (!filters.modelType || (model.commonModel ? "通用模型" : "个性化模型") === filters.modelType) )); const model = filtered.find((item) => item.modelId === selectedModelId) ?? filtered[0] ?? null; - const lifecycle = model ? MODEL_LIFECYCLE[model.modelId] : null; const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"]; - const rankingRates = model ? metricSeries(model, [9.8, 7.2, 5.1, 3.4, 2.0, 1.1], 28) : []; - const liftValues = model ? metricSeries(model, [3.2, 2.4, 1.7, 1.1, 0.7, 0.4], 20) : []; - const psiValues = model ? metricSeries(model, [1.2, 0.9, 0.6, 0.5, 0.4, 0.3], 20) : []; + const rankingRates: number[] = []; + const liftValues: number[] = []; + const psiValues: number[] = []; const scoreFile = model ? scoreFiles[model.modelId] : undefined; const update = (key: K, value: Filters[K]) => { @@ -96,18 +90,13 @@ export default function DeployedModelsPage() { }; const downloadScoreFile = () => { - if (!model) return; - void exportRowsToExcel({ - fileName: scoreFile?.name.replace(/\.xlsx?$/i, "") ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑`, - sheetName: "评分逻辑", - headers: ["入模特征", "划分区间", "对应评分"], - rows: [["age", "[18,25)", 12], ["age", "[25,35)", 26], ["income", "[0,5000)", 8], ["income", "[5000,+)", 31], ["query_3m", "[0,2]", 22], ["query_3m", "[3,+)", 6]], - }); + if (!model || !scoreFile) return; + toast.info("评分逻辑文件下载接口尚未接入"); }; return (
-
+
- {model && lifecycle ? ( + {model ? ( <> @@ -148,12 +137,12 @@ export default function DeployedModelsPage() { {[ ["银行 × 模型ID", `${model.bank} × ${model.modelId}`], ["版本", model.version], - ["开发人员", lifecycle.developer], - ["上线日期", lifecycle.onlineAt ?? "—"], + ["开发人员", "—"], + ["上线日期", "—"], ["最近迭代", model.iteratedAt], - ["陪跑开始", lifecycle.escortStartAt ?? "—"], - ["陪跑结束", lifecycle.escortEndAt ?? "—"], - ["下线日期", lifecycle.offlineAt ?? "—"], + ["陪跑开始", "—"], + ["陪跑结束", "—"], + ["下线日期", "—"], ["状态", model.status], ].map(([label, value]) => (
{label}{value}
@@ -167,11 +156,11 @@ export default function DeployedModelsPage() {

开发时点排序性相符:评分越高,坏客户占比越低。

- 开发时点 · KS 与 LIFTKS {lifecycle.developmentKs.toFixed(1)}% · 最高 LIFT {lifecycle.maxLift.toFixed(2)} + 开发时点 · KS 与 LIFT暂无开发时点指标 - 开发时点 · PSI建模样本与 OOT 样本对比 · PSI {lifecycle.developmentPsi.toFixed(2)}% + 开发时点 · PSI暂无开发时点指标

上线后的滚动 PSI 请进入模型监控详情查看。

@@ -182,7 +171,7 @@ export default function DeployedModelsPage() {
- {scoreFile?.name ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑.xlsx`}{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : `模型团队 ${lifecycle.developer} · ${lifecycle.onlineAt ?? model.iteratedAt}`} + {scoreFile?.name ?? "暂无评分逻辑文件"}{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : "未关联评分逻辑文件"}
{ uploadScoreFile(event.target.files?.[0]); event.target.value = ""; }} /> @@ -196,9 +185,7 @@ export default function DeployedModelsPage() { 材料环节操作 - {[ - ["模型设计方案", "方案设计"], ["开发结果材料", "开发迭代"], ["新老模型对比", "模型验证"], ["评审会议纪要", "评审决议"], ["一致性报告", "测试陪跑"], - ].map(([name, stage]) => {name}{stage})} + 暂无模型开发材料
diff --git a/frontend/app/features/operations/KnowledgeBasePage.tsx b/frontend/app/features/operations/KnowledgeBasePage.tsx index 1cfff34..def8d93 100644 --- a/frontend/app/features/operations/KnowledgeBasePage.tsx +++ b/frontend/app/features/operations/KnowledgeBasePage.tsx @@ -48,7 +48,7 @@ export default function KnowledgeBasePage() { return (
-
+
= { std: 9.3, bai: 12, big: 8, afd: 14 }; - type CategoryCardItem = { id: string; name: string; @@ -37,11 +35,10 @@ type CategoryCardItem = { banks: number; versions: number; latestIteration: string; - averageCycle: number; + averageCycle: number | null; averageKs: number; averagePsi: number; gradeCounts: Record; - demo?: boolean; }; function isCategoryId(value: string | null): value is ModelCategoryId { @@ -105,9 +102,9 @@ export default function ModelOverviewPage() { const realCards = MODEL_CATEGORIES.map((item) => { const categoryModels = models.filter((model) => model.category === item.id && model.status !== "下线"); const gradeCounts = categoryModels.reduce>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 }); - return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: CATEGORY_AVERAGE_CYCLE[item.id], averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts }; + return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: averageIterationCycle(categoryModels), averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts }; }); - return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], banks: 5, versions: 4, latestIteration: "2026-07-18", averageCycle: 10.6, averageKs: 39.6, averagePsi: 14.2, gradeCounts: { A: 2, B: 2, C: 1 }, demo: true }]; + return realCards; }, [models]); const selectCategory = (value: ModelCategoryId, scroll = false) => { @@ -117,26 +114,25 @@ export default function ModelOverviewPage() { }; const openGrade = (item: CategoryCardItem, grade: ModelGrade) => { - if (item.demo) return toast.info("该卡片仅用于展示第 5 个及以上模型大类的纵向滚动效果"); setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) }); }; return (
-
+
navigate("/operations/monitoring")}>查看监控明细 } /> {categoryCards.map((item) => { const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1; - const selected = !item.demo && item.id === category; - const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同卡片结构") : selectCategory(item.id as ModelCategoryId, true); + const selected = item.id === category; + const activate = () => selectCategory(item.id as ModelCategoryId, true); return ( { if (event.key === "Enter" || event.key === " ") activate(); }}> - {item.name}{item.demo ? "扩展示意" : "全部银行"} + {item.name}全部银行 -
{[["银行数", item.banks], ["模型数", item.demo ? 5 : item.models.length], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
-
最近迭代日期
{item.latestIteration}
平均迭代周期
{item.averageCycle.toFixed(1)}月
+
{[["银行数", item.banks], ["模型数", item.models.length], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
+
最近迭代日期
{item.latestIteration}
平均迭代周期
{item.averageCycle === null ? "—" : `${item.averageCycle.toFixed(1)}月`}
平均 KS
{item.averageKs.toFixed(1)}%
平均 PSI
{item.averagePsi.toFixed(1)}%
event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => openGrade(item, grade)} />)}
diff --git a/frontend/app/features/operations/MonitoringDetailPage.tsx b/frontend/app/features/operations/MonitoringDetailPage.tsx index 10a9703..1d3f824 100644 --- a/frontend/app/features/operations/MonitoringDetailPage.tsx +++ b/frontend/app/features/operations/MonitoringDetailPage.tsx @@ -27,9 +27,8 @@ import { modelTrend, monthsBetween, } from "./modelData"; -import { REPORTS } from "./reportData"; import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext"; -import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole"; +import { useCanOperationsAction } from "./operationsRole"; function recentMonths(count: number): string[] { const end = 2026 * 12 + 6; @@ -43,7 +42,6 @@ export default function MonitoringDetailPage() { const navigate = useNavigate(); const params = useParams(); const { models } = useOperationsData(); - const operationsRole = useOperationsRole(); const modelId = params.modelId ?? models[0]?.modelId ?? ""; const detail = useOperationsModelDetail(modelId, "2026-07"); const baseModel = detail.model ?? models.find((item) => item.modelId === modelId) ?? models[0]; @@ -57,6 +55,7 @@ export default function MonitoringDetailPage() { const [reviewDecision, setReviewDecision] = useState("暂不处理"); const [reviewNote, setReviewNote] = useState(""); const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : recentMonths(Number(range)); + const monitorMonth = detail.monitoringResult?.monitorMonth ?? ""; const ksTrend = modelTrend(model, "ks", months); const psiTrend = modelTrend(model, "psi", months); const compareModel = models.find((item) => item.modelId === compareId && item.modelId !== model.modelId) ?? null; @@ -64,12 +63,11 @@ export default function MonitoringDetailPage() { const comparePsiTrend = compareModel ? modelTrend(compareModel, "psi", months) : null; const grade = gradeOf(model); const abnormal = abnormalLevelOf(model); - const linkedReport = REPORTS.find((report) => report.modelId === model.modelId && report.monitorMonth === "2026-07") ?? null; const chosenFeature = FEATURE_METRICS.find((item) => item.key === selectedFeature) ?? null; const ivTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.ivDrop - left.ivDrop), []); const csiTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.csiRise - left.csiRise), []); - const canInitialReview = isOperationsActionVisibleForRole(operationsRole, "monitor:initial-review"); - const canFinalReview = isOperationsActionVisibleForRole(operationsRole, "monitor:final-review"); + const canInitialReview = useCanOperationsAction("monitor:initial-review"); + const canFinalReview = useCanOperationsAction("monitor:final-review"); const effectiveReviewStage = grade === "A" ? 2 : reviewStage; const canReview = grade !== "A" && ((reviewStage === 0 && canInitialReview) || (reviewStage === 1 && canFinalReview)); @@ -97,7 +95,7 @@ export default function MonitoringDetailPage() { }, [modelId]); if (detail.loading) { - return
; + return
; } if (detail.error) { @@ -106,7 +104,7 @@ export default function MonitoringDetailPage() { return (
-
+
① 监控结论 - 2026-07 监控周期 · 结果优先展示 + {monitorMonth ? `${monitorMonth} 监控周期 · 结果优先展示` : "暂无监控结果"} @@ -213,8 +211,8 @@ export default function MonitoringDetailPage() {
关联报告 -

{grade === "A" ? "模型监控报告" : "模型诊断报告"} · 2026-07 · 输出日期 2026-08-15

- +

{monitorMonth ? `${grade === "A" ? "模型监控报告" : "模型诊断报告"} · ${monitorMonth}` : "暂无关联报告"}

+
@@ -239,7 +237,7 @@ export default function MonitoringDetailPage() {
- 排序性趋势(当月)2026-07 · 蓝色柱为客户数,橙色折线为坏客户占比 + 排序性趋势(当月){monitorMonth || "当期"} · 蓝色柱为客户数,橙色折线为坏客户占比 @@ -312,22 +310,9 @@ export default function MonitoringDetailPage() {
-
{chosenFeature.name}({chosenFeature.key})分布变化

对比基准期与 2026-07 当期各分箱占比,正式数据由共享指标库读取。

-
-
- {[38, 27, 18, 11, 6].map((reference, index) => { - const current = Math.max(2, reference + [4, -3, 2, -1, -2][index]); - return ( -
- 分箱 {index + 1} -
-
基准期{reference}%
-
当期{current}%
-
-
- ); - })} +
{chosenFeature.name}({chosenFeature.key})分布变化

对比基准期与当期各分箱占比,正式数据由后端接口读取。

+
暂无特征分布数据
)} diff --git a/frontend/app/features/operations/MonitoringOverviewPage.tsx b/frontend/app/features/operations/MonitoringOverviewPage.tsx index a588cd2..d4fcb3a 100644 --- a/frontend/app/features/operations/MonitoringOverviewPage.tsx +++ b/frontend/app/features/operations/MonitoringOverviewPage.tsx @@ -179,7 +179,7 @@ export default function MonitoringOverviewPage() { return (
-
+
void exportMonitoringExcel(sortedRows)}>导出 Excel} />
diff --git a/frontend/app/features/operations/OperationsDataContext.tsx b/frontend/app/features/operations/OperationsDataContext.tsx index 05c2069..ba8c164 100644 --- a/frontend/app/features/operations/OperationsDataContext.tsx +++ b/frontend/app/features/operations/OperationsDataContext.tsx @@ -1,5 +1,5 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Database, RefreshCw, TriangleAlert } from "lucide-react"; +import { RefreshCw, TriangleAlert } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; @@ -8,7 +8,10 @@ import { useAuth } from "~/context/AuthContext"; import { getMonthlyMonitoringResult, getOperationsModel, + getOperationsWorkbench, listOperationsModels, + OperationsApiError, + type OperationsWorkbenchDto, operationsApiMode, type OperationsApiMode, } from "~/services/operationsApi"; @@ -16,6 +19,7 @@ import type { ModelRecord, MonitoringRow } from "./modelData"; type OperationsDataContextValue = { models: ModelRecord[]; + workbench: OperationsWorkbenchDto; loading: boolean; error: string | null; source: OperationsApiMode; @@ -24,10 +28,20 @@ type OperationsDataContextValue = { const OperationsDataContext = createContext(null); +const EMPTY_WORKBENCH: OperationsWorkbenchDto = { + role: "model_team", + alerts: [], + kpis: [], + todos: [], + watches: [], + activities: [], +}; + export function OperationsDataProvider({ children }: { children: ReactNode }) { const { currentWorkspace } = useAuth(); const workspaceId = currentWorkspace?.workspace_id; const [models, setModels] = useState([]); + const [workbench, setWorkbench] = useState(EMPTY_WORKBENCH); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -35,7 +49,13 @@ export function OperationsDataProvider({ children }: { children: ReactNode }) { setLoading(true); setError(null); try { - setModels(await listOperationsModels({ workspaceId, signal })); + const [modelsResult, workbenchResult] = await Promise.allSettled([ + listOperationsModels({ workspaceId, signal }), + getOperationsWorkbench(workspaceId, signal), + ]); + if (modelsResult.status === "rejected") throw modelsResult.reason; + setModels(modelsResult.value); + setWorkbench(workbenchResult.status === "fulfilled" ? workbenchResult.value : EMPTY_WORKBENCH); } catch (cause) { if (cause instanceof DOMException && cause.name === "AbortError") return; setModels([]); @@ -53,11 +73,12 @@ export function OperationsDataProvider({ children }: { children: ReactNode }) { const value = useMemo(() => ({ models, + workbench, loading, error, source: operationsApiMode, reload: () => load(), - }), [error, load, loading, models]); + }), [error, load, loading, models, workbench]); return {children}; } @@ -83,7 +104,12 @@ export function useOperationsModelDetail(modelId: string, month: string) { setError(null); void Promise.all([ getOperationsModel(modelId, workspaceId, controller.signal), - getMonthlyMonitoringResult(modelId, month, workspaceId, controller.signal), + getMonthlyMonitoringResult(modelId, month, workspaceId, controller.signal).catch((cause) => { + if (cause instanceof OperationsApiError && cause.code === "MONITOR_RESULT_NOT_FOUND") { + return null; + } + throw cause; + }), ]).then(([modelValue, resultValue]) => { setModel(modelValue); setMonitoringResult(resultValue); @@ -106,11 +132,11 @@ export function useOperationsModelDetail(modelId: string, month: string) { } export function OperationsDataBoundary({ children }: { children: ReactNode }) { - const { loading, error, models, reload, source } = useOperationsData(); + const { loading, error, reload } = useOperationsData(); if (loading) { return (
-
+
{Array.from({ length: 4 }, (_, index) => )}
@@ -128,7 +154,7 @@ export function OperationsDataBoundary({ children }: { children: ReactNode }) {

运维数据加载失败

{error}

-

当前数据源:{source === "api" ? "真实接口" : "前端 Mock"}

+

当前数据源:真实接口

@@ -136,13 +162,5 @@ export function OperationsDataBoundary({ children }: { children: ReactNode }) { ); } - if (!models.length) { - return ( -
-

暂无模型数据

请确认模型平台已同步模型信息,或调整接口环境配置。

-
- ); - } - return children; } diff --git a/frontend/app/features/operations/OperationsUi.tsx b/frontend/app/features/operations/OperationsUi.tsx index 731bd50..3adfd35 100644 --- a/frontend/app/features/operations/OperationsUi.tsx +++ b/frontend/app/features/operations/OperationsUi.tsx @@ -118,6 +118,7 @@ export function FilterSelect({ onChange, allLabel = "全部", className, + disabled = false, }: { label: string; value: string; @@ -125,6 +126,7 @@ export function FilterSelect({ onChange: (value: string) => void; allLabel?: string; className?: string; + disabled?: boolean; }) { return (