diff --git a/.env.example b/.env.example index 7ea6fae..2be54ac 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,35 @@ MYSQL_USER=root MYSQL_PASSWORD=change-me MYSQL_DATABASE=model_platform DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_platform?charset=utf8mb4 +# 运维模块独立库;省略时后端自动复用 DATABASE_URL 的账号和主机, +# 仅将数据库名切换为 model_operations。 +OPERATIONS_DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_operations?charset=utf8mb4 +# 运维模块访问另外两库时使用专用只读 Session;生产建议配置 DBA +# 创建的只读账号,不要复用 root。 +PLATFORM_READ_DATABASE_URL=mysql+asyncmy://readonly:change-me@127.0.0.1:3306/model_platform?charset=utf8mb4 +DEPLOY_DATABASE_URL=mysql+asyncmy://readonly:change-me@127.0.0.1:3306/model_deploy?charset=utf8mb4 + +# Redis is an optional accelerator: cache + Streams only. MySQL Outbox remains +# the reliable source of truth when Redis is unavailable. +REDIS_URL=redis://127.0.0.1:6379/0 +OPERATIONS_CACHE_TTL_SECONDS=300 +OPERATIONS_REDIS_PREFIX=model-platform:operations +OPERATIONS_EVENT_STREAM=model-platform:operations:events +OPERATIONS_EVENT_STREAM_MAXLEN=10000 JWT_SECRET=change-this-development-secret +# AES-256 configuration decryption key used only when a value is wrapped in ENC(...). +# Replace before deployment; keep the same value across all four services. +APP_CONFIG_SECRET_KEY=change-this-config-secret-key + +# Force the Secure flag on the session cookie even when the inbound request +# scheme is plain HTTP. Enable behind a TLS-terminating reverse proxy that +# strips/rewrites X-Forwarded-Proto — otherwise the cookie is written without +# Secure and browsers refuse to send it back over HTTPS. +COOKIE_FORCE_SECURE=false + +# Service label surfaced in lifespan / health checks. +SERVICE_NAME=service # Force the Secure flag on the session cookie even when the inbound request # scheme is plain HTTP. Enable behind a TLS-terminating reverse proxy that diff --git a/CLAUDE.md b/CLAUDE.md index d106ebb..2b5069c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,9 @@ - `migrations` — Alembic schema + seed migrations. - `nginx` — static frontend, `/api/` proxy, authenticated `/jupyter/` proxy. -Redis and the former separate Storage API container are intentionally removed. +Redis is reintroduced only for the A-card operations query cache and event +Streams. MySQL Outbox remains authoritative, and Redis outages must not block +core APIs. The former separate Storage API container remains removed. ## Commands @@ -135,4 +137,4 @@ Lessons from splitting `frontend/app/features/schedules/state/schedulesStore.ts` - **前端 — 页面 vs 路由 wrapper**:Page 组件持有 useState,route 文件只做 `` 重挂载,二者不要混在一起。 - **拆分前先列调用面**(`grep "from "`),任何外部 import 路径必须仍然可用 — 用 re-export 或 shim 兜底,不要让调用方被迫改。 - **拆分后**每个新文件 ≤ 500 行是硬约束,验证方式:`wc -l ` 或 CI 脚本。 -- 拆分本身是**纯结构调整**,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。 \ No newline at end of file +- 拆分本身是**纯结构调整**,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。 diff --git a/backend/Dockerfile b/backend/Dockerfile index 85b5977..7299145 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,6 +14,8 @@ COPY common ./common COPY backend ./backend COPY alembic.ini ./ COPY migrations ./migrations +COPY operations-alembic.ini ./ +COPY operations_migrations ./operations_migrations RUN uv sync --frozen --no-dev --package backend diff --git a/backend/README.md b/backend/README.md index 5e943a5..e7d42f1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,4 +5,43 @@ 模块合并,外部 REST 契约保持不变。 底层走的是 `common.storage.AsyncStorageBackend` 抽象,按 -`settings.storage_backend` 切换 s3 / local 两种实现。 \ No newline at end of file +`settings.storage_backend` 切换 s3 / local 两种实现。 + +## A 卡模型运维模块 + +运维能力作为同一 FastAPI 进程内的独立 bounded context 接入: + +- 平台库继续使用 `DATABASE_URL`,负责登录、角色和工作空间校验。 +- 运维库使用 `OPERATIONS_DATABASE_URL`;未配置时自动复用平台库连接信息, + 仅将库名切换为 `model_operations`。 +- `PLATFORM_READ_DATABASE_URL` 与 `DEPLOY_DATABASE_URL` 使用独立只读 + Session;MySQL 事务会显式执行 `SET TRANSACTION READ ONLY`。 +- 三套 SQLAlchemy engine/session factory 完全分离:模型、版本和银行基础信息从 + `model_deploy` 只读查询,监控结果与运维业务数据从 `model_operations` 查询或写入。 +- 当前已落地 P0 ORM,以及模型列表、模型详情、指定月份监控结果三条接口。 +- 运维模型接口联查 `model_deploy` 与 `model_operations`;模型方写入部署/银行/版本 + 信息和监控结果后,列表、模型详情、工作台及指定月份监控结果由后端统一返回。 +- Redis作为可选加速层接入:缓存聚合查询并承载通知 Streams;MySQL + Outbox仍是可靠事实源,Redis不可用时主接口继续运行。 + +本地启动: + +```bash +uv run --package backend --env-file .env uvicorn backend.main:app \ + --host 0.0.0.0 --port 8010 --reload +``` + +运维模块探针: + +```text +GET /api/v1/operations/health +``` + +真实业务接口均需登录 Cookie 和 `workspace_id` 查询参数: + +```text +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/pyproject.toml b/backend/pyproject.toml index 7ae9d3a..482c432 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "bcrypt>=4.0,<4.1", "loguru>=0.7.2", "aiofiles>=25.1.0", + "redis[hiredis]>=6.2,<7", ] [tool.uv.sources] diff --git a/backend/src/backend/api/operations/__init__.py b/backend/src/backend/api/operations/__init__.py new file mode 100644 index 0000000..2fbce3b --- /dev/null +++ b/backend/src/backend/api/operations/__init__.py @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..632c519 --- /dev/null +++ b/backend/src/backend/api/operations/_deps.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Literal + +from common.config import settings +from common.db import readonly_session_scope, session_scope +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 +from backend.services.operations import OperationsCache, OperationsEventStream + +OperationsRole = Literal["admin", "model_team", "business_team"] + +_ROLE_ALIASES: dict[str, OperationsRole] = { + "admin": "admin", + "administrator": "admin", + "developer": "model_team", + "model_team": "model_team", + "model": "model_team", + "business": "business_team", + "business_team": "business_team", + "biz": "business_team", +} + + +@dataclass(frozen=True) +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: + return self.role == "admin" + + +async def operations_database_session( + request: Request, +) -> AsyncIterator[AsyncSession]: + factory = getattr(request.app.state, "operations_session_factory", None) + if factory is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + {"code": "OPERATIONS_DATABASE_UNAVAILABLE", "message": "运维数据库未初始化"}, + ) + async with session_scope(factory) as session: + yield session + + +async def operations_platform_database_session( + request: Request, +) -> AsyncIterator[AsyncSession]: + factory = getattr( + request.app.state, + "operations_platform_read_session_factory", + None, + ) + if factory is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + {"code": "PLATFORM_DATABASE_UNAVAILABLE", "message": "平台只读库未初始化"}, + ) + async with readonly_session_scope(factory) as session: + yield session + + +async def deploy_database_session( + request: Request, +) -> AsyncIterator[AsyncSession]: + factory = getattr(request.app.state, "deploy_read_session_factory", None) + if factory is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + {"code": "DEPLOY_DATABASE_UNAVAILABLE", "message": "部署只读库未初始化"}, + ) + async with readonly_session_scope(factory) as session: + yield session + + +def operations_cache(request: Request) -> OperationsCache: + return OperationsCache( + getattr(request.app.state, "operations_redis", None), + prefix=settings.operations_redis_prefix, + default_ttl_seconds=settings.operations_cache_ttl_seconds, + ) + + +def operations_event_stream(request: Request) -> OperationsEventStream: + return OperationsEventStream( + getattr(request.app.state, "operations_redis", None), + stream_name=settings.operations_event_stream, + maxlen=settings.operations_event_stream_maxlen, + ) + + +async def operations_context( + context: RequestContext = Depends(request_context), + platform_session: AsyncSession = Depends(operations_platform_database_session), +) -> OperationsContext: + """Reuse login/workspace checks and normalize the three operations roles.""" + raw_role = context.role.role_code + if context.is_system_admin: + raw_role = "admin" + elif context.user.platform_role_id: + platform_role = await platform_session.get(Roles, context.user.platform_role_id) + if platform_role is not None: + raw_role = platform_role.role_code + + normalized = _ROLE_ALIASES.get(raw_role) + if normalized is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + { + "code": "OPERATIONS_ROLE_UNSUPPORTED", + "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 new file mode 100644 index 0000000..a3c55fe --- /dev/null +++ b/backend/src/backend/api/operations/health.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Request, Response, status +from sqlalchemy import text + +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( + request: Request, + response: Response, +) -> dict[str, Any]: + checks: dict[str, dict[str, str]] = {} + targets = ( + ("model_operations", "operations_session_factory", False), + ("model_platform", "operations_platform_read_session_factory", True), + ("model_deploy", "deploy_read_session_factory", True), + ) + for database_name, factory_name, read_only in targets: + factory = getattr(request.app.state, factory_name, None) + if factory is None: + checks[database_name] = {"status": "error", "mode": "unavailable"} + continue + scope = readonly_session_scope if read_only else session_scope + try: + async with scope(factory) as session: + current_database = await session.scalar(text("SELECT DATABASE()")) + 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", + "mode": "read_only" if read_only else "read_write", + } + ready = all(item["status"] == "ok" for item in checks.values()) + if not ready: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + redis_client = getattr(request.app.state, "operations_redis", None) + if redis_client is None: + redis_check = {"status": "disabled", "mode": "cache_and_streams"} + else: + try: + pong = await redis_client.ping() + redis_check = { + "status": "ok" if pong else "error", + "mode": "cache_and_streams", + } + except Exception: + redis_check = {"status": "error", "mode": "cache_and_streams"} + module_status = "ok" if ready and redis_check["status"] == "ok" else ( + "degraded" if ready else "not_ready" + ) + return { + "data": { + "status": module_status, + "module": "model_operations", + "databases": checks, + "redis": redis_check, + }, + "meta": {}, + } + + +__all__ = ["router"] diff --git a/backend/src/backend/api/operations/models.py b/backend/src/backend/api/operations/models.py new file mode 100644 index 0000000..a9d8e09 --- /dev/null +++ b/backend/src/backend/api/operations/models.py @@ -0,0 +1,146 @@ +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, + deploy_database_session, + operations_database_session, + require_operations_permission, +) +from backend.schemas.operations import ModelCategoryCode, ModelStatusLabel +from backend.services.operations import ( + get_deploy_model, + get_deploy_monthly_monitoring_result, + list_deploy_models, + parse_monitor_month, +) + +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": {}} + + +@router.get("/models") +async def list_operation_models( + bank: str | None = Query(default=None, max_length=150), + 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(require_operations_permission("operations:model-overview:view")), + session: AsyncSession = Depends(operations_database_session), + deploy_session: AsyncSession = Depends(deploy_database_session), +) -> dict[str, Any]: + 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(require_operations_permission("operations:monitoring-detail:view")), + session: AsyncSession = Depends(operations_database_session), + deploy_session: AsyncSession = Depends(deploy_database_session), +) -> dict[str, Any]: + 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, + {"code": "MODEL_NOT_FOUND", "message": "模型不存在"}, + ) + return _envelope(context, model) + + +@router.get("/models/{model_id}/monitor-results") +async def get_operation_model_monitor_result( + model_id: str, + month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + 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) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + {"code": "INVALID_MONITOR_MONTH", "message": str(exc)}, + ) from exc + 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, + { + "code": "MONITOR_RESULT_NOT_FOUND", + "message": "该月份暂无监控结果", + }, + ) + return _envelope(context, result) + + +__all__ = ["router"] 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/api/platform/members.py b/backend/src/backend/api/platform/members.py index 7024e9e..0c30928 100644 --- a/backend/src/backend/api/platform/members.py +++ b/backend/src/backend/api/platform/members.py @@ -295,4 +295,3 @@ async def remove_member( context.request_id, {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, ) - diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 6c6ad3a..ea46820 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -32,10 +32,12 @@ from common.storage import ( from fastapi import Request from fastapi.responses import JSONResponse from loguru import logger +from redis.asyncio import Redis from backend.api.admin import router as admin_router from backend.api.auth import router as auth_router from backend.api.jupyter import router as jupyter_router +from backend.api.operations import router as operations_router from backend.api.platform import router as platform_router from backend.api.resources import router as resources_router from backend.api.resources_content import router as resources_content_router @@ -66,6 +68,35 @@ async def lifespan(app: Any) -> AsyncIterator[None]: # 取得它们;这样每个请求不会重复创建数据库连接或 HTTP 客户端。 engine = create_database_engine(settings.database_url) app.state.session_factory = create_session_factory(engine) + operations_database_url = settings.operations_database_url + if not operations_database_url: + raise RuntimeError("OPERATIONS_DATABASE_URL could not be resolved") + operations_engine = create_database_engine(operations_database_url) + app.state.operations_session_factory = create_session_factory(operations_engine) + platform_read_database_url = settings.platform_read_database_url + deploy_database_url = settings.deploy_database_url + if not platform_read_database_url or not deploy_database_url: + raise RuntimeError("operations read-only database URLs could not be resolved") + platform_read_engine = create_database_engine(platform_read_database_url) + deploy_read_engine = create_database_engine(deploy_database_url) + app.state.operations_platform_read_session_factory = create_session_factory( + platform_read_engine + ) + app.state.deploy_read_session_factory = create_session_factory(deploy_read_engine) + + # Redis only accelerates operations reads and event delivery. It is kept + # optional so a cache outage never prevents MySQL-backed APIs from booting. + operations_redis: Redis | None = None + if settings.redis_url: + operations_redis = Redis.from_url( + settings.redis_url, + decode_responses=True, + socket_connect_timeout=settings.redis_socket_connect_timeout_seconds, + socket_timeout=settings.redis_socket_timeout_seconds, + health_check_interval=30, + retry_on_timeout=True, + ) + app.state.operations_redis = operations_redis # 存储接口与业务路由运行在同一个 backend 进程中,因此直接复用存储对象, # 不需要再通过 HTTP 调用自己。字典键使用真实桶名,便于上传会话和存储 @@ -95,8 +126,13 @@ async def lifespan(app: Any) -> AsyncIterator[None]: try: yield finally: + if operations_redis is not None: + await operations_redis.aclose() await rclone_http_client.aclose() await runtime_http_client.aclose() + await deploy_read_engine.dispose() + await platform_read_engine.dispose() + await operations_engine.dispose() await engine.dispose() @@ -114,6 +150,7 @@ app.include_router(schedules_router) app.include_router(scripts_router) app.include_router(admin_router) app.include_router(platform_router) +app.include_router(operations_router) # 内部存储接口额外加上 /internal 前缀,供后端服务间调用,不作为普通前端 API。 app.include_router(storage_api_router, prefix="/internal") diff --git a/backend/src/backend/schemas/operations/__init__.py b/backend/src/backend/schemas/operations/__init__.py new file mode 100644 index 0000000..aed7282 --- /dev/null +++ b/backend/src/backend/schemas/operations/__init__.py @@ -0,0 +1,15 @@ +from backend.schemas.operations.models import ( + ModelCategoryCode, + ModelStatusLabel, + OperationsModelDto, + RankingStatusLabel, +) +from backend.schemas.operations.monitoring import MonthlyMonitoringResultDto + +__all__ = [ + "ModelCategoryCode", + "ModelStatusLabel", + "MonthlyMonitoringResultDto", + "OperationsModelDto", + "RankingStatusLabel", +] diff --git a/backend/src/backend/schemas/operations/models.py b/backend/src/backend/schemas/operations/models.py new file mode 100644 index 0000000..a9f33c4 --- /dev/null +++ b/backend/src/backend/schemas/operations/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Literal + +from common.schemas import StrictModel + +ModelCategoryCode = Literal["std", "bai", "big", "afd"] +ModelStatusLabel = Literal["正常", "陪跑", "陪跑结束", "下线"] +RankingStatusLabel = Literal["相符", "不符", "—"] + + +class OperationsModelDto(StrictModel): + model_instance_id: str + bank_name: str + model_category: ModelCategoryCode + model_name: str + model_id: str + model_version: str + model_status: ModelStatusLabel + last_iteration_date: str + ranking_result: RankingStatusLabel + ks: float + psi: float + ks_mom_drop: float + secondary_hits_6m: int + is_wuji_bank: bool + last_processed_at: str | None + previous_advice: str + common_model_name: str | None + + +__all__ = [ + "ModelCategoryCode", + "ModelStatusLabel", + "OperationsModelDto", + "RankingStatusLabel", +] diff --git a/backend/src/backend/schemas/operations/monitoring.py b/backend/src/backend/schemas/operations/monitoring.py new file mode 100644 index 0000000..5cb5e5e --- /dev/null +++ b/backend/src/backend/schemas/operations/monitoring.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from common.schemas import StrictModel + +from backend.schemas.operations.models import RankingStatusLabel + + +class MonthlyMonitoringResultDto(StrictModel): + model_instance_id: str + monitor_month: str + ranking_result: RankingStatusLabel + ks: float + psi: float + ks_mom_drop: float + secondary_hits_6m: int + + +__all__ = ["MonthlyMonitoringResultDto"] diff --git a/backend/src/backend/services/operations/__init__.py b/backend/src/backend/services/operations/__init__.py new file mode 100644 index 0000000..917272a --- /dev/null +++ b/backend/src/backend/services/operations/__init__.py @@ -0,0 +1,27 @@ +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, + list_models, + 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/cache.py b/backend/src/backend/services/operations/cache.py new file mode 100644 index 0000000..33296a0 --- /dev/null +++ b/backend/src/backend/services/operations/cache.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from typing import Any + +from loguru import logger +from redis.asyncio import Redis +from redis.exceptions import RedisError + + +class OperationsCache: + """Best-effort JSON cache; every failure degrades to a cache miss.""" + + def __init__( + self, + client: Redis | None, + *, + prefix: str, + default_ttl_seconds: int, + ) -> None: + self._client = client + self._prefix = prefix.rstrip(":") + self._default_ttl_seconds = default_ttl_seconds + + def build_key( + self, + workspace_id: str, + resource: str, + params: Mapping[str, Any] | None = None, + ) -> str: + canonical = json.dumps( + dict(params or {}), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20] + safe_resource = resource.strip(":").replace(" ", "-") + return f"{self._prefix}:cache:{workspace_id}:{safe_resource}:{digest}" + + async def get_json(self, key: str) -> Any | None: + if self._client is None: + return None + try: + value = await self._client.get(key) + return json.loads(value) if value is not None else None + except (RedisError, ValueError, TypeError) as exc: + logger.warning("operations cache get failed for {key}: {error}", key=key, error=exc) + return None + + async def set_json( + self, + key: str, + value: Any, + *, + ttl_seconds: int | None = None, + ) -> bool: + if self._client is None: + return False + try: + payload = json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + default=str, + ) + await self._client.set( + key, + payload, + ex=ttl_seconds or self._default_ttl_seconds, + ) + return True + except (RedisError, TypeError, ValueError) as exc: + logger.warning("operations cache set failed for {key}: {error}", key=key, error=exc) + return False + + async def delete(self, *keys: str) -> int: + if self._client is None or not keys: + return 0 + try: + return int(await self._client.unlink(*keys)) + except RedisError as exc: + logger.warning("operations cache delete failed: {error}", error=exc) + return 0 + + async def invalidate_resource(self, workspace_id: str, resource: str) -> int: + if self._client is None: + return 0 + pattern = ( + f"{self._prefix}:cache:{workspace_id}:" + f"{resource.strip(':').replace(' ', '-')}:*" + ) + removed = 0 + try: + batch: list[str] = [] + async for key in self._client.scan_iter(match=pattern, count=200): + batch.append(key) + if len(batch) >= 200: + removed += int(await self._client.unlink(*batch)) + batch.clear() + if batch: + removed += int(await self._client.unlink(*batch)) + return removed + except RedisError as exc: + logger.warning("operations cache invalidation failed: {error}", error=exc) + return removed + + +__all__ = ["OperationsCache"] 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/model_queries.py b/backend/src/backend/services/operations/model_queries.py new file mode 100644 index 0000000..2cf46cc --- /dev/null +++ b/backend/src/backend/services/operations/model_queries.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from decimal import Decimal +from typing import Any + +from common.db.models.operations import ( + OpsBank, + OpsModelInstance, + OpsModelVersion, + OpsMonitorBatch, + OpsMonitorEvaluation, + OpsMonitorResult, + OpsMonitorReview, +) +from sqlalchemy import Select, and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.schemas.operations import ( + MonthlyMonitoringResultDto, + OperationsModelDto, +) + +_MODEL_STATUS_TO_API = { + "normal": "正常", + "escort": "陪跑", + "escort_finished": "陪跑结束", + "offline": "下线", +} +_MODEL_STATUS_TO_DB = {value: key for key, value in _MODEL_STATUS_TO_API.items()} +_RANKING_TO_API = { + "matched": "相符", + "unmatched": "不符", + "not_applicable": "—", + None: "—", +} + + +def parse_monitor_month(value: str) -> datetime.date: + """Parse the API's YYYY-MM month into the DB's first-of-month date.""" + try: + parsed = datetime.datetime.strptime(value, "%Y-%m") + except ValueError as exc: + raise ValueError("month must use YYYY-MM format") from exc + return parsed.date().replace(day=1) + + +def _percent(value: Decimal | float | int | None) -> float: + return round(float(value or 0) * 100, 4) + + +def _date_text(value: datetime.date | None) -> str: + return value.isoformat() if value else "—" + + +def _datetime_date_text(value: datetime.datetime | None) -> str | None: + return value.date().isoformat() if value else None + + +def _latest_result_subquery( + 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"), + 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") + ) + 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, + ) + .where(ranked.c.row_no == 1) + .subquery("latest_monitor_result") + ) + + +def _latest_review_subquery(): + return ( + select( + OpsMonitorReview.monitor_result_id.label("monitor_result_id"), + func.max(OpsMonitorReview.handled_at).label("handled_at"), + ) + .where( + OpsMonitorReview.is_deleted == 0, + OpsMonitorReview.handled_at.is_not(None), + ) + .group_by(OpsMonitorReview.monitor_result_id) + .subquery("latest_monitor_review") + ) + + +def _model_statement( + workspace_id: str, + *, + bank: str | None = None, + category: str | None = None, + status: str | None = None, + keyword: str | None = None, + model_id: str | None = None, +) -> Select[Any]: + latest_result = _latest_result_subquery(workspace_id) + latest_review = _latest_review_subquery() + statement = ( + select( + OpsModelInstance.model_instance_id.label("model_instance_id"), + OpsBank.bank_name.label("bank_name"), + OpsModelInstance.category_code.label("model_category"), + OpsModelInstance.model_name.label("model_name"), + OpsModelInstance.model_id.label("model_id"), + OpsModelVersion.version_label.label("model_version"), + OpsModelInstance.model_status.label("model_status"), + OpsModelVersion.last_iteration_date.label("last_iteration_date"), + latest_result.c.ranking_result.label("ranking_result"), + latest_result.c.ks_value.label("ks_value"), + latest_result.c.psi_value.label("psi_value"), + OpsMonitorEvaluation.ks_mom_drop_rate.label("ks_mom_drop_rate"), + OpsMonitorEvaluation.secondary_level2_hits_6m.label( + "secondary_level2_hits_6m" + ), + OpsBank.is_wuji_bank.label("is_wuji_bank"), + latest_review.c.handled_at.label("last_processed_at"), + OpsMonitorEvaluation.action_snapshot.label("previous_advice"), + OpsModelInstance.common_model_name.label("common_model_name"), + ) + .select_from(OpsModelInstance) + .join(OpsBank, OpsBank.bank_id == OpsModelInstance.bank_id) + .outerjoin( + OpsModelVersion, + and_( + OpsModelVersion.model_version_id + == OpsModelInstance.current_version_id, + OpsModelVersion.is_deleted == 0, + ), + ) + .outerjoin( + latest_result, + latest_result.c.model_instance_id + == OpsModelInstance.model_instance_id, + ) + .outerjoin( + OpsMonitorEvaluation, + and_( + OpsMonitorEvaluation.monitor_result_id + == latest_result.c.monitor_result_id, + OpsMonitorEvaluation.is_current == 1, + OpsMonitorEvaluation.is_deleted == 0, + ), + ) + .outerjoin( + latest_review, + latest_review.c.monitor_result_id + == latest_result.c.monitor_result_id, + ) + .where( + OpsModelInstance.workspace_id == workspace_id, + OpsModelInstance.is_deleted == 0, + OpsBank.workspace_id == workspace_id, + OpsBank.is_deleted == 0, + ) + ) + if bank: + statement = statement.where(OpsBank.bank_name == bank) + if category: + statement = statement.where(OpsModelInstance.category_code == category) + if status: + statement = statement.where( + OpsModelInstance.model_status == _MODEL_STATUS_TO_DB.get(status, status) + ) + if model_id: + statement = statement.where(OpsModelInstance.model_id == model_id) + if keyword and (term := keyword.strip()): + like_term = f"%{term}%" + statement = statement.where( + or_( + OpsBank.bank_name.ilike(like_term), + OpsModelInstance.model_name.ilike(like_term), + OpsModelInstance.model_id.ilike(like_term), + OpsModelVersion.version_label.ilike(like_term), + ) + ) + return statement.order_by(OpsBank.bank_name, OpsModelInstance.model_id) + + +def _model_dto(row: Mapping[str, Any]) -> OperationsModelDto: + return OperationsModelDto( + model_instance_id=row["model_instance_id"], + bank_name=row["bank_name"], + model_category=row["model_category"], + model_name=row["model_name"], + model_id=row["model_id"], + model_version=row["model_version"] or "—", + model_status=_MODEL_STATUS_TO_API.get(row["model_status"], "正常"), + last_iteration_date=_date_text(row["last_iteration_date"]), + ranking_result=_RANKING_TO_API.get(row["ranking_result"], "—"), + ks=_percent(row["ks_value"]), + psi=_percent(row["psi_value"]), + ks_mom_drop=_percent(row["ks_mom_drop_rate"]), + secondary_hits_6m=int(row["secondary_level2_hits_6m"] or 0), + is_wuji_bank=bool(row["is_wuji_bank"]), + last_processed_at=_datetime_date_text(row["last_processed_at"]), + previous_advice=row["previous_advice"] or "暂无历史处理建议", + common_model_name=row["common_model_name"], + ) + + +async def list_models( + session: AsyncSession, + workspace_id: str, + *, + bank: str | None = None, + category: str | None = None, + status: str | None = None, + keyword: str | None = None, +) -> list[OperationsModelDto]: + rows = ( + await session.execute( + _model_statement( + workspace_id, + bank=bank, + category=category, + status=status, + keyword=keyword, + ) + ) + ).mappings() + return [_model_dto(row) for row in rows] + + +async def get_model( + session: AsyncSession, + workspace_id: str, + model_id: str, +) -> OperationsModelDto | None: + row = ( + await session.execute( + _model_statement(workspace_id, model_id=model_id).limit(1) + ) + ).mappings().first() + return _model_dto(row) if row else None + + +async def get_monthly_monitoring_result( + session: AsyncSession, + workspace_id: str, + model_id: str, + monitor_month: datetime.date, +) -> MonthlyMonitoringResultDto | None: + latest_result = _latest_result_subquery(workspace_id, monitor_month) + statement = ( + select( + OpsModelInstance.model_instance_id.label("model_instance_id"), + latest_result.c.monitor_month.label("monitor_month"), + latest_result.c.ranking_result.label("ranking_result"), + latest_result.c.ks_value.label("ks_value"), + latest_result.c.psi_value.label("psi_value"), + OpsMonitorEvaluation.ks_mom_drop_rate.label("ks_mom_drop_rate"), + OpsMonitorEvaluation.secondary_level2_hits_6m.label( + "secondary_level2_hits_6m" + ), + ) + .select_from(OpsModelInstance) + .join( + latest_result, + latest_result.c.model_instance_id + == OpsModelInstance.model_instance_id, + ) + .outerjoin( + OpsMonitorEvaluation, + and_( + OpsMonitorEvaluation.monitor_result_id + == latest_result.c.monitor_result_id, + OpsMonitorEvaluation.is_current == 1, + OpsMonitorEvaluation.is_deleted == 0, + ), + ) + .where( + OpsModelInstance.workspace_id == workspace_id, + OpsModelInstance.model_id == model_id, + OpsModelInstance.is_deleted == 0, + ) + .limit(1) + ) + row = (await session.execute(statement)).mappings().first() + if not row: + return None + return MonthlyMonitoringResultDto( + model_instance_id=row["model_instance_id"], + monitor_month=row["monitor_month"].strftime("%Y-%m"), + ranking_result=_RANKING_TO_API.get(row["ranking_result"], "—"), + ks=_percent(row["ks_value"]), + psi=_percent(row["psi_value"]), + ks_mom_drop=_percent(row["ks_mom_drop_rate"]), + secondary_hits_6m=int(row["secondary_level2_hits_6m"] or 0), + ) + + +__all__ = [ + "get_model", + "get_monthly_monitoring_result", + "list_models", + "parse_monitor_month", +] diff --git a/backend/src/backend/services/operations/streams.py b/backend/src/backend/services/operations/streams.py new file mode 100644 index 0000000..fb9a0fc --- /dev/null +++ b/backend/src/backend/services/operations/streams.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +from typing import Any + +from loguru import logger +from redis.asyncio import Redis +from redis.exceptions import RedisError, ResponseError + + +class OperationsEventStream: + """Best-effort Redis Streams transport fed by the durable MySQL Outbox.""" + + def __init__( + self, + client: Redis | None, + *, + stream_name: str, + maxlen: int, + ) -> None: + self._client = client + self.stream_name = stream_name + self._maxlen = maxlen + + async def publish( + self, + event_type: str, + payload: dict[str, Any], + *, + event_id: str, + idempotency_key: str = "", + trace_id: str = "", + schema_version: int = 1, + ) -> str | None: + if self._client is None: + return None + fields = { + "event_id": event_id, + "event_type": event_type, + "schema_version": str(schema_version), + "idempotency_key": idempotency_key, + "trace_id": trace_id, + "payload": json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + default=str, + ), + } + try: + return await self._client.xadd( + self.stream_name, + fields, + maxlen=self._maxlen, + approximate=True, + ) + except RedisError as exc: + logger.warning( + "operations stream publish failed for {event_id}: {error}", + event_id=event_id, + error=exc, + ) + return None + + async def ensure_group(self, group_name: str) -> bool: + if self._client is None: + return False + try: + await self._client.xgroup_create( + self.stream_name, + group_name, + id="0-0", + mkstream=True, + ) + return True + except ResponseError as exc: + if "BUSYGROUP" in str(exc): + return True + logger.warning("operations stream group creation failed: {error}", error=exc) + return False + except RedisError as exc: + logger.warning("operations stream group creation failed: {error}", error=exc) + return False + + async def read_group( + self, + group_name: str, + consumer_name: str, + *, + count: int = 20, + block_ms: int = 5000, + ) -> list[tuple[str, dict[str, str]]]: + if self._client is None: + return [] + try: + response = await self._client.xreadgroup( + group_name, + consumer_name, + {self.stream_name: ">"}, + count=count, + block=block_ms, + ) + if not response: + return [] + return [(message_id, fields) for _, messages in response for message_id, fields in messages] + except RedisError as exc: + logger.warning("operations stream read failed: {error}", error=exc) + return [] + + async def acknowledge(self, group_name: str, *message_ids: str) -> int: + if self._client is None or not message_ids: + return 0 + try: + return int( + await self._client.xack( + self.stream_name, + group_name, + *message_ids, + ) + ) + except RedisError as exc: + logger.warning("operations stream ack failed: {error}", error=exc) + return 0 + + +__all__ = ["OperationsEventStream"] 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_queries.py b/backend/tests/test_operations_queries.py new file mode 100644 index 0000000..a0086ff --- /dev/null +++ b/backend/tests/test_operations_queries.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import datetime +from decimal import Decimal + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from backend.services.operations import ( + get_model, + get_monthly_monitoring_result, + list_models, + parse_monitor_month, +) +from common.config import Settings +from common.db.models.operations import ( + OperationsBase, + OpsBank, + OpsModelInstance, + OpsModelVersion, + OpsMonitorBatch, + OpsMonitorEvaluation, + OpsMonitorResult, + OpsMonitorReview, +) + +WORKSPACE_ID = "W" * 26 +OTHER_WORKSPACE_ID = "X" * 26 +MODEL_INSTANCE_ID = "M" * 26 +MODEL_VERSION_ID = "V" * 26 +MODEL_ID = "JC-STD-001" + + +@pytest.fixture +async def session(): + engine = create_async_engine( + "sqlite+aiosqlite://", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as connection: + await connection.run_sync(OperationsBase.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as database_session: + await _seed(database_session) + await database_session.commit() + yield database_session + await engine.dispose() + + +async def _seed(session) -> None: + bank = OpsBank( + bank_id="B" * 26, + workspace_id=WORKSPACE_ID, + bank_code="JC", + bank_name="江城银行", + is_wuji_bank=1, + bank_status="active", + source_bank_ref="bank-jc", + ) + version = OpsModelVersion( + model_version_id=MODEL_VERSION_ID, + workspace_id=WORKSPACE_ID, + model_instance_id=MODEL_INSTANCE_ID, + version_label="v2.3", + version_status="active", + last_iteration_date=datetime.date(2026, 6, 18), + source_version_ref="version-jc-std-v23", + ) + model = OpsModelInstance( + model_instance_id=MODEL_INSTANCE_ID, + workspace_id=WORKSPACE_ID, + bank_id=bank.bank_id, + category_code="std", + model_id=MODEL_ID, + model_name="标准A卡", + model_status="normal", + current_version_id=version.model_version_id, + common_model_name="标准A卡通用版 v2.3", + source_model_ref="model-jc-std", + ) + old_batch = OpsMonitorBatch( + batch_id="1" * 26, + workspace_id=WORKSPACE_ID, + source_batch_no="2026-07-r1", + monitor_month=datetime.date(2026, 7, 1), + revision_no=1, + batch_status="published", + published_at=datetime.datetime(2026, 7, 20, 8, 0), + ) + new_batch = OpsMonitorBatch( + batch_id="2" * 26, + workspace_id=WORKSPACE_ID, + source_batch_no="2026-07-r2", + monitor_month=datetime.date(2026, 7, 1), + revision_no=2, + batch_status="published", + published_at=datetime.datetime(2026, 7, 21, 8, 0), + ) + old_result = OpsMonitorResult( + monitor_result_id="3" * 26, + workspace_id=WORKSPACE_ID, + batch_id=old_batch.batch_id, + model_instance_id=MODEL_INSTANCE_ID, + model_version_id=MODEL_VERSION_ID, + monitor_month=datetime.date(2026, 7, 1), + ranking_result="matched", + ks_value=Decimal("0.41000000"), + psi_value=Decimal("0.09000000"), + ) + new_result = OpsMonitorResult( + monitor_result_id="4" * 26, + workspace_id=WORKSPACE_ID, + batch_id=new_batch.batch_id, + model_instance_id=MODEL_INSTANCE_ID, + model_version_id=MODEL_VERSION_ID, + monitor_month=datetime.date(2026, 7, 1), + ranking_result="unmatched", + ks_value=Decimal("0.36840000"), + psi_value=Decimal("0.27400000"), + ) + evaluation = OpsMonitorEvaluation( + evaluation_id="5" * 26, + workspace_id=WORKSPACE_ID, + monitor_result_id=new_result.monitor_result_id, + rule_version_id="6" * 26, + rule_item_id="7" * 26, + ks_mom_drop_rate=Decimal("0.24100000"), + secondary_level2_hits_6m=3, + abnormal_level="level3", + monitor_grade="C", + reason_code="R-C-001", + reason_text_snapshot="排序性不符、KS低、PSI高", + action_snapshot="建议启动模型微调或重构评估", + is_current=1, + evaluated_at=datetime.datetime(2026, 7, 21, 9, 0), + ) + review = OpsMonitorReview( + review_id="8" * 26, + workspace_id=WORKSPACE_ID, + monitor_result_id=new_result.monitor_result_id, + evaluation_id=evaluation.evaluation_id, + review_stage="model_initial", + review_status="handled", + decision_code="tune_or_rebuild", + handling_note="建议评估重构", + handled_by="U" * 26, + handled_at=datetime.datetime(2026, 7, 22, 10, 30), + ) + other_bank = OpsBank( + bank_id="C" * 26, + workspace_id=OTHER_WORKSPACE_ID, + bank_code="OTHER", + bank_name="其他银行", + bank_status="active", + source_bank_ref="bank-other", + ) + other_model = OpsModelInstance( + model_instance_id="N" * 26, + workspace_id=OTHER_WORKSPACE_ID, + bank_id=other_bank.bank_id, + category_code="std", + model_id="OTHER-STD-001", + model_name="其他模型", + model_status="normal", + source_model_ref="model-other", + ) + session.add_all( + [ + bank, + version, + model, + old_batch, + new_batch, + old_result, + new_result, + evaluation, + review, + other_bank, + other_model, + ] + ) + + +async def test_list_models_uses_latest_revision_and_workspace(session) -> None: + models = await list_models(session, WORKSPACE_ID) + assert len(models) == 1 + model = models[0] + assert model.model_id == MODEL_ID + assert model.ks == 36.84 + assert model.psi == 27.4 + assert model.ks_mom_drop == 24.1 + assert model.ranking_result == "不符" + assert model.last_processed_at == "2026-07-22" + assert model.is_wuji_bank is True + + +async def test_list_models_filters_and_detail(session) -> None: + assert len(await list_models(session, WORKSPACE_ID, bank="江城银行")) == 1 + assert len(await list_models(session, WORKSPACE_ID, category="big")) == 0 + assert len(await list_models(session, WORKSPACE_ID, keyword="v2.3")) == 1 + model = await get_model(session, WORKSPACE_ID, MODEL_ID) + assert model is not None + assert model.model_version == "v2.3" + assert model.common_model_name == "标准A卡通用版 v2.3" + assert await get_model(session, OTHER_WORKSPACE_ID, MODEL_ID) is None + + +async def test_get_monthly_monitoring_result(session) -> None: + result = await get_monthly_monitoring_result( + session, + WORKSPACE_ID, + MODEL_ID, + datetime.date(2026, 7, 1), + ) + assert result is not None + assert result.monitor_month == "2026-07" + assert result.ks == 36.84 + assert result.psi == 27.4 + assert result.secondary_hits_6m == 3 + + +def test_monitor_month_and_operations_url() -> None: + assert parse_monitor_month("2026-07") == datetime.date(2026, 7, 1) + with pytest.raises(ValueError): + parse_monitor_month("2026-13") + settings = Settings( + database_url="mysql+asyncmy://user:pass@db:3306/model_platform?charset=utf8mb4", + _env_file=None, + ) + assert settings.operations_database_url is not None + assert "/model_operations?" in settings.operations_database_url diff --git a/backend/tests/test_operations_redis.py b/backend/tests/test_operations_redis.py new file mode 100644 index 0000000..5e8646f --- /dev/null +++ b/backend/tests/test_operations_redis.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import fnmatch + +from backend.services.operations import OperationsCache, OperationsEventStream + + +class FakeRedis: + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.expirations: dict[str, int] = {} + self.stream_entries: list[tuple[str, dict[str, str]]] = [] + self.groups: set[str] = set() + + async def get(self, key: str): + return self.values.get(key) + + async def set(self, key: str, value: str, *, ex: int): + self.values[key] = value + self.expirations[key] = ex + return True + + async def unlink(self, *keys: str): + removed = 0 + for key in keys: + if key in self.values: + removed += 1 + self.values.pop(key) + self.expirations.pop(key, None) + return removed + + async def scan_iter(self, *, match: str, count: int): + del count + for key in list(self.values): + if fnmatch.fnmatch(key, match): + yield key + + async def xadd(self, stream: str, fields, *, maxlen: int, approximate: bool): + assert maxlen == 1000 + assert approximate is True + message_id = f"1-{len(self.stream_entries)}" + self.stream_entries.append((message_id, dict(fields))) + return message_id + + async def xgroup_create(self, stream: str, group: str, *, id: str, mkstream: bool): + assert stream == "ops:events" + assert id == "0-0" + assert mkstream is True + self.groups.add(group) + return True + + async def xreadgroup(self, group, consumer, streams, *, count: int, block: int): + del group, consumer, count, block + stream = next(iter(streams)) + return [(stream, list(self.stream_entries))] + + async def xack(self, stream: str, group: str, *message_ids: str): + del stream, group + return len(message_ids) + + +async def test_cache_round_trip_and_workspace_invalidation() -> None: + redis = FakeRedis() + cache = OperationsCache( + redis, # type: ignore[arg-type] + prefix="ops", + default_ttl_seconds=300, + ) + key = cache.build_key("W1", "model-overview", {"month": "2026-07", "bank": "江城"}) + same_key = cache.build_key("W1", "model-overview", {"bank": "江城", "month": "2026-07"}) + other_key = cache.build_key("W2", "model-overview", {"bank": "江城"}) + assert key == same_key + assert key != other_key + assert await cache.set_json(key, {"total": 3, "name": "标准A卡"}) is True + assert redis.expirations[key] == 300 + assert await cache.get_json(key) == {"total": 3, "name": "标准A卡"} + await cache.set_json(other_key, {"total": 1}) + assert await cache.invalidate_resource("W1", "model-overview") == 1 + assert await cache.get_json(key) is None + assert await cache.get_json(other_key) == {"total": 1} + + +async def test_cache_without_redis_is_a_noop() -> None: + cache = OperationsCache(None, prefix="ops", default_ttl_seconds=300) + assert await cache.get_json("missing") is None + assert await cache.set_json("missing", {"value": 1}) is False + assert await cache.delete("missing") == 0 + + +async def test_stream_publish_group_read_and_ack() -> None: + redis = FakeRedis() + stream = OperationsEventStream( + redis, # type: ignore[arg-type] + stream_name="ops:events", + maxlen=1000, + ) + assert await stream.ensure_group("notifications") is True + message_id = await stream.publish( + "monitor.review.due", + {"review_id": "R1", "bank": "江城银行"}, + event_id="E1", + idempotency_key="review:R1:due", + trace_id="T1", + ) + assert message_id == "1-0" + messages = await stream.read_group("notifications", "worker-1") + assert messages[0][0] == "1-0" + assert messages[0][1]["event_type"] == "monitor.review.due" + assert '"review_id":"R1"' in messages[0][1]["payload"] + assert await stream.acknowledge("notifications", message_id) == 1 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 734c410..56f1fce 100644 --- a/common/src/common/config.py +++ b/common/src/common/config.py @@ -26,6 +26,7 @@ from venv import logger from cryptography.hazmat.primitives.ciphers.aead import AESGCM from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from sqlalchemy.engine import make_url # ── AES 解密核心函数 ──────────────────────────────────────────────────────── @@ -63,7 +64,39 @@ class Settings(BaseSettings): ), description="SQLAlchemy async URI for the platform MySQL.", ) + operations_database_url: str | None = Field( + default=None, + description=( + "SQLAlchemy async URI for the isolated model_operations database. " + "When omitted it reuses DATABASE_URL credentials and switches only " + "the schema name to model_operations." + ), + ) + platform_read_database_url: str | None = Field( + default=None, + description="Read-only model_platform connection used by operations APIs.", + ) + deploy_database_url: str | None = Field( + default=None, + description="Read-only model_deploy connection used by operations APIs.", + ) + # ── operations Redis accelerator ───────────────────────────── + redis_url: str | None = Field( + default=None, + description=( + "Optional Redis URL for operations query cache and event Streams. " + "MySQL remains the source of truth when Redis is unavailable." + ), + ) + redis_socket_connect_timeout_seconds: float = Field(default=2.0, gt=0, le=30) + redis_socket_timeout_seconds: float = Field(default=2.0, gt=0, le=30) + operations_cache_ttl_seconds: int = Field(default=300, ge=10, le=86400) + operations_redis_prefix: str = Field(default="model-platform:operations") + operations_event_stream: str = Field( + default="model-platform:operations:events" + ) + operations_event_stream_maxlen: int = Field(default=10000, ge=100) # ── JWT ─────────────────────────────────────────────────────── jwt_secret: str = Field( default="dev-only-not-for-production", @@ -268,6 +301,23 @@ class Settings(BaseSettings): # print(values[key]) return values + @model_validator(mode="after") + def _derive_operations_database_url(self) -> "Settings": + """Default the operations database to the platform server credentials.""" + if not self.operations_database_url: + platform_url = make_url(self.database_url) + self.operations_database_url = platform_url.set( + database="model_operations" + ).render_as_string(hide_password=False) + if not self.platform_read_database_url: + self.platform_read_database_url = self.database_url + if not self.deploy_database_url: + platform_url = make_url(self.database_url) + self.deploy_database_url = platform_url.set( + database="model_deploy" + ).render_as_string(hide_password=False) + return self + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/common/src/common/db/__init__.py b/common/src/common/db/__init__.py index 10b0d03..21b1649 100644 --- a/common/src/common/db/__init__.py +++ b/common/src/common/db/__init__.py @@ -5,6 +5,7 @@ from common.db.session import ( AsyncSessionFactory, create_database_engine, create_session_factory, + readonly_session_scope, session_scope, ) @@ -13,5 +14,6 @@ __all__ = [ "Base", "create_database_engine", "create_session_factory", + "readonly_session_scope", "session_scope", ] diff --git a/common/src/common/db/models/operations/__init__.py b/common/src/common/db/models/operations/__init__.py new file mode 100644 index 0000000..7a93d2d --- /dev/null +++ b/common/src/common/db/models/operations/__init__.py @@ -0,0 +1,31 @@ +from common.db.models.operations.base import OperationsBase +from common.db.models.operations.governance import ( + OpsMonitorEvaluation, + OpsMonitorReview, + OpsRuleItem, + OpsRuleVersion, +) +from common.db.models.operations.models import OpsModelInstance, OpsModelVersion +from common.db.models.operations.monitoring import ( + OpsMonitorBatch, + OpsMonitorDistribution, + OpsMonitorFeatureMetric, + OpsMonitorResult, +) +from common.db.models.operations.reference import OpsBank, OpsModelCategory + +__all__ = [ + "OperationsBase", + "OpsBank", + "OpsModelCategory", + "OpsModelInstance", + "OpsModelVersion", + "OpsMonitorBatch", + "OpsMonitorDistribution", + "OpsMonitorEvaluation", + "OpsMonitorFeatureMetric", + "OpsMonitorResult", + "OpsMonitorReview", + "OpsRuleItem", + "OpsRuleVersion", +] diff --git a/common/src/common/db/models/operations/base.py b/common/src/common/db/models/operations/base.py new file mode 100644 index 0000000..931d291 --- /dev/null +++ b/common/src/common/db/models/operations/base.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import datetime + +from sqlalchemy import DateTime, Integer, text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class OperationsBase(DeclarativeBase): + """Declarative base isolated from the model_platform metadata.""" + + +class CreatedAtMixin: + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(), nullable=False, server_default=text("CURRENT_TIMESTAMP") + ) + + +class TimestampMixin(CreatedAtMixin): + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + onupdate=datetime.datetime.utcnow, + ) + + +class SoftDeleteMixin: + is_deleted: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("0") + ) + deleted_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + + +__all__ = [ + "CreatedAtMixin", + "OperationsBase", + "SoftDeleteMixin", + "TimestampMixin", +] diff --git a/common/src/common/db/models/operations/governance.py b/common/src/common/db/models/operations/governance.py new file mode 100644 index 0000000..e3692d2 --- /dev/null +++ b/common/src/common/db/models/operations/governance.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import DateTime, Index, Integer, JSON, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from common.db.models.operations.base import ( + CreatedAtMixin, + OperationsBase, + SoftDeleteMixin, + TimestampMixin, +) + + +class OpsRuleVersion(CreatedAtMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_rule_versions" + __table_args__ = ( + Index( + "uk_ops_rule_versions_label", + "workspace_id", + "category_code", + "version_label", + unique=True, + ), + Index( + "idx_ops_rule_versions_status", + "workspace_id", + "category_code", + "rule_status", + "published_at", + ), + {"comment": "监控判级规则版本"}, + ) + + rule_version_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + category_code: Mapped[str] = mapped_column(String(32), nullable=False, default="*") + version_label: Mapped[str] = mapped_column(String(64), nullable=False) + rule_status: Mapped[str] = mapped_column(String(24), nullable=False, default="draft") + threshold_json: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=False) + supersedes_rule_version_id: Mapped[str | None] = mapped_column(String(26)) + change_note: Mapped[str | None] = mapped_column(String(1000)) + created_by: Mapped[str] = mapped_column(String(26), nullable=False) + published_by: Mapped[str | None] = mapped_column(String(26)) + published_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + + +class OpsRuleItem(CreatedAtMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_rule_items" + __table_args__ = ( + Index("uk_ops_rule_items_order", "rule_version_id", "sort_order", unique=True), + Index("uk_ops_rule_items_reason", "rule_version_id", "reason_code", unique=True), + {"comment": "规则版本下的判级矩阵行"}, + ) + + rule_item_id: Mapped[str] = mapped_column(String(26), primary_key=True) + rule_version_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False) + ranking_result: Mapped[str] = mapped_column(String(24), nullable=False) + ks_band_code: Mapped[str] = mapped_column(String(32), nullable=False) + psi_band_code: Mapped[str] = mapped_column(String(32), nullable=False) + ks_drop_band_code: Mapped[str] = mapped_column(String(32), nullable=False) + conditions_json: Mapped[dict[str, Any] | None] = mapped_column(JSON()) + abnormal_level: Mapped[str] = mapped_column(String(16), nullable=False) + monitor_grade: Mapped[str] = mapped_column(String(1), nullable=False) + secondary_upgrade_threshold: Mapped[int | None] = mapped_column(Integer) + reason_code: Mapped[str] = mapped_column(String(64), nullable=False) + reason_template: Mapped[str] = mapped_column(String(1000), nullable=False) + action_text: Mapped[str] = mapped_column(String(1000), nullable=False) + + +class OpsMonitorEvaluation(CreatedAtMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_monitor_evaluations" + __table_args__ = ( + Index( + "uk_ops_evaluations_result_rule", + "monitor_result_id", + "rule_version_id", + unique=True, + ), + Index( + "idx_ops_evaluations_grade", + "workspace_id", + "monitor_grade", + "abnormal_level", + "evaluated_at", + ), + {"comment": "按规则版本生成的不可变监控判级快照"}, + ) + + evaluation_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + monitor_result_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + rule_version_id: Mapped[str] = mapped_column(String(26), nullable=False) + rule_item_id: Mapped[str] = mapped_column(String(26), nullable=False) + ks_mom_drop_rate: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + secondary_level2_hits_6m: Mapped[int] = mapped_column( + Integer, nullable=False, default=0 + ) + abnormal_level: Mapped[str] = mapped_column(String(16), nullable=False) + monitor_grade: Mapped[str] = mapped_column(String(1), nullable=False) + reason_code: Mapped[str] = mapped_column(String(64), nullable=False) + reason_text_snapshot: Mapped[str] = mapped_column(String(2000), nullable=False) + action_snapshot: Mapped[str] = mapped_column(String(2000), nullable=False) + is_current: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + evaluated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(), nullable=False, default=datetime.datetime.utcnow + ) + + +class OpsMonitorReview(TimestampMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_monitor_reviews" + __table_args__ = ( + Index( + "uk_ops_reviews_result_stage", + "monitor_result_id", + "review_stage", + unique=True, + ), + Index( + "idx_ops_reviews_pending", + "workspace_id", + "review_status", + "review_stage", + "due_at", + ), + {"comment": "模型团队初审与业务团队终审"}, + ) + + review_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + monitor_result_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + evaluation_id: Mapped[str] = mapped_column(String(26), nullable=False) + review_stage: Mapped[str] = mapped_column(String(24), nullable=False) + review_status: Mapped[str] = mapped_column( + String(24), nullable=False, default="pending" + ) + decision_code: Mapped[str | None] = mapped_column(String(32)) + handling_note: Mapped[str | None] = mapped_column(String(2000)) + due_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + handled_by: Mapped[str | None] = mapped_column(String(26)) + handled_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + auto_closed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + state_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +__all__ = [ + "OpsMonitorEvaluation", + "OpsMonitorReview", + "OpsRuleItem", + "OpsRuleVersion", +] diff --git a/common/src/common/db/models/operations/models.py b/common/src/common/db/models/operations/models.py new file mode 100644 index 0000000..021cd82 --- /dev/null +++ b/common/src/common/db/models/operations/models.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, Index, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from common.db.models.operations.base import ( + OperationsBase, + SoftDeleteMixin, + TimestampMixin, +) + + +class OpsModelInstance(TimestampMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_model_instances" + __table_args__ = ( + Index( + "uk_ops_models_business_id", "workspace_id", "model_id", unique=True + ), + Index( + "uk_ops_models_source", + "workspace_id", + "source_system", + "source_model_ref", + unique=True, + ), + Index( + "idx_ops_models_filters", + "workspace_id", + "category_code", + "model_status", + "is_deleted", + ), + {"comment": "业务模型实例;模型平台可受控直写"}, + ) + + model_instance_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + bank_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + category_code: Mapped[str] = mapped_column(String(32), nullable=False) + model_id: Mapped[str] = mapped_column(String(128), nullable=False) + model_name: Mapped[str] = mapped_column(String(200), nullable=False) + model_status: Mapped[str] = mapped_column( + String(24), nullable=False, default="normal" + ) + current_version_id: Mapped[str | None] = mapped_column(String(26), index=True) + is_common_model: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + common_source_model_id: Mapped[str | None] = mapped_column(String(26), index=True) + common_model_name: Mapped[str | None] = mapped_column(String(200)) + source_system: Mapped[str] = mapped_column( + String(64), nullable=False, default="model_platform" + ) + source_model_ref: Mapped[str | None] = mapped_column(String(128)) + source_updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + + +class OpsModelVersion(TimestampMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_model_versions" + __table_args__ = ( + Index( + "uk_ops_model_versions_label", + "model_instance_id", + "version_label", + unique=True, + ), + Index( + "uk_ops_model_versions_source", + "workspace_id", + "source_system", + "source_version_ref", + unique=True, + ), + Index( + "idx_ops_model_versions_lifecycle", + "workspace_id", + "version_status", + "online_date", + "offline_date", + ), + {"comment": "模型版本与生命周期;模型平台可受控直写"}, + ) + + model_version_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + model_instance_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + version_label: Mapped[str] = mapped_column(String(64), nullable=False) + version_status: Mapped[str] = mapped_column( + String(24), nullable=False, default="active" + ) + platform_versions_id: Mapped[str | None] = mapped_column(String(26)) + developer_user_id: Mapped[str | None] = mapped_column(String(26)) + developer_display_name: Mapped[str | None] = mapped_column(String(100)) + development_date: Mapped[datetime.date | None] = mapped_column(Date()) + iteration_start_date: Mapped[datetime.date | None] = mapped_column(Date()) + last_iteration_date: Mapped[datetime.date | None] = mapped_column(Date()) + iteration_reason: Mapped[str | None] = mapped_column(String(1000)) + escort_start_date: Mapped[datetime.date | None] = mapped_column(Date()) + escort_end_date: Mapped[datetime.date | None] = mapped_column(Date()) + online_date: Mapped[datetime.date | None] = mapped_column(Date()) + offline_date: Mapped[datetime.date | None] = mapped_column(Date()) + development_ks: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + development_psi: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + max_lift: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + scoring_logic_storage_object_id: Mapped[str | None] = mapped_column(String(26)) + source_system: Mapped[str] = mapped_column( + String(64), nullable=False, default="model_platform" + ) + source_version_ref: Mapped[str | None] = mapped_column(String(128)) + source_updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + + +__all__ = ["OpsModelInstance", "OpsModelVersion"] diff --git a/common/src/common/db/models/operations/monitoring.py b/common/src/common/db/models/operations/monitoring.py new file mode 100644 index 0000000..b842cae --- /dev/null +++ b/common/src/common/db/models/operations/monitoring.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import Date, DateTime, Index, Integer, JSON, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from common.db.models.operations.base import ( + CreatedAtMixin, + OperationsBase, + SoftDeleteMixin, + TimestampMixin, +) + + +class OpsMonitorBatch(TimestampMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_monitor_batches" + __table_args__ = ( + Index( + "uk_ops_monitor_batches_source_no", + "workspace_id", + "source_system", + "source_batch_no", + unique=True, + ), + Index( + "uk_ops_monitor_batches_revision", + "workspace_id", + "source_system", + "monitor_month", + "revision_no", + unique=True, + ), + Index( + "idx_ops_monitor_batches_publish", + "workspace_id", + "batch_status", + "monitor_month", + "revision_no", + ), + {"comment": "月度监控写入批次与发布门闩"}, + ) + + batch_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + source_system: Mapped[str] = mapped_column( + String(64), nullable=False, default="model_platform" + ) + source_batch_no: Mapped[str] = mapped_column(String(128), nullable=False) + monitor_month: Mapped[datetime.date] = mapped_column(Date(), nullable=False) + revision_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + supersedes_batch_id: Mapped[str | None] = mapped_column(String(26)) + batch_status: Mapped[str] = mapped_column( + String(24), nullable=False, default="writing" + ) + expected_model_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + written_model_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + feature_row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + distribution_row_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0 + ) + checksum_sha256: Mapped[str | None] = mapped_column(String(64)) + generated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + published_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + published_by: Mapped[str | None] = mapped_column(String(26)) + failed_reason: Mapped[str | None] = mapped_column(String(2000)) + + +class OpsMonitorResult(CreatedAtMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_monitor_results" + __table_args__ = ( + Index( + "uk_ops_monitor_results_batch_model", + "batch_id", + "model_instance_id", + unique=True, + ), + Index( + "idx_ops_monitor_results_month", + "workspace_id", + "monitor_month", + "model_instance_id", + ), + {"comment": "单模型单月原始监控结果;发布后不可更新"}, + ) + + monitor_result_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + batch_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + model_instance_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + model_version_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + monitor_month: Mapped[datetime.date] = mapped_column(Date(), nullable=False) + source_result_ref: Mapped[str | None] = mapped_column(String(128)) + ranking_result: Mapped[str | None] = mapped_column(String(24)) + ks_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + psi_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + sample_count: Mapped[int | None] = mapped_column(Integer) + good_count: Mapped[int | None] = mapped_column(Integer) + bad_count: Mapped[int | None] = mapped_column(Integer) + source_result_json: Mapped[dict[str, Any] | None] = mapped_column(JSON()) + source_calculated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + + +class OpsMonitorFeatureMetric(CreatedAtMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_monitor_feature_metrics" + __table_args__ = ( + Index( + "uk_ops_feature_metrics_result_feature", + "monitor_result_id", + "feature_code", + unique=True, + ), + Index("idx_ops_feature_metrics_iv_drop", "monitor_result_id", "iv_drop_rate"), + Index( + "idx_ops_feature_metrics_csi_rise", "monitor_result_id", "csi_rise_rate" + ), + {"comment": "单月特征级 IV/CSI 与贡献变化"}, + ) + + feature_metric_id: Mapped[str] = mapped_column(String(26), primary_key=True) + monitor_result_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + feature_code: Mapped[str] = mapped_column(String(128), nullable=False) + feature_name: Mapped[str] = mapped_column(String(200), nullable=False) + iv_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + previous_iv_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + iv_drop_rate: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + csi_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + previous_csi_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + csi_rise_rate: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + ks_contribution_change: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + psi_contribution_change: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + source_metric_json: Mapped[dict[str, Any] | None] = mapped_column(JSON()) + + +class OpsMonitorDistribution(CreatedAtMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_monitor_distributions" + __table_args__ = ( + Index( + "uk_ops_distributions_bin", + "monitor_result_id", + "dimension_type", + "feature_code", + "bin_order", + unique=True, + ), + Index( + "idx_ops_distributions_feature", + "monitor_result_id", + "feature_code", + "bin_order", + ), + {"comment": "排序性评分分箱及特征分布"}, + ) + + distribution_id: Mapped[str] = mapped_column(String(26), primary_key=True) + monitor_result_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + dimension_type: Mapped[str] = mapped_column(String(24), nullable=False) + feature_code: Mapped[str] = mapped_column(String(128), nullable=False, default="") + feature_name: Mapped[str | None] = mapped_column(String(200)) + bin_order: Mapped[int] = mapped_column(Integer, nullable=False) + bin_code: Mapped[str] = mapped_column(String(128), nullable=False) + bin_label: Mapped[str] = mapped_column(String(255), nullable=False) + reference_period_label: Mapped[str | None] = mapped_column(String(64)) + reference_count: Mapped[int | None] = mapped_column(Integer) + reference_share: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + current_count: Mapped[int | None] = mapped_column(Integer) + current_share: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + good_count: Mapped[int | None] = mapped_column(Integer) + bad_count: Mapped[int | None] = mapped_column(Integer) + bad_rate: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + psi_component: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + csi_component: Mapped[Decimal | None] = mapped_column(Numeric(12, 8)) + + +__all__ = [ + "OpsMonitorBatch", + "OpsMonitorDistribution", + "OpsMonitorFeatureMetric", + "OpsMonitorResult", +] diff --git a/common/src/common/db/models/operations/reference.py b/common/src/common/db/models/operations/reference.py new file mode 100644 index 0000000..69d6351 --- /dev/null +++ b/common/src/common/db/models/operations/reference.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import datetime + +from sqlalchemy import DateTime, Index, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from common.db.models.operations.base import ( + OperationsBase, + SoftDeleteMixin, + TimestampMixin, +) + + +class OpsModelCategory(TimestampMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_model_categories" + __table_args__ = ( + Index("uk_ops_categories_name", "category_name", unique=True), + Index("idx_ops_categories_status", "status", "sort_order"), + {"comment": "固定模型大类字典,由运维模块维护"}, + ) + + category_code: Mapped[str] = mapped_column(String(32), primary_key=True) + category_name: Mapped[str] = mapped_column(String(100), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + + +class OpsBank(TimestampMixin, SoftDeleteMixin, OperationsBase): + __tablename__ = "ops_banks" + __table_args__ = ( + Index("uk_ops_banks_code", "workspace_id", "bank_code", unique=True), + Index( + "uk_ops_banks_source", + "workspace_id", + "source_system", + "source_bank_ref", + unique=True, + ), + Index( + "idx_ops_banks_workspace_status", + "workspace_id", + "bank_status", + "is_deleted", + ), + {"comment": "银行主数据;模型平台可受控直写"}, + ) + + bank_id: Mapped[str] = mapped_column(String(26), primary_key=True) + workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) + bank_code: Mapped[str] = mapped_column(String(64), nullable=False) + bank_name: Mapped[str] = mapped_column(String(150), nullable=False) + is_wuji_bank: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + bank_status: Mapped[str] = mapped_column( + String(16), nullable=False, default="active" + ) + source_system: Mapped[str] = mapped_column( + String(64), nullable=False, default="model_platform" + ) + source_bank_ref: Mapped[str | None] = mapped_column(String(128)) + source_updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime()) + + +__all__ = ["OpsBank", "OpsModelCategory"] diff --git a/common/src/common/db/session.py b/common/src/common/db/session.py index 0b9836d..b17ea77 100644 --- a/common/src/common/db/session.py +++ b/common/src/common/db/session.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -55,3 +56,18 @@ async def session_scope( except Exception: await session.rollback() raise + + +@asynccontextmanager +async def readonly_session_scope( + factory: AsyncSessionFactory, +) -> AsyncIterator[AsyncSession]: + """Yield a session whose MySQL transaction is explicitly read-only.""" + async with factory() as session: + try: + bind = session.get_bind() + if bind.dialect.name == "mysql": + await session.execute(text("SET TRANSACTION READ ONLY")) + yield session + finally: + await session.rollback() diff --git a/docker-compose.yml b/docker-compose.yml index 5ea023e..fbf7a98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,6 +78,15 @@ services: # No local-FS volume: backend stores everything in S3 (S3_*). environment: DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + OPERATIONS_DATABASE_URL: ${OPERATIONS_DATABASE_URL:-} + PLATFORM_READ_DATABASE_URL: ${PLATFORM_READ_DATABASE_URL:-} + DEPLOY_DATABASE_URL: ${DEPLOY_DATABASE_URL:-} + REDIS_URL: ${REDIS_URL:-} + OPERATIONS_CACHE_TTL_SECONDS: ${OPERATIONS_CACHE_TTL_SECONDS:-300} + OPERATIONS_REDIS_PREFIX: ${OPERATIONS_REDIS_PREFIX:-model-platform:operations} + OPERATIONS_EVENT_STREAM: ${OPERATIONS_EVENT_STREAM:-model-platform:operations:events} + OPERATIONS_EVENT_STREAM_MAXLEN: ${OPERATIONS_EVENT_STREAM_MAXLEN:-10000} + OPERATIONS_DATA_MODE: ${OPERATIONS_DATA_MODE:-database} SERVICE_NAME: model-platform-backend SCHEDULE_EVENT_NAMESPACE: ${SCHEDULE_EVENT_NAMESPACE:-model-platform-local} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} diff --git a/docs/architecture/A卡模型运维模块-后端架构与数据库设计-V0.2.md b/docs/architecture/A卡模型运维模块-后端架构与数据库设计-V0.2.md new file mode 100644 index 0000000..2e89a93 --- /dev/null +++ b/docs/architecture/A卡模型运维模块-后端架构与数据库设计-V0.2.md @@ -0,0 +1,308 @@ +# A 卡模型运维模块后端架构与数据库设计 V0.2 + +> 状态:设计评审稿;独立库 DDL 已生成但尚未执行,ORM、迁移和接口尚未实现 +> 日期:2026-08-31 +> 依据:当前 `develop` 工程、运维前端 V1.9、现有三条 API 契约及甲方提供的 `model_platform` 18 张表 DDL + +## 1. 结论 + +采用“**同一工程、两个数据库、平台库只读、运维库自有**”的方式: + +- 现有 `model_platform` 库保持不变,只读复用用户、角色、工作空间、对象存储、脚本版本和调度数据。 +- 新建独立 `model_operations` 库;模型平台直接写入其中 7 张受控源表,不通过运维平台复制一份数据。 +- 运维模块只读取状态为 `published` 的监控批次,未发布或失败批次对页面不可见。 +- 判级、复核、报告、流程、文档、Prompt 和配置由运维模块独立维护,模型平台不得写入。 +- 已发布监控数据不原地覆盖;勘误时新增修订批次,保留历史版本和审计链。 +- 首期不新增微服务、Redis或消息中间件;复用现有 Nginx、React、FastAPI、对象存储和 Schedule Executor,新库自带独立 Outbox/Inbox。 + +这能满足“模型方直接写业务表”和“我方单独建库”的双重要求,同时避免取得现有平台库不必要的写权限。 + +## 2. 模块边界 + +### 2.1 模型平台负责 + +- 银行、模型实例、模型版本及生命周期数据的写入。 +- 月度监控原始结果、特征指标和分箱分布数据的计算与写入。 +- 写入批次的完整性校验、计数、校验和及发布。 +- 仅获得 `model_operations` 7 张源表的受控写权限,以及发布事件所需的 Outbox 插入权限。 +- 已发布数据发生错误时,以新修订批次更正,不修改旧记录。 + +### 2.2 运维模块负责 + +- 按已发布原始结果和已发布规则版本计算异常等级、监控结果等级和命中原因。 +- 模型团队初审、业务团队终审、超时默认不处理及全程留痕。 +- 监控/诊断报告生成、编辑、发送、汇总和历史修订。 +- 七阶段开发评审流程、材料、知识库、规则、Prompt、报告模板和按银行配置。 +- 工作台、模型大类、细分银行、监控明细和详情等查询 API。 + +### 2.3 明确不做 + +- 运维模块不重复建设账号、角色、权限表;只复用登录模块返回的用户、角色和工作空间。 +- 运维模块不得读取 `model_platform.users.password_hash`,不得写现有平台任一业务表。 +- 模型平台不写判级、处理、报告、流程或配置表。 +- “手工同步”不再复制数据;其含义调整为“校验最新发布批次并重建派生判级”。 +- 报告汇总、知识库和工作台不单独建冗余汇总表,首期由业务表查询生成。 + +## 3. 总体架构 + +现有服务拓扑保持不变。运维能力作为 FastAPI 后端内的新 bounded context 接入,但增加第二套数据库连接: + +1. 浏览器继续经 Nginx 访问 React SPA 和同源 `/api/v1/*`。 +2. `DATABASE_URL` 继续连接 `model_platform`,只承担现有认证、工作空间、存储和调度能力。 +3. 新增 `OPERATIONS_DATABASE_URL` 连接 `model_operations`,承载全部运维业务数据。 +4. 模型平台模块或调度任务直接写新库的 `ops_*` 源表。 +5. 批次发布后,运维服务计算并保存规则判级快照。 +6. 运维 API 联查新库源数据、判级和处理结果;用户显示名等按需只读平台库。 +7. 报告生成、提醒等动作写新库 `ops_outbox_events`;接入异步阶段时让 Schedule Executor 增加运维库轮询连接。 +8. 报告文件、流程材料继续复用平台库 `storage_objects` 与现有对象存储服务,运维库只保存对象 ID。 + +配套图: + +- `A卡模型运维模块-系统架构-V0.2.png/.svg` +- `A卡模型运维模块-数据域关系-V0.2.png/.svg` + +修改 YAML 后运行 `python3 docs/architecture/render_diagrams.py` 可重新生成两种格式;脚本依赖 `diagrams`、`pyyaml` 和本机 Graphviz。 + +配套建库建表脚本位于 `docs/database/model_operations-V0.1/`,包含 24 张表、1 个视图、四类模型大类种子数据和静态安全校验脚本;原始平台 DDL 已只读归档到 `docs/database/reference/`。 + +## 4. 数据设计约定 + +- 表名前缀统一为 `ops_`,与现有模型平台表隔离。 +- 全部 `ops_*` 对象均位于独立 `model_operations` 库。 +- 主键统一使用 ULID `CHAR(26)`;日期时间统一 `DATETIME(3)`。 +- 月份在库内用 `DATE` 且固定为当月 1 日,API 序列化为 `YYYY-MM`。 +- KS、PSI、IV、CSI、坏账率和降幅在数据库中统一存 **0~1 比率**,使用 `DECIMAL(12,8)`;API 转换为 0~100 的百分数。 +- 沿用当前仓库约定:不创建物理外键,使用 `fk_*` 逻辑索引并在服务层校验完整性。 +- 可修改业务状态表使用 `state_version INTEGER` 做乐观锁。 +- 需要删除的配置或流程数据采用 `is_deleted`、`deleted_at` 软删除;监控历史不允许删除。 +- 所有根业务表包含 `workspace_id`,避免未来多个项目空间数据串用。 +- `workspace_id/user_id/storage_object_id/platform_versions_id` 是对 `model_platform` 的逻辑引用,不建立跨库物理外键。 + +## 5. 表清单与归属 + +### 5.1 模型平台可写源表(7 张) + +| 表 | 用途 | 核心唯一性 | +|---|---|---| +| `ops_banks` | 银行主数据及无极银行标记 | `(workspace_id, bank_code)` | +| `ops_model_instances` | 银行下的模型实例、通用模型关系、当前版本 | `(workspace_id, model_id)` | +| `ops_model_versions` | 版本、开发指标和生命周期日期 | `(model_instance_id, version_label)` | +| `ops_monitor_batches` | 月度写入批次、修订号、发布状态和校验和 | `(workspace_id, source_system, monitor_month, revision_no)` | +| `ops_monitor_results` | 模型单月原始排序性、KS、PSI和样本量 | `(batch_id, model_instance_id)` | +| `ops_monitor_feature_metrics` | 单月特征级 IV、CSI及贡献变化 | `(monitor_result_id, feature_code)` | +| `ops_monitor_distributions` | 评分分箱或特征分箱的基准期/当期分布 | `(monitor_result_id, dimension_type, feature_code, bin_order)` | + +`ops_model_categories` 为运维模块维护的固定字典,模型平台只有读取权限。首期种子为 `std / bai / big / afd` 四类。 + +### 5.2 运维模块独占业务表(15 张) + +| 数据域 | 表 | +|---|---| +| 字典 | `ops_model_categories` | +| 判级与处理 | `ops_rule_versions`、`ops_rule_items`、`ops_monitor_evaluations`、`ops_monitor_reviews` | +| 报告治理 | `ops_reports`、`ops_report_revisions`、`ops_report_template_versions`、`ops_prompt_versions`、`ops_prompt_regression_runs`、`ops_bank_report_configs` | +| 流程与材料 | `ops_workflows`、`ops_workflow_stages`、`ops_documents` | +| 使用统计 | `ops_usage_events` | + +不新增通知任务表;通知和后台报告任务复用新库 `ops_outbox_events`。若后续需要展示逐次送达结果,再增通知投递日志表。 + +### 5.3 运维库技术表(2 张) + +- `ops_outbox_events`:与运维业务写入同事务提交的后台事件。 +- `ops_consumer_inbox`:异步消费者幂等去重。 + +新库合计 **24 张表 + 1 个当前监控结果视图**。不直接写 `model_platform.outbox_events`,防止运维事件污染现有模型调度事件流。 + +## 6. 核心表字段初稿 + +### 6.1 模型与版本 + +`ops_model_instances` + +- `model_instance_id`、`workspace_id`、`bank_id`、`category_id` +- `model_id`、`model_name`、`model_status` +- `current_version_id` +- `is_common_model`、`common_source_model_id`、`common_model_name` +- `source_updated_at`、`created_at`、`updated_at` + +`ops_model_versions` + +- `model_version_id`、`model_instance_id`、`version_label`、`version_status` +- `developer_user_ref`、`developer_display_name` +- `development_date`、`iteration_start_date`、`last_iteration_date`、`iteration_reason` +- `escort_start_date`、`escort_end_date`、`online_date`、`offline_date` +- `development_ks`、`development_psi`、`max_lift` +- `scoring_logic_storage_object_id`、`source_updated_at` + +模型实例只保存当前版本指针,所有生命周期和开发指标按版本留存。 + +### 6.2 监控批次和原始结果 + +`ops_monitor_batches` + +- `batch_id`、`workspace_id`、`source_system`、`source_batch_no` +- `monitor_month`、`revision_no` +- `batch_status`:`writing / published / failed / superseded` +- `expected_model_count`、`written_model_count`、`feature_row_count`、`distribution_row_count` +- `checksum_sha256`、`generated_at`、`published_at`、`failed_reason` + +`ops_monitor_results` + +- `monitor_result_id`、`batch_id`、`model_instance_id`、`model_version_id`、`monitor_month` +- `ranking_result`:`matched / unmatched / not_applicable` +- `ks_value`、`psi_value` +- `sample_count`、`good_count`、`bad_count` +- `source_result_json`:仅保存尚未结构化且确有追溯价值的源字段 +- `calculated_at`、`created_at` + +源表不保存 A/B/C、异常等级、处理建议或报告状态,避免两个模块同时改一行。 + +### 6.3 判级快照 + +`ops_monitor_evaluations` + +- `evaluation_id`、`monitor_result_id`、`rule_version_id`、`rule_item_id` +- `ks_mom_drop_rate`、`secondary_level2_hits_6m` +- `abnormal_level`:`normal / level1 / level2 / level3` +- `monitor_grade`:`A / B / C` +- `reason_code`、`reason_text_snapshot`、`action_snapshot` +- `is_current`、`evaluated_at` + +每次规则发布或源数据修订都新增判级记录;旧快照不覆盖。页面默认读取 `is_current=1` 的记录,历史报告仍绑定生成时的判级快照。 + +### 6.4 两段处理 + +`ops_monitor_reviews` + +- `review_id`、`monitor_result_id`、`evaluation_id` +- `review_stage`:`model_initial / business_final` +- `review_status`:`pending / handled / auto_closed` +- `decision`:`no_action / tune_or_rebuild` +- `handling_note`(手工处理必填) +- `due_at`、`handled_by`、`handled_at`、`auto_closed_at` +- `state_version`、`created_at`、`updated_at` + +唯一约束为 `(monitor_result_id, review_stage)`。模型团队阶段完成后才创建或激活业务团队阶段;超时任务通过 Outbox 执行默认“不处理”。 + +### 6.5 报告 + +`ops_reports` 保存报告主状态与绑定快照,`ops_report_revisions` 保存每次正文修改: + +- 报告绑定 `monitor_result_id + evaluation_id + template_version_id + prompt_version_id`。 +- `ops_report_revisions` 使用 `(report_id, revision_no)` 唯一约束,正文和结构化快照均不可变。 +- 监控报告页默认只查询最新监控月份;报告汇总页查询全部历史。数据库不删除旧月份报告。 +- PDF/Excel 等文件通过 `storage_object_id` 指向现有 `storage_objects`。 + +### 6.6 流程、材料与知识库 + +- `ops_workflows`:银行、模型大类、模型实例、独立开发/复用通用模型、当前阶段、状态和计划日期。 +- `ops_workflow_stages`:1~7 阶段实例、负责人角色、开始/截止/完成时间、确认状态和停滞天数。 +- `ops_documents`:同时支持 `storage_object_id` 和 `external_url`,可关联流程、阶段、模型和版本。 +- 知识库不是新表,而是对 `ops_documents` 按银行、模型、版本、环节和通用模型标记查询。 + +## 7. 发布协议 + +模型平台每次写入必须执行以下协议: + +1. 创建 `ops_monitor_batches`,状态为 `writing`,携带幂等的 `source_batch_no`。 +2. 幂等写入银行、模型实例和版本;插入本批次结果、特征指标和分布。 +3. 校验模型数、明细行数、必填字段、指标范围和 SHA-256 校验和。 +4. 在一个短事务内将批次改为 `published`;同月份上一修订改为 `superseded`。 +5. 写入 `monitor_batch.published` Outbox 事件,触发运维模块生成判级快照。 +6. 运维查询只允许关联 `published` 批次,并按最高修订号取当前结果。 + +失败批次保留为 `failed` 供排查。任何已发布结果、特征和分布均不得执行 `UPDATE` 或物理删除。 + +## 8. 数据库权限 + +建议生产环境至少分四类账号: + +| 账号 | 权限 | +|---|---| +| `schema_migrator` | 仅部署时使用,可对 `model_operations` 执行 DDL;不改 `model_platform` | +| `platform_reader` | 对平台库指定字段只读;禁止读取密码散列和权限内部表 | +| `model_writer` | 读取字典;对新库 7 张源表执行限定 `SELECT/INSERT/UPDATE`,并插入发布事件;无 `DELETE`,不能写运维独占表 | +| `operations_app` | 读写运维库;只读平台库必要表;服务层禁止修改已发布源结果 | + +若模型任务与运维 API 暂时运行在同一 FastAPI 进程,代码层仍按平台库/运维库两个 session factory 和 repository 隔离;生产批处理脚本优先使用独立 `MODEL_WRITER_DATABASE_URL`,将权限边界落实到数据库。 + +## 9. API 设计 + +### 9.1 首条真实纵向链路 + +保持前端已写好的路径,不重新发明契约: + +| 接口 | 主要数据来源 | +|---|---| +| `GET /api/v1/operations/models` | 模型实例 + 当前版本 + 最新已发布结果 + 当前判级 + 最近处理 | +| `GET /api/v1/operations/models/{model_id}` | 单模型完整元数据和最新状态 | +| `GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM` | 指定月最新已发布修订 + 当前判级 | + +所有接口返回现有 `{ data, meta? }` 包装。切换 `VITE_OPERATIONS_API_MODE=api` 前,前端 `operationsApi.ts` 需要统一附加当前 `workspace_id`,用于选择并校验数据空间。 + +### 9.2 后续资源接口 + +- 查询:`/model-categories/overview`、`/banks/overview`、`/monitor-results`、`/monitor-results/{id}/features`、`/monitor-results/{id}/distributions` +- 处理:`/monitor-results/{id}/reviews/model-initial`、`/reviews/business-final` +- 报告:`/reports`、`/reports/{id}`、`/reports/{id}/revisions`、`/reports/{id}/send` +- 流程:`/workflows`、`/workflows/{id}`、`/workflows/{id}/stages/{stage}`、`/documents` +- 治理:`/rule-versions`、`/prompt-versions`、`/report-template-versions`、`/bank-report-configs` + +列表统一支持分页、排序和与 V1.9 筛选框一致的查询参数;Excel/PDF 导出走独立端点,不让普通列表接口返回超大数据集。 + +## 10. 登录与角色 + +- 不增加运维 RBAC 表,也不接管现有权限管理模块。 +- API 复用登录 Cookie、`Users`、`Roles` 和当前工作空间,不创建新的角色或权限数据。 +- 新增轻量 `operations_context`:校验用户已登录且可访问所选工作空间;业务角色取与 `/api/v1/auth/me`、前端 `AuthUser.role_code` 相同的登录角色来源,避免前后端角色口径不一致。 +- 角色映射约定:`admin` → 管理员;`developer/model_team` → 模型团队;`business/business_team/biz` → 业务团队。 +- 前端按角色决定页面呈现,后端仍必须按角色校验写操作,不能把前端隐藏按钮当安全边界。 +- 角色代码最终值由登录/权限模块给出,运维模块只维护一处映射常量。 + +## 11. 代码落位建议 + +沿用现有 router / schema / service 分层,每个文件不超过 500 行: + +```text +common/src/common/db/models/operations/ + reference.py models.py monitoring.py governance.py + reviews.py reports.py workflows.py usage.py +backend/src/backend/api/operations/ + __init__.py _deps.py models.py monitoring.py reviews.py + reports.py workflows.py governance.py +backend/src/backend/services/operations/ + queries.py evaluation.py publishing.py reports.py workflows.py +backend/src/backend/schemas/operations/ + common.py models.py monitoring.py reports.py workflows.py governance.py +``` + +运维 ORM 使用独立 Alembic 版本线和 `OPERATIONS_DATABASE_URL`,不能让现有 `model_platform` 基线迁移误扫新表。`backend.main` 创建第二个 engine/session factory 并只注册一个 `operations_router`;业务规则不写进路由文件。 + +## 12. 实施顺序 + +### P0:首条真实链路 + +1. DBA 审核并创建 `model_operations`;建模型/版本/批次/结果/特征/分布及规则判级 11 张核心表。 +2. 导入一组 2026-07 样例数据并走完整 `writing → published` 协议。 +3. 实现三条既有 API,前端追加 `workspace_id` 后切到 API 模式。 +4. 验证列表、模型详情、单月监控详情数值一致。 + +### P1:业务闭环 + +实现两段复核、报告及修订、七阶段流程、材料和知识库。 + +### P2:治理与运营 + +实现规则版本、Prompt 回归、模板版本、使用统计、提醒和导出优化。 + +## 13. 开发前需共同确认的 5 项 + +以下均给出推荐默认值;未得到反对意见时可按推荐值实施: + +1. **模型 ID 唯一性**:推荐 `model_id` 在工作空间内全局唯一,不只在银行内唯一。 +2. **指标单位**:推荐数据库存 0~1 比率,API 输出百分数;禁止两种单位混写。 +3. **批次粒度**:推荐一个来源系统每月一个全量批次,勘误整体升修订号。 +4. **工作空间范围**:推荐全部根表带 `workspace_id` 并逻辑引用 `model_platform.workspaces`,即使首期只有一个 A 卡项目空间。 +5. **模型开发材料**:推荐运维表同时支持对象存储文件和模型平台链接,避免重复上传。 + +这 5 项确认后即可开始 ORM、Alembic 迁移和首条真实接口实现。 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/architecture/A卡模型运维模块-数据域关系-V0.2.png b/docs/architecture/A卡模型运维模块-数据域关系-V0.2.png new file mode 100644 index 0000000..eed4eed Binary files /dev/null and b/docs/architecture/A卡模型运维模块-数据域关系-V0.2.png differ diff --git a/docs/architecture/A卡模型运维模块-数据域关系-V0.2.svg b/docs/architecture/A卡模型运维模块-数据域关系-V0.2.svg new file mode 100644 index 0000000..2834c3c --- /dev/null +++ b/docs/architecture/A卡模型运维模块-数据域关系-V0.2.svg @@ -0,0 +1,331 @@ + + + + + + +A 卡模型运维模块 · 双库数据域关系 V0.2 +平台标识只读引用,运维业务全部落入独立库 + +A 卡模型运维模块 · 双库数据域关系 V0.2 +平台标识只读引用,运维业务全部落入独立库 + +cluster_model_platform · 现有库(只读依赖) + +model_platform · 现有库(只读依赖) + + +cluster_model_operations · 模型方受控直写(7 张) + +model_operations · 模型方受控直写(7 张) + + +cluster_model_operations · 运维判级与处理 + +model_operations · 运维判级与处理 + + +cluster_model_operations · 报告、流程与运营 + +model_operations · 报告、流程与运营 + + + +5e390f22af134f76b948b6179f886986 + +users + roles +用户与登录角色 + + + +fa2425ff55b64fb68447fbe9df6124c2 + +ops_monitor_reviews +模型初审 → 业务终审 + + + +5e390f22af134f76b948b6179f886986->fa2425ff55b64fb68447fbe9df6124c2 + + +处理人 ID / 显示名 + + + +f7ad9eee12d4406182ca79edef7e10cc + +ops_workflows + stages +七阶段流程 + + + +5e390f22af134f76b948b6179f886986->f7ad9eee12d4406182ca79edef7e10cc + + +发起人 / 经办人 + + + +2f8f6abee8874b8083218fafe3dfceb4 + +workspaces + members +项目空间与成员 + + + +9f7470d84e0f495d9e32d9e83ee777ae + +ops_model_instances +模型实例 + + + +2f8f6abee8874b8083218fafe3dfceb4->9f7470d84e0f495d9e32d9e83ee777ae + + +workspace_id 逻辑引用 + + + +b5bf8bfd876b4a8298364b8832b1a1f8 + +storage_objects + versions +文件与开发制品 + + + +d63ec4b0c1f54f28a1b6e9e25ced7720 + +ops_model_versions +业务模型版本 + + + +b5bf8bfd876b4a8298364b8832b1a1f8->d63ec4b0c1f54f28a1b6e9e25ced7720 + + +可选开发制品引用 + + + +52370f80438a4639b04449518b48b710 + +ops_documents +材料 / 知识库索引 + + + +b5bf8bfd876b4a8298364b8832b1a1f8->52370f80438a4639b04449518b48b710 + + +storage_object_id + + + +0eef55ad5f8e482d84f70346e8a07c51 + +schedules + runs +模型计算任务 + + + +e39a175c784d4021862ca0594e08476b + +ops_monitor_batches +发布批次 / 修订 + + + +0eef55ad5f8e482d84f70346e8a07c51->e39a175c784d4021862ca0594e08476b + + +计算任务产出 + + + +cd5ab313d36f43718a23c57ae60110d3 + +ops_banks +银行 + + + +cd5ab313d36f43718a23c57ae60110d3->9f7470d84e0f495d9e32d9e83ee777ae + + +1:N + + + +9f7470d84e0f495d9e32d9e83ee777ae->d63ec4b0c1f54f28a1b6e9e25ced7720 + + +1:N + + + +9f7470d84e0f495d9e32d9e83ee777ae->f7ad9eee12d4406182ca79edef7e10cc + + +开发 / 复用通用模型 + + + +c270303679e04e67b03d65b8a341a0a3 + +ops_monitor_results +单月原始指标 + + + +d63ec4b0c1f54f28a1b6e9e25ced7720->c270303679e04e67b03d65b8a341a0a3 + + +版本快照 + + + +e39a175c784d4021862ca0594e08476b->c270303679e04e67b03d65b8a341a0a3 + + +published 后可见 + + + +a96305dd888e46aba9b6b0ff43b5317f + +ops_monitor_feature_metrics +IV / CSI / 贡献 + + + +c270303679e04e67b03d65b8a341a0a3->a96305dd888e46aba9b6b0ff43b5317f + + +1:N + + + +164a58bf45ff4224aa7f5e8ec2378bf7 + +ops_monitor_distributions +评分 / 特征分箱 + + + +c270303679e04e67b03d65b8a341a0a3->164a58bf45ff4224aa7f5e8ec2378bf7 + + +1:N + + + +5aef5a7b826344848389149b9879e85b + +ops_monitor_evaluations +判级快照 + + + +c270303679e04e67b03d65b8a341a0a3->5aef5a7b826344848389149b9879e85b + + +原始指标 → 判级 + + + +12ba8a67ad2f4749b9fb5da5367a4c50 + +ops_model_categories +固定大类字典 + + + +12ba8a67ad2f4749b9fb5da5367a4c50->9f7470d84e0f495d9e32d9e83ee777ae + + +1:N + + + +08cea7b88ab94490a5a850f76ad9efd8 + +ops_rule_versions + items +规则版本与矩阵 + + + +08cea7b88ab94490a5a850f76ad9efd8->5aef5a7b826344848389149b9879e85b + + +按已发布规则计算 + + + +5aef5a7b826344848389149b9879e85b->fa2425ff55b64fb68447fbe9df6124c2 + + +触发两段处理 + + + +d67d14f80f4b4ce39377cb6b59ae2311 + +ops_reports + revisions +报告与不可变修订 + + + +5aef5a7b826344848389149b9879e85b->d67d14f80f4b4ce39377cb6b59ae2311 + + +绑定判级快照 + + + +c395893276dd4282beb453109dd0f4c7 + +ops_outbox + inbox +独立异步事件流 + + + +d67d14f80f4b4ce39377cb6b59ae2311->c395893276dd4282beb453109dd0f4c7 + + +生成 / 提醒事件 + + + +5ff3174966bd40cc958a6b27dd619911 + +模板 / Prompt / 回归 +按银行报告配置 + + + +5ff3174966bd40cc958a6b27dd619911->d67d14f80f4b4ce39377cb6b59ae2311 + + +模板 + Prompt + + + +f7ad9eee12d4406182ca79edef7e10cc->52370f80438a4639b04449518b48b710 + + +阶段材料 + + + +1cd6cd505e6d4bcb90641198590c283d + +ops_usage_events +登录 / 需求 / 报告行为 + + + diff --git a/docs/architecture/A卡模型运维模块-数据域关系-V0.2.yaml b/docs/architecture/A卡模型运维模块-数据域关系-V0.2.yaml new file mode 100644 index 0000000..c2856a9 --- /dev/null +++ b/docs/architecture/A卡模型运维模块-数据域关系-V0.2.yaml @@ -0,0 +1,71 @@ +title: "A 卡模型运维模块 · 双库数据域关系 V0.2\n平台标识只读引用,运维业务全部落入独立库" +direction: LR +formats: [png, svg] +theme: + font: "PingFang SC" + title_size: 24 + node_size: 10.5 + edge_size: 8.8 + splines: spline + nodesep: 0.5 + ranksep: 0.82 + dpi: 180 + pad: 0.5 + size: "21,13!" +clusters: + - name: "model_platform · 现有库(只读依赖)" + style: { bg: "#F5F6F8", pen: "#AEB7C4", dashed: true, margin: 18 } + nodes: + - { id: identity, label: "users + roles\n用户与登录角色", icon: flowdb } + - { id: workspace, label: "workspaces + members\n项目空间与成员", icon: flowdb } + - { id: artifacts, label: "storage_objects + versions\n文件与开发制品", icon: flowdb } + - { id: platform_jobs, label: "schedules + runs\n模型计算任务", icon: flowdb } + - name: "model_operations · 模型方受控直写(7 张)" + style: { bg: "#EEF5FD", pen: "#6F9ED4", margin: 18 } + nodes: + - { id: banks, label: "ops_banks\n银行", icon: flowdb } + - { id: models, label: "ops_model_instances\n模型实例", icon: flowdb } + - { id: versions, label: "ops_model_versions\n业务模型版本", icon: flowdb } + - { id: batches, label: "ops_monitor_batches\n发布批次 / 修订", icon: flowdb } + - { id: results, label: "ops_monitor_results\n单月原始指标", icon: flowdb } + - { id: features, label: "ops_monitor_feature_metrics\nIV / CSI / 贡献", icon: flowdb } + - { id: distributions, label: "ops_monitor_distributions\n评分 / 特征分箱", icon: flowdb } + - name: "model_operations · 运维判级与处理" + style: { bg: "#F1F8F5", pen: "#74A087", margin: 18 } + nodes: + - { id: categories, label: "ops_model_categories\n固定大类字典", icon: flowdb } + - { id: rules, label: "ops_rule_versions + items\n规则版本与矩阵", icon: flowdb } + - { id: evaluations, label: "ops_monitor_evaluations\n判级快照", icon: flowdb } + - { id: reviews, label: "ops_monitor_reviews\n模型初审 → 业务终审", icon: flowdb } + - name: "model_operations · 报告、流程与运营" + style: { bg: "#FAF6EE", pen: "#C5A467", margin: 18 } + nodes: + - { id: reports, label: "ops_reports + revisions\n报告与不可变修订", icon: flowdb } + - { id: governance, label: "模板 / Prompt / 回归\n按银行报告配置", icon: flowdb } + - { id: workflows, label: "ops_workflows + stages\n七阶段流程", icon: flowdb } + - { id: documents, label: "ops_documents\n材料 / 知识库索引", icon: flowdb } + - { id: usage, label: "ops_usage_events\n登录 / 需求 / 报告行为", icon: flowdb } + - { id: events, label: "ops_outbox + inbox\n独立异步事件流", icon: flowdb } +edges: + - { from: identity, to: reviews, label: "处理人 ID / 显示名", type: dep } + - { from: identity, to: workflows, label: "发起人 / 经办人", type: dep } + - { from: workspace, to: models, label: "workspace_id 逻辑引用", type: dep } + - { from: artifacts, to: versions, label: "可选开发制品引用", type: dep } + - { from: artifacts, to: documents, label: "storage_object_id", type: dep } + - { from: platform_jobs, to: batches, label: "计算任务产出", type: dep } + - { from: banks, to: models, label: "1:N", type: plain } + - { from: categories, to: models, label: "1:N", type: plain } + - { from: models, to: versions, label: "1:N", type: plain } + - { from: batches, to: results, label: "published 后可见", type: write, width: 2.0 } + - { from: versions, to: results, label: "版本快照", type: plain } + - { from: results, to: features, label: "1:N", type: plain } + - { from: results, to: distributions, label: "1:N", type: plain } + - { from: rules, to: evaluations, label: "按已发布规则计算", type: dep } + - { from: results, to: evaluations, label: "原始指标 → 判级", type: write, width: 2.0 } + - { from: evaluations, to: reviews, label: "触发两段处理", type: flow } + - { from: evaluations, to: reports, label: "绑定判级快照", type: flow } + - { from: governance, to: reports, label: "模板 + Prompt", type: dep } + - { from: models, to: workflows, label: "开发 / 复用通用模型", type: flow } + - { from: workflows, to: documents, label: "阶段材料", type: write } + - { from: reports, to: events, label: "生成 / 提醒事件", type: async } + diff --git a/docs/architecture/A卡模型运维模块-系统架构-V0.2.png b/docs/architecture/A卡模型运维模块-系统架构-V0.2.png new file mode 100644 index 0000000..8412188 Binary files /dev/null and b/docs/architecture/A卡模型运维模块-系统架构-V0.2.png differ diff --git a/docs/architecture/A卡模型运维模块-系统架构-V0.2.svg b/docs/architecture/A卡模型运维模块-系统架构-V0.2.svg new file mode 100644 index 0000000..d0088f8 --- /dev/null +++ b/docs/architecture/A卡模型运维模块-系统架构-V0.2.svg @@ -0,0 +1,204 @@ + + + + + + +A 卡模型运维模块 · 推荐系统架构 V0.2 +同一工程 / 双数据库 / 平台库只读 / 运维库自有 + +A 卡模型运维模块 · 推荐系统架构 V0.2 +同一工程 / 双数据库 / 平台库只读 / 运维库自有 + +cluster_入口层(现有) + +入口层(现有) + + +cluster_统一 FastAPI 工程(现有服务内新增模块) + +统一 FastAPI 工程(现有服务内新增模块) + + +cluster_model_platform(现有库,不改表) + +model_platform(现有库,不改表) + + +cluster_model_operations(新建独立库) + +model_operations(新建独立库) + + +cluster_异步执行(现有服务扩展连接) + +异步执行(现有服务扩展连接) + + + +a43539db1c804657b3d885ca10362e1f + +业务团队 / 模型团队 +管理员 + + + +899fff0a9a094701b6f22f9956bcdb3a + +Nginx Gateway +唯一入口 + + + +a43539db1c804657b3d885ca10362e1f->899fff0a9a094701b6f22f9956bcdb3a + + +HTTPS + + + +89f32bf7b7fc46a28737c75e205d9b60 + +React SPA +模型平台 + 运维平台 + + + +899fff0a9a094701b6f22f9956bcdb3a->89f32bf7b7fc46a28737c75e205d9b60 + + +静态资源 + + + +9296664724924c399cb8cfb5068084fc + +模型平台模块 +现有功能 + 模型源数据写入 + + + +899fff0a9a094701b6f22f9956bcdb3a->9296664724924c399cb8cfb5068084fc + + +模型平台 API + + + +9aabb7a0ed52461f9dcc185116311d37 + +运维模块 +查询 / 判级 / 复核 / 报告 / 流程 + + + +899fff0a9a094701b6f22f9956bcdb3a->9aabb7a0ed52461f9dcc185116311d37 + + +运维 API + + + +69e41aadb5c1457698ca525cb60f84f4 + +MySQL · model_platform +用户 / 角色 / 工作空间 / 调度 + + + +9296664724924c399cb8cfb5068084fc->69e41aadb5c1457698ca525cb60f84f4 + + +现有平台读写 + + + +aa5be898225c4a13839eb1bf2df335f2 + +7 张模型源表 +模型方受控直写 + + + +9296664724924c399cb8cfb5068084fc->aa5be898225c4a13839eb1bf2df335f2 + + +受控 SQL 直写 +writing → published + + + +9aabb7a0ed52461f9dcc185116311d37->69e41aadb5c1457698ca525cb60f84f4 + + +指定表 / 字段只读 + + + +16645ab788e94fef923a94e81cca369c + +对象存储 + storage_objects +文件本体与平台资源 + + + +9aabb7a0ed52461f9dcc185116311d37->16645ab788e94fef923a94e81cca369c + + +复用文件服务 + + + +9aabb7a0ed52461f9dcc185116311d37->aa5be898225c4a13839eb1bf2df335f2 + + +只读已发布修订 + + + +c96300e2e41e48dd97521fa7cb378d72 + +15 张运维业务表 +判级 / 处理 / 报告 / 流程 + + + +9aabb7a0ed52461f9dcc185116311d37->c96300e2e41e48dd97521fa7cb378d72 + + +业务读写 + + + +bcaf3960a54f41b9bc84a892ea5cb7bb + +ops_outbox_events +运维库独立事件流 + + + +9aabb7a0ed52461f9dcc185116311d37->bcaf3960a54f41b9bc84a892ea5cb7bb + + +同事务写事件 + + + +2b9a60d3180746c5a0ebb4649ddfd854 + +Schedule Executor +增加运维库 Outbox 轮询 + + + +bcaf3960a54f41b9bc84a892ea5cb7bb->2b9a60d3180746c5a0ebb4649ddfd854 + + +轮询 / 重试 + + + diff --git a/docs/architecture/A卡模型运维模块-系统架构-V0.2.yaml b/docs/architecture/A卡模型运维模块-系统架构-V0.2.yaml new file mode 100644 index 0000000..886da90 --- /dev/null +++ b/docs/architecture/A卡模型运维模块-系统架构-V0.2.yaml @@ -0,0 +1,55 @@ +title: "A 卡模型运维模块 · 推荐系统架构 V0.2\n同一工程 / 双数据库 / 平台库只读 / 运维库自有" +direction: LR +formats: [png, svg] +theme: + font: "PingFang SC" + title_size: 25 + node_size: 11 + edge_size: 9.5 + splines: spline + nodesep: 0.62 + ranksep: 0.86 + dpi: 180 + pad: 0.55 + size: "21,11!" +clusters: + - name: "入口层(现有)" + style: { bg: "#F5F7FA", pen: "#AEB7C4", margin: 20 } + nodes: + - { id: users, label: "业务团队 / 模型团队\n管理员", icon: users } + - { id: nginx, label: "Nginx Gateway\n唯一入口", icon: nginx } + - { id: react, label: "React SPA\n模型平台 + 运维平台", icon: react } + - name: "统一 FastAPI 工程(现有服务内新增模块)" + style: { bg: "#EEF5FD", pen: "#7FA8D8", margin: 22 } + nodes: + - { id: model_api, label: "模型平台模块\n现有功能 + 模型源数据写入", icon: fastapi } + - { id: ops_api, label: "运维模块\n查询 / 判级 / 复核 / 报告 / 流程", icon: fastapi } + - name: "model_platform(现有库,不改表)" + style: { bg: "#F5F6F8", pen: "#AEB7C4", dashed: true, margin: 22 } + nodes: + - { id: platform_db, label: "MySQL · model_platform\n用户 / 角色 / 工作空间 / 调度", icon: mysql } + - { id: storage, label: "对象存储 + storage_objects\n文件本体与平台资源", icon: storage } + - name: "model_operations(新建独立库)" + style: { bg: "#F1F8F5", pen: "#78A88D", margin: 22 } + nodes: + - { id: source_tables, label: "7 张模型源表\n模型方受控直写", icon: mysql } + - { id: ops_tables, label: "15 张运维业务表\n判级 / 处理 / 报告 / 流程", icon: mysql } + - { id: ops_outbox, label: "ops_outbox_events\n运维库独立事件流", icon: flowdb } + - name: "异步执行(现有服务扩展连接)" + style: { bg: "#FAF6EE", pen: "#C6A96D", margin: 20 } + nodes: + - { id: scheduler, label: "Schedule Executor\n增加运维库 Outbox 轮询", icon: process } +edges: + - { from: users, to: nginx, label: "HTTPS", type: flow, width: 1.8 } + - { from: nginx, to: react, label: "静态资源", type: flow } + - { from: nginx, to: model_api, label: "模型平台 API", type: flow } + - { from: nginx, to: ops_api, label: "运维 API", type: flow, width: 1.8 } + - { from: model_api, to: platform_db, label: "现有平台读写", type: write } + - { from: ops_api, to: platform_db, label: "指定表 / 字段只读", type: dep, width: 1.8 } + - { from: ops_api, to: storage, label: "复用文件服务", type: dep } + - { from: model_api, to: source_tables, label: "受控 SQL 直写\nwriting → published", type: write, width: 2.2 } + - { from: ops_api, to: source_tables, label: "只读已发布修订", type: write, style: dashed } + - { from: ops_api, to: ops_tables, label: "业务读写", type: write, width: 2.2 } + - { from: ops_api, to: ops_outbox, label: "同事务写事件", type: async } + - { from: ops_outbox, to: scheduler, label: "轮询 / 重试", type: async, width: 1.8 } + diff --git a/docs/architecture/CHANGELOG.md b/docs/architecture/CHANGELOG.md new file mode 100644 index 0000000..f9a0bfb --- /dev/null +++ b/docs/architecture/CHANGELOG.md @@ -0,0 +1,26 @@ +# 架构设计变更记录 + +## **当前最新版本:V0.3** + +### V0.3 · 2026-09-02 + +- 将设计稿与当前工程实现对齐,明确首条链路实际读取 `model_deploy + model_operations`。 +- 补充已实现的模型列表、模型详情、单月监控结果和运维工作台接口说明。 +- 补充三角色权限来源、页面/操作权限校验以及 `dashboard:view` 与运维工作台权限的边界。 +- 标记真实非空数据联调、Workspace 映射和三角色实账号验收为本周剩余事项。 + +### V0.2 · 2026-08-31 + +- 解析甲方提供的 `model_platform` 18 张表 DDL,确认现有库只有身份、工作空间、文件、脚本版本和调度基础能力,没有运维业务表。 +- 架构由“同库分表”调整为“同一工程、双数据库”:平台库指定表/字段只读,新建独立 `model_operations`。 +- 运维库设计为 24 张表和 1 个当前监控结果视图;模型方受控直写 7 张源表,运维模块独占判级、复核、报告与流程数据。 +- 增加双数据库连接、跨库逻辑标识、独立 Outbox/Inbox 和 Schedule Executor 第二连接的边界说明。 +- 系统架构图与数据域关系图同步升级至 V0.2。 + +### V0.1 · 2026-08-31 + +- 明确采用同一工程、同一 MySQL,并由模型平台受控直写 7 张源表。 +- 将原始监控结果与运维判级/处理状态拆表,避免双方同时修改同一业务行。 +- 给出 22 张逻辑表、批次发布与修订协议、数据库账号权限和首条 API 纵向链路。 +- 明确不新增 RBAC、Redis或微服务,复用现有角色、对象存储、Outbox 与 Schedule Executor。 +- 新增系统架构图、数据域关系图及仓库内可复现的 YAML 渲染脚本。 diff --git a/docs/architecture/render_diagrams.py b/docs/architecture/render_diagrams.py new file mode 100644 index 0000000..13a853e --- /dev/null +++ b/docs/architecture/render_diagrams.py @@ -0,0 +1,148 @@ +"""Render the versioned A-card architecture YAML files to PNG and SVG. + +Setup once: + python3 -m pip install diagrams pyyaml + +Usage: + python3 docs/architecture/render_diagrams.py + python3 docs/architecture/render_diagrams.py path/to/one.yaml +""" + +from __future__ import annotations + +import argparse +import importlib +from pathlib import Path +from typing import Any + +import yaml +from diagrams import Cluster, Diagram, Edge + + +ICONS = { + "users": "diagrams.onprem.client:Users", + "nginx": "diagrams.onprem.network:Nginx", + "server": "diagrams.onprem.compute:Server", + "storage": "diagrams.generic.storage:Storage", + "process": "diagrams.programming.flowchart:PredefinedProcess", + "flowdb": "diagrams.programming.flowchart:Database", + "fastapi": "diagrams.programming.framework:Fastapi", + "react": "diagrams.programming.framework:React", + "mysql": "diagrams.onprem.database:MySQL", +} + +EDGE_TYPES = { + "flow": {"color": "#2f6fe0"}, + "write": {"color": "#3f9ad0"}, + "async": {"color": "#e0902f", "style": "dashed"}, + "obs": {"color": "#4f9d78", "style": "dashed"}, + "dep": {"color": "#8b6fc4", "style": "dashed"}, + "plain": {"color": "#9aa2ad"}, +} + + +def resolve_icon(name: str) -> Any: + module_path, class_name = ICONS[name].split(":") + return getattr(importlib.import_module(module_path), class_name) + + +def cluster_attr(style: dict[str, Any], font: str) -> dict[str, str]: + return { + "bgcolor": style.get("bg", "#F5F6F8"), + "pencolor": style.get("pen", "#B8C0CC"), + "style": "rounded,dashed" if style.get("dashed") else "rounded", + "fontname": font, + "fontsize": str(style.get("fontsize", 15)), + "margin": str(style.get("margin", 18)), + } + + +def build_nodes(definitions: list[dict], registry: dict[str, Any]) -> None: + for definition in definitions: + label = str(definition.get("label", definition["id"])).replace("\\n", "\n") + registry[definition["id"]] = resolve_icon(definition.get("icon", "server"))(label) + + +def build_clusters( + definitions: list[dict], registry: dict[str, Any], font: str +) -> None: + for definition in definitions: + with Cluster( + definition["name"], + graph_attr=cluster_attr(definition.get("style", {}), font), + ): + build_nodes(definition.get("nodes", []), registry) + build_clusters(definition.get("clusters", []), registry, font) + + +def draw_edges(definitions: list[dict], registry: dict[str, Any]) -> None: + for definition in definitions: + attributes = dict( + EDGE_TYPES.get(definition.get("type", "plain"), EDGE_TYPES["plain"]) + ) + for key, output_key in ( + ("label", "label"), + ("style", "style"), + ("width", "penwidth"), + ("color", "color"), + ("dir", "dir"), + ): + if key in definition: + value = definition[key] + attributes[output_key] = ( + str(value).replace("\\n", "\n") if key == "label" else str(value) + ) + if definition.get("free"): + attributes["constraint"] = "false" + registry[definition["from"]] >> Edge(**attributes) >> registry[definition["to"]] + + +def render(path: Path) -> None: + spec = yaml.safe_load(path.read_text(encoding="utf-8")) + theme = spec.get("theme", {}) + font = theme.get("font", "PingFang SC") + graph_attr = { + "fontname": font, + "fontsize": str(theme.get("title_size", 28)), + "bgcolor": "white", + "pad": str(theme.get("pad", 0.7)), + "splines": theme.get("splines", "ortho"), + "nodesep": str(theme.get("nodesep", 0.75)), + "ranksep": str(theme.get("ranksep", 1.0)), + "compound": "true", + "newrank": "true", + "label": str(spec.get("title", "")).replace("\\n", "\n"), + "labelloc": "t", + } + for optional in ("dpi", "size"): + if theme.get(optional): + graph_attr[optional] = str(theme[optional]) + + registry: dict[str, Any] = {} + with Diagram( + str(spec.get("title", path.stem)).replace("\\n", "\n"), + filename=str(path.with_suffix("")), + show=False, + direction=spec.get("direction", "TB"), + graph_attr=graph_attr, + node_attr={"fontname": font, "fontsize": str(theme.get("node_size", 12))}, + edge_attr={"fontname": font, "fontsize": str(theme.get("edge_size", 10))}, + outformat=spec.get("formats", ["png", "svg"]), + ): + build_nodes(spec.get("nodes", []), registry) + build_clusters(spec.get("clusters", []), registry, font) + draw_edges(spec.get("edges", []), registry) + print(f"rendered {path.name}: {len(registry)} nodes") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("paths", nargs="*", type=Path) + args = parser.parse_args() + paths = args.paths or sorted(Path(__file__).parent.glob("*.yaml")) + for path in paths: + render(path.resolve()) + + +if __name__ == "__main__": + main() diff --git a/docs/architecture/历史/A卡模型运维模块-后端架构与数据库设计-V0.1.md b/docs/architecture/历史/A卡模型运维模块-后端架构与数据库设计-V0.1.md new file mode 100644 index 0000000..7b2ab01 --- /dev/null +++ b/docs/architecture/历史/A卡模型运维模块-后端架构与数据库设计-V0.1.md @@ -0,0 +1,291 @@ +# A 卡模型运维模块后端架构与数据库设计 V0.1 + +> 状态:设计评审稿,尚未创建 ORM、迁移或接口实现 +> 日期:2026-08-31 +> 依据:当前 `develop` 工程结构、运维前端 V1.9 页面与现有三条 API 契约 + +## 1. 结论 + +采用“**同一工程、同一 MySQL、分表归属、发布后可见**”的方式: + +- 模型平台直接写入 7 张受控源表,不通过运维平台复制一份数据。 +- 运维模块只读取状态为 `published` 的监控批次,未发布或失败批次对页面不可见。 +- 判级、复核、报告、流程、文档、Prompt 和配置由运维模块独立维护,模型平台不得写入。 +- 已发布监控数据不原地覆盖;勘误时新增修订批次,保留历史版本和审计链。 +- 首期不新增微服务、Redis或消息中间件;复用现有 Nginx、React、FastAPI、MySQL、对象存储和 MySQL Outbox。 + +这能满足“模型方直接写业务表”的效率要求,同时避免模型任务误覆盖业务处理结果。 + +## 2. 模块边界 + +### 2.1 模型平台负责 + +- 银行、模型实例、模型版本及生命周期数据的写入。 +- 月度监控原始结果、特征指标和分箱分布数据的计算与写入。 +- 写入批次的完整性校验、计数、校验和及发布。 +- 已发布数据发生错误时,以新修订批次更正,不修改旧记录。 + +### 2.2 运维模块负责 + +- 按已发布原始结果和已发布规则版本计算异常等级、监控结果等级和命中原因。 +- 模型团队初审、业务团队终审、超时默认不处理及全程留痕。 +- 监控/诊断报告生成、编辑、发送、汇总和历史修订。 +- 七阶段开发评审流程、材料、知识库、规则、Prompt、报告模板和按银行配置。 +- 工作台、模型大类、细分银行、监控明细和详情等查询 API。 + +### 2.3 明确不做 + +- 运维模块不重复建设账号、角色、权限表;只复用登录模块返回的用户、角色和工作空间。 +- 模型平台不写判级、处理、报告、流程或配置表。 +- “手工同步”不再复制数据;其含义调整为“校验最新发布批次并重建派生判级”。 +- 报告汇总、知识库和工作台不单独建冗余汇总表,首期由业务表查询生成。 + +## 3. 总体架构 + +现有部署保持不变。运维能力作为 FastAPI 后端内的新 bounded context 接入: + +1. 浏览器继续经 Nginx 访问 React SPA 和同源 `/api/v1/*`。 +2. 模型平台模块或调度任务直接写 `ops_*` 源表。 +3. 批次发布后,运维服务计算并保存规则判级快照。 +4. 运维 API 联查源数据、判级和处理结果,返回前端 DTO。 +5. 报告生成、提醒等后台动作复用现有 MySQL Outbox 和 Schedule Executor。 +6. 报告文件、流程材料继续复用 `storage_objects` 与现有对象存储抽象。 + +配套图: + +- `A卡模型运维模块-系统架构-V0.1.png/.svg` +- `A卡模型运维模块-数据域关系-V0.1.png/.svg` + +修改 YAML 后运行 `python3 docs/architecture/render_diagrams.py` 可重新生成两种格式;脚本依赖 `diagrams`、`pyyaml` 和本机 Graphviz。 + +## 4. 数据设计约定 + +- 表名前缀统一为 `ops_`,与现有模型平台表隔离。 +- 主键统一使用 ULID `CHAR(26)`;日期时间统一 `DATETIME(3)`。 +- 月份在库内用 `DATE` 且固定为当月 1 日,API 序列化为 `YYYY-MM`。 +- KS、PSI、IV、CSI、坏账率和降幅在数据库中统一存 **0~1 比率**,使用 `DECIMAL(12,8)`;API 转换为 0~100 的百分数。 +- 沿用当前仓库约定:不创建物理外键,使用 `fk_*` 逻辑索引并在服务层校验完整性。 +- 可修改业务状态表使用 `state_version INTEGER` 做乐观锁。 +- 需要删除的配置或流程数据采用 `is_deleted`、`deleted_at` 软删除;监控历史不允许删除。 +- 所有根业务表包含 `workspace_id`,避免未来多个项目空间数据串用。 + +## 5. 表清单与归属 + +### 5.1 模型平台可写源表(7 张) + +| 表 | 用途 | 核心唯一性 | +|---|---|---| +| `ops_banks` | 银行主数据及无极银行标记 | `(workspace_id, bank_code)` | +| `ops_model_instances` | 银行下的模型实例、通用模型关系、当前版本 | `(workspace_id, model_id)` | +| `ops_model_versions` | 版本、开发指标和生命周期日期 | `(model_instance_id, version_label)` | +| `ops_monitor_batches` | 月度写入批次、修订号、发布状态和校验和 | `(workspace_id, source_system, monitor_month, revision_no)` | +| `ops_monitor_results` | 模型单月原始排序性、KS、PSI和样本量 | `(batch_id, model_instance_id)` | +| `ops_monitor_feature_metrics` | 单月特征级 IV、CSI及贡献变化 | `(monitor_result_id, feature_code)` | +| `ops_monitor_distributions` | 评分分箱或特征分箱的基准期/当期分布 | `(monitor_result_id, dimension_type, feature_code, bin_order)` | + +`ops_model_categories` 为运维模块维护的固定字典,模型平台只有读取权限。首期种子为 `std / bai / big / afd` 四类。 + +### 5.2 运维模块独占表(15 张) + +| 数据域 | 表 | +|---|---| +| 字典 | `ops_model_categories` | +| 判级与处理 | `ops_rule_versions`、`ops_rule_items`、`ops_monitor_evaluations`、`ops_monitor_reviews` | +| 报告治理 | `ops_reports`、`ops_report_revisions`、`ops_report_template_versions`、`ops_prompt_versions`、`ops_prompt_regression_runs`、`ops_bank_report_configs` | +| 流程与材料 | `ops_workflows`、`ops_workflow_stages`、`ops_documents` | +| 使用统计 | `ops_usage_events` | + +不新增通知任务表;通知和后台报告任务复用现有 `outbox_events`。若后续需要展示逐次送达结果,再增通知投递日志表。 + +## 6. 核心表字段初稿 + +### 6.1 模型与版本 + +`ops_model_instances` + +- `model_instance_id`、`workspace_id`、`bank_id`、`category_id` +- `model_id`、`model_name`、`model_status` +- `current_version_id` +- `is_common_model`、`common_source_model_id`、`common_model_name` +- `source_updated_at`、`created_at`、`updated_at` + +`ops_model_versions` + +- `model_version_id`、`model_instance_id`、`version_label`、`version_status` +- `developer_user_ref`、`developer_display_name` +- `development_date`、`iteration_start_date`、`last_iteration_date`、`iteration_reason` +- `escort_start_date`、`escort_end_date`、`online_date`、`offline_date` +- `development_ks`、`development_psi`、`max_lift` +- `scoring_logic_storage_object_id`、`source_updated_at` + +模型实例只保存当前版本指针,所有生命周期和开发指标按版本留存。 + +### 6.2 监控批次和原始结果 + +`ops_monitor_batches` + +- `batch_id`、`workspace_id`、`source_system`、`source_batch_no` +- `monitor_month`、`revision_no` +- `batch_status`:`writing / published / failed / superseded` +- `expected_model_count`、`written_model_count`、`feature_row_count`、`distribution_row_count` +- `checksum_sha256`、`generated_at`、`published_at`、`failed_reason` + +`ops_monitor_results` + +- `monitor_result_id`、`batch_id`、`model_instance_id`、`model_version_id`、`monitor_month` +- `ranking_result`:`matched / unmatched / not_applicable` +- `ks_value`、`psi_value` +- `sample_count`、`good_count`、`bad_count` +- `source_result_json`:仅保存尚未结构化且确有追溯价值的源字段 +- `calculated_at`、`created_at` + +源表不保存 A/B/C、异常等级、处理建议或报告状态,避免两个模块同时改一行。 + +### 6.3 判级快照 + +`ops_monitor_evaluations` + +- `evaluation_id`、`monitor_result_id`、`rule_version_id`、`rule_item_id` +- `ks_mom_drop_rate`、`secondary_level2_hits_6m` +- `abnormal_level`:`normal / level1 / level2 / level3` +- `monitor_grade`:`A / B / C` +- `reason_code`、`reason_text_snapshot`、`action_snapshot` +- `is_current`、`evaluated_at` + +每次规则发布或源数据修订都新增判级记录;旧快照不覆盖。页面默认读取 `is_current=1` 的记录,历史报告仍绑定生成时的判级快照。 + +### 6.4 两段处理 + +`ops_monitor_reviews` + +- `review_id`、`monitor_result_id`、`evaluation_id` +- `review_stage`:`model_initial / business_final` +- `review_status`:`pending / handled / auto_closed` +- `decision`:`no_action / tune_or_rebuild` +- `handling_note`(手工处理必填) +- `due_at`、`handled_by`、`handled_at`、`auto_closed_at` +- `state_version`、`created_at`、`updated_at` + +唯一约束为 `(monitor_result_id, review_stage)`。模型团队阶段完成后才创建或激活业务团队阶段;超时任务通过 Outbox 执行默认“不处理”。 + +### 6.5 报告 + +`ops_reports` 保存报告主状态与绑定快照,`ops_report_revisions` 保存每次正文修改: + +- 报告绑定 `monitor_result_id + evaluation_id + template_version_id + prompt_version_id`。 +- `ops_report_revisions` 使用 `(report_id, revision_no)` 唯一约束,正文和结构化快照均不可变。 +- 监控报告页默认只查询最新监控月份;报告汇总页查询全部历史。数据库不删除旧月份报告。 +- PDF/Excel 等文件通过 `storage_object_id` 指向现有 `storage_objects`。 + +### 6.6 流程、材料与知识库 + +- `ops_workflows`:银行、模型大类、模型实例、独立开发/复用通用模型、当前阶段、状态和计划日期。 +- `ops_workflow_stages`:1~7 阶段实例、负责人角色、开始/截止/完成时间、确认状态和停滞天数。 +- `ops_documents`:同时支持 `storage_object_id` 和 `external_url`,可关联流程、阶段、模型和版本。 +- 知识库不是新表,而是对 `ops_documents` 按银行、模型、版本、环节和通用模型标记查询。 + +## 7. 发布协议 + +模型平台每次写入必须执行以下协议: + +1. 创建 `ops_monitor_batches`,状态为 `writing`,携带幂等的 `source_batch_no`。 +2. 幂等写入银行、模型实例和版本;插入本批次结果、特征指标和分布。 +3. 校验模型数、明细行数、必填字段、指标范围和 SHA-256 校验和。 +4. 在一个短事务内将批次改为 `published`;同月份上一修订改为 `superseded`。 +5. 写入 `monitor_batch.published` Outbox 事件,触发运维模块生成判级快照。 +6. 运维查询只允许关联 `published` 批次,并按最高修订号取当前结果。 + +失败批次保留为 `failed` 供排查。任何已发布结果、特征和分布均不得执行 `UPDATE` 或物理删除。 + +## 8. 数据库权限 + +建议生产环境至少分三类账号: + +| 账号 | 权限 | +|---|---| +| `schema_migrator` | 仅部署时使用,可对全部 `ops_*` 执行 DDL | +| `model_writer` | 读取字典;对 7 张源表执行限定的 `SELECT/INSERT/UPDATE`,无 `DELETE`,不能写运维独占表 | +| `operations_app` | 读取全部源表;读写运维独占表;不能修改已发布源结果 | + +若模型任务与运维 API 暂时运行在同一 FastAPI 进程,代码层仍按两个 repository 隔离;生产批处理脚本优先使用独立 `MODEL_WRITER_DATABASE_URL`,将权限边界落实到数据库。 + +## 9. API 设计 + +### 9.1 首条真实纵向链路 + +保持前端已写好的路径,不重新发明契约: + +| 接口 | 主要数据来源 | +|---|---| +| `GET /api/v1/operations/models` | 模型实例 + 当前版本 + 最新已发布结果 + 当前判级 + 最近处理 | +| `GET /api/v1/operations/models/{model_id}` | 单模型完整元数据和最新状态 | +| `GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM` | 指定月最新已发布修订 + 当前判级 | + +所有接口返回现有 `{ data, meta? }` 包装。切换 `VITE_OPERATIONS_API_MODE=api` 前,前端 `operationsApi.ts` 需要统一附加当前 `workspace_id`,用于选择并校验数据空间。 + +### 9.2 后续资源接口 + +- 查询:`/model-categories/overview`、`/banks/overview`、`/monitor-results`、`/monitor-results/{id}/features`、`/monitor-results/{id}/distributions` +- 处理:`/monitor-results/{id}/reviews/model-initial`、`/reviews/business-final` +- 报告:`/reports`、`/reports/{id}`、`/reports/{id}/revisions`、`/reports/{id}/send` +- 流程:`/workflows`、`/workflows/{id}`、`/workflows/{id}/stages/{stage}`、`/documents` +- 治理:`/rule-versions`、`/prompt-versions`、`/report-template-versions`、`/bank-report-configs` + +列表统一支持分页、排序和与 V1.9 筛选框一致的查询参数;Excel/PDF 导出走独立端点,不让普通列表接口返回超大数据集。 + +## 10. 登录与角色 + +- 不增加运维 RBAC 表,也不接管现有权限管理模块。 +- API 复用登录 Cookie、`Users`、`Roles` 和当前工作空间,不创建新的角色或权限数据。 +- 新增轻量 `operations_context`:校验用户已登录且可访问所选工作空间;业务角色取与 `/api/v1/auth/me`、前端 `AuthUser.role_code` 相同的登录角色来源,避免前后端角色口径不一致。 +- 角色映射约定:`admin` → 管理员;`developer/model_team` → 模型团队;`business/business_team/biz` → 业务团队。 +- 前端按角色决定页面呈现,后端仍必须按角色校验写操作,不能把前端隐藏按钮当安全边界。 +- 角色代码最终值由登录/权限模块给出,运维模块只维护一处映射常量。 + +## 11. 代码落位建议 + +沿用现有 router / schema / service 分层,每个文件不超过 500 行: + +```text +common/src/common/db/models/operations/ + reference.py models.py monitoring.py governance.py + reviews.py reports.py workflows.py usage.py +backend/src/backend/api/operations/ + __init__.py _deps.py models.py monitoring.py reviews.py + reports.py workflows.py governance.py +backend/src/backend/services/operations/ + queries.py evaluation.py publishing.py reports.py workflows.py +backend/src/backend/schemas/operations/ + common.py models.py monitoring.py reports.py workflows.py governance.py +``` + +`backend.main` 只注册一个 `operations_router`;业务规则不写进路由文件。 + +## 12. 实施顺序 + +### P0:首条真实链路 + +1. 建模型/版本/批次/结果/特征/分布及规则判级 11 张核心表。 +2. 导入一组 2026-07 样例数据并走完整 `writing → published` 协议。 +3. 实现三条既有 API,前端追加 `workspace_id` 后切到 API 模式。 +4. 验证列表、模型详情、单月监控详情数值一致。 + +### P1:业务闭环 + +实现两段复核、报告及修订、七阶段流程、材料和知识库。 + +### P2:治理与运营 + +实现规则版本、Prompt 回归、模板版本、使用统计、提醒和导出优化。 + +## 13. 开发前需共同确认的 5 项 + +以下均给出推荐默认值;未得到反对意见时可按推荐值实施: + +1. **模型 ID 唯一性**:推荐 `model_id` 在工作空间内全局唯一,不只在银行内唯一。 +2. **指标单位**:推荐数据库存 0~1 比率,API 输出百分数;禁止两种单位混写。 +3. **批次粒度**:推荐一个来源系统每月一个全量批次,勘误整体升修订号。 +4. **工作空间范围**:推荐全部根表带 `workspace_id`,即使首期只有一个 A 卡项目空间。 +5. **模型开发材料**:推荐运维表同时支持对象存储文件和模型平台链接,避免重复上传。 + +这 5 项确认后即可开始 ORM、Alembic 迁移和首条真实接口实现。 diff --git a/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.png b/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.png new file mode 100644 index 0000000..dca3d48 Binary files /dev/null and b/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.png differ diff --git a/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.svg b/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.svg new file mode 100644 index 0000000..5c0e935 --- /dev/null +++ b/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.svg @@ -0,0 +1,249 @@ + + + + + + +A 卡模型运维模块 · 数据域关系 V0.1 +源数据与运维状态分离,历史结果不可覆盖 + +A 卡模型运维模块 · 数据域关系 V0.1 +源数据与运维状态分离,历史结果不可覆盖 + +cluster_模型平台直写源数据(7 张) + +模型平台直写源数据(7 张) + + +cluster_运维判级与处理 + +运维判级与处理 + + +cluster_报告、流程与运营 + +报告、流程与运营 + + + +0bc523623a544090a0d886de1f78a625 + +ops_banks +银行 + + + +0e290213c8ea44bcb0ee9ee437a7c80b + +ops_model_instances +模型实例 + + + +0bc523623a544090a0d886de1f78a625->0e290213c8ea44bcb0ee9ee437a7c80b + + +1:N + + + +6cdbdf2ce8de45c694548663936ffb4e + +ops_model_versions +模型版本 + + + +0e290213c8ea44bcb0ee9ee437a7c80b->6cdbdf2ce8de45c694548663936ffb4e + + +1:N + + + +5297915299eb45eea32a0915742e54cd + +ops_workflows + stages +七阶段流程 + + + +0e290213c8ea44bcb0ee9ee437a7c80b->5297915299eb45eea32a0915742e54cd + + +开发 / 复用通用模型 + + + +87e82968badc47e9aa3f20c61d45d927 + +ops_documents +材料 / 知识库索引 + + + +0e290213c8ea44bcb0ee9ee437a7c80b->87e82968badc47e9aa3f20c61d45d927 + + +按模型 / 版本检索 + + + +941939a7ca7c4dd68aa48a7d95fba6ea + +ops_monitor_results +单月原始指标 + + + +6cdbdf2ce8de45c694548663936ffb4e->941939a7ca7c4dd68aa48a7d95fba6ea + + +版本快照 + + + +b4378b7b44d1441c990e5629da97e3f8 + +ops_monitor_batches +发布批次 / 修订 + + + +b4378b7b44d1441c990e5629da97e3f8->941939a7ca7c4dd68aa48a7d95fba6ea + + +published 后可见 + + + +6d81dd3447fe491e8c37a88de15bba21 + +ops_monitor_feature_metrics +IV / CSI / 贡献 + + + +941939a7ca7c4dd68aa48a7d95fba6ea->6d81dd3447fe491e8c37a88de15bba21 + + +1:N + + + +8cf5f1d27bd44ef6a43acba265f9c5f4 + +ops_monitor_distributions +评分 / 特征分箱 + + + +941939a7ca7c4dd68aa48a7d95fba6ea->8cf5f1d27bd44ef6a43acba265f9c5f4 + + +1:N + + + +1a651d34b0c045b88085bf40cb9c52e1 + +ops_monitor_evaluations +判级快照 + + + +941939a7ca7c4dd68aa48a7d95fba6ea->1a651d34b0c045b88085bf40cb9c52e1 + + +原始指标 → 判级 + + + +13501c09d5a347388b6bf1551ddd64dd + +ops_model_categories +固定大类字典 + + + +13501c09d5a347388b6bf1551ddd64dd->0e290213c8ea44bcb0ee9ee437a7c80b + + +1:N + + + +6253de4668fb4387968b2c3037eb1130 + +ops_rule_versions + items +规则版本与矩阵 + + + +6253de4668fb4387968b2c3037eb1130->1a651d34b0c045b88085bf40cb9c52e1 + + +按已发布规则计算 + + + +79d9b23ca5254f4cacb53546dfb6824e + +ops_monitor_reviews +模型初审 → 业务终审 + + + +1a651d34b0c045b88085bf40cb9c52e1->79d9b23ca5254f4cacb53546dfb6824e + + +触发两段处理 + + + +34aca64f2cd942a883883ca8ca28adce + +ops_reports + revisions +报告与不可变修订 + + + +1a651d34b0c045b88085bf40cb9c52e1->34aca64f2cd942a883883ca8ca28adce + + +绑定判级快照 + + + +80b8a0a57f5041f0b9226cdc133358f6 + +模板 / Prompt / 回归 +按银行报告配置 + + + +80b8a0a57f5041f0b9226cdc133358f6->34aca64f2cd942a883883ca8ca28adce + + +模板 + Prompt + + + +5297915299eb45eea32a0915742e54cd->87e82968badc47e9aa3f20c61d45d927 + + +阶段材料 + + + +dd6af7440411402f8066d90862eac449 + +ops_usage_events +登录 / 需求 / 报告行为 + + + diff --git a/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.yaml b/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.yaml new file mode 100644 index 0000000..3b036eb --- /dev/null +++ b/docs/architecture/历史/A卡模型运维模块-数据域关系-V0.1.yaml @@ -0,0 +1,56 @@ +title: "A 卡模型运维模块 · 数据域关系 V0.1\n源数据与运维状态分离,历史结果不可覆盖" +direction: LR +formats: [png, svg] +theme: + font: "PingFang SC" + title_size: 24 + node_size: 10.5 + edge_size: 8.8 + splines: spline + nodesep: 0.48 + ranksep: 0.8 + dpi: 180 + pad: 0.5 + size: "20,12!" +clusters: + - name: "模型平台直写源数据(7 张)" + style: { bg: "#EEF5FD", pen: "#6F9ED4", margin: 18 } + nodes: + - { id: banks, label: "ops_banks\n银行", icon: flowdb } + - { id: models, label: "ops_model_instances\n模型实例", icon: flowdb } + - { id: versions, label: "ops_model_versions\n模型版本", icon: flowdb } + - { id: batches, label: "ops_monitor_batches\n发布批次 / 修订", icon: flowdb } + - { id: results, label: "ops_monitor_results\n单月原始指标", icon: flowdb } + - { id: features, label: "ops_monitor_feature_metrics\nIV / CSI / 贡献", icon: flowdb } + - { id: distributions, label: "ops_monitor_distributions\n评分 / 特征分箱", icon: flowdb } + - name: "运维判级与处理" + style: { bg: "#F1F8F5", pen: "#74A087", margin: 18 } + nodes: + - { id: categories, label: "ops_model_categories\n固定大类字典", icon: flowdb } + - { id: rules, label: "ops_rule_versions + items\n规则版本与矩阵", icon: flowdb } + - { id: evaluations, label: "ops_monitor_evaluations\n判级快照", icon: flowdb } + - { id: reviews, label: "ops_monitor_reviews\n模型初审 → 业务终审", icon: flowdb } + - name: "报告、流程与运营" + style: { bg: "#FAF6EE", pen: "#C5A467", margin: 18 } + nodes: + - { id: reports, label: "ops_reports + revisions\n报告与不可变修订", icon: flowdb } + - { id: governance, label: "模板 / Prompt / 回归\n按银行报告配置", icon: flowdb } + - { id: workflows, label: "ops_workflows + stages\n七阶段流程", icon: flowdb } + - { id: documents, label: "ops_documents\n材料 / 知识库索引", icon: flowdb } + - { id: usage, label: "ops_usage_events\n登录 / 需求 / 报告行为", icon: flowdb } +edges: + - { from: banks, to: models, label: "1:N", type: plain } + - { from: categories, to: models, label: "1:N", type: plain } + - { from: models, to: versions, label: "1:N", type: plain } + - { from: batches, to: results, label: "published 后可见", type: write, width: 2.0 } + - { from: versions, to: results, label: "版本快照", type: plain } + - { from: results, to: features, label: "1:N", type: plain } + - { from: results, to: distributions, label: "1:N", type: plain } + - { from: rules, to: evaluations, label: "按已发布规则计算", type: dep, width: 1.8 } + - { from: results, to: evaluations, label: "原始指标 → 判级", type: write, width: 2.0 } + - { from: evaluations, to: reviews, label: "触发两段处理", type: flow } + - { from: evaluations, to: reports, label: "绑定判级快照", type: flow } + - { from: governance, to: reports, label: "模板 + Prompt", type: dep } + - { from: models, to: workflows, label: "开发 / 复用通用模型", type: flow } + - { from: workflows, to: documents, label: "阶段材料", type: write } + - { from: models, to: documents, label: "按模型 / 版本检索", type: plain, free: true } diff --git a/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.png b/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.png new file mode 100644 index 0000000..2383cab Binary files /dev/null and b/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.png differ diff --git a/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.svg b/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.svg new file mode 100644 index 0000000..d40c34f --- /dev/null +++ b/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.svg @@ -0,0 +1,206 @@ + + + + + + +A 卡模型运维模块 · 推荐系统架构 V0.1 +同一工程 / 同一 MySQL / 源表发布后可见 + +A 卡模型运维模块 · 推荐系统架构 V0.1 +同一工程 / 同一 MySQL / 源表发布后可见 + +cluster_入口层(现有) + +入口层(现有) + + +cluster_统一 FastAPI 工程(不新增服务) + +统一 FastAPI 工程(不新增服务) + + +cluster_共享数据层(现有 MySQL) + +共享数据层(现有 MySQL) + + +cluster_文件层(现有) + +文件层(现有) + + + +8e923065d05147b68fd08369fc363143 + +业务团队 / 模型团队 +管理员 + + + +7302e8c0cbac47f8b0461d1c3238c1b8 + +Nginx Gateway +唯一入口 + + + +8e923065d05147b68fd08369fc363143->7302e8c0cbac47f8b0461d1c3238c1b8 + + +HTTPS + + + +4e2614e624784972b26a9cbc506918fd + +React SPA +模型平台 + 运维平台 + + + +7302e8c0cbac47f8b0461d1c3238c1b8->4e2614e624784972b26a9cbc506918fd + + +静态资源 + + + +62f35beca1644317a41769c903fdcc3d + +模型平台模块 +模型 / 版本 / 计算任务 + + + +7302e8c0cbac47f8b0461d1c3238c1b8->62f35beca1644317a41769c903fdcc3d + + +模型平台 API + + + +3cf7ac83f6764e16bfd1e5ea55c240e2 + +运维模块 +查询 / 判级 / 复核 / 报告 / 流程 + + + +7302e8c0cbac47f8b0461d1c3238c1b8->3cf7ac83f6764e16bfd1e5ea55c240e2 + + +运维 API + + + +d97ed9af3adb480abbe2376e20067521 + +Jupyter Runtime +模型开发运行时 + + + +62f35beca1644317a41769c903fdcc3d->d97ed9af3adb480abbe2376e20067521 + + +运行时控制 + + + +18c413154ae24c099f7a314c5bda70a5 + +7 张模型源表 +模型方受控直写 + + + +62f35beca1644317a41769c903fdcc3d->18c413154ae24c099f7a314c5bda70a5 + + +受控 SQL 直写 +writing → published + + + +3cf7ac83f6764e16bfd1e5ea55c240e2->18c413154ae24c099f7a314c5bda70a5 + + +只读已发布修订 + + + +7a2b21350c4c414098e07d740ff99dad + +15 张运维业务表 +判级 / 处理 / 报告 / 流程 + + + +3cf7ac83f6764e16bfd1e5ea55c240e2->7a2b21350c4c414098e07d740ff99dad + + +业务读写 + + + +b33cd1fa7d3641028972a6872041f8b5 + +outbox_events +现有异步可靠投递 + + + +3cf7ac83f6764e16bfd1e5ea55c240e2->b33cd1fa7d3641028972a6872041f8b5 + + +同事务写事件 + + + +e2ab9cb488234c80803bcdfa37522afa + +对象存储 + storage_objects +报告 / 流程材料 / 评分逻辑 + + + +3cf7ac83f6764e16bfd1e5ea55c240e2->e2ab9cb488234c80803bcdfa37522afa + + +材料与报告文件 + + + +60e5076279b545fe957a6ff711d7e045 + +Schedule Executor +Outbox 轮询与后台任务 + + + +60e5076279b545fe957a6ff711d7e045->18c413154ae24c099f7a314c5bda70a5 + + +批处理结果直写 + + + +60e5076279b545fe957a6ff711d7e045->7a2b21350c4c414098e07d740ff99dad + + +判级 / 报告 / 超时任务 + + + +b33cd1fa7d3641028972a6872041f8b5->60e5076279b545fe957a6ff711d7e045 + + +轮询 / 重试 + + + diff --git a/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.yaml b/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.yaml new file mode 100644 index 0000000..c8f344c --- /dev/null +++ b/docs/architecture/历史/A卡模型运维模块-系统架构-V0.1.yaml @@ -0,0 +1,52 @@ +title: "A 卡模型运维模块 · 推荐系统架构 V0.1\n同一工程 / 同一 MySQL / 源表发布后可见" +direction: TB +formats: [png, svg] +theme: + font: "PingFang SC" + title_size: 25 + node_size: 11 + edge_size: 9.5 + splines: spline + nodesep: 0.65 + ranksep: 0.85 + dpi: 180 + pad: 0.55 + size: "18,12!" +clusters: + - name: "入口层(现有)" + style: { bg: "#F5F7FA", pen: "#AEB7C4", margin: 20 } + nodes: + - { id: users, label: "业务团队 / 模型团队\n管理员", icon: users } + - { id: nginx, label: "Nginx Gateway\n唯一入口", icon: nginx } + - { id: react, label: "React SPA\n模型平台 + 运维平台", icon: react } + - name: "统一 FastAPI 工程(不新增服务)" + style: { bg: "#EEF5FD", pen: "#7FA8D8", margin: 22 } + nodes: + - { id: model_api, label: "模型平台模块\n模型 / 版本 / 计算任务", icon: fastapi } + - { id: ops_api, label: "运维模块\n查询 / 判级 / 复核 / 报告 / 流程", icon: fastapi } + - { id: runtime, label: "Jupyter Runtime\n模型开发运行时", icon: server } + - { id: scheduler, label: "Schedule Executor\nOutbox 轮询与后台任务", icon: process } + - name: "共享数据层(现有 MySQL)" + style: { bg: "#F1F8F5", pen: "#78A88D", margin: 22 } + nodes: + - { id: source_tables, label: "7 张模型源表\n模型方受控直写", icon: mysql } + - { id: ops_tables, label: "15 张运维业务表\n判级 / 处理 / 报告 / 流程", icon: mysql } + - { id: outbox, label: "outbox_events\n现有异步可靠投递", icon: flowdb } + - name: "文件层(现有)" + style: { bg: "#FAF6EE", pen: "#C6A96D", margin: 20 } + nodes: + - { id: storage, label: "对象存储 + storage_objects\n报告 / 流程材料 / 评分逻辑", icon: storage } +edges: + - { from: users, to: nginx, label: "HTTPS", type: flow, width: 1.8 } + - { from: nginx, to: react, label: "静态资源", type: flow } + - { from: nginx, to: model_api, label: "模型平台 API", type: flow } + - { from: nginx, to: ops_api, label: "运维 API", type: flow, width: 1.8 } + - { from: model_api, to: runtime, label: "运行时控制", type: dep } + - { from: model_api, to: source_tables, label: "受控 SQL 直写\nwriting → published", type: write, width: 2.2 } + - { from: scheduler, to: source_tables, label: "批处理结果直写", type: write } + - { from: ops_api, to: source_tables, label: "只读已发布修订", type: write, style: dashed, width: 1.8 } + - { from: ops_api, to: ops_tables, label: "业务读写", type: write, width: 2.2 } + - { from: ops_api, to: outbox, label: "同事务写事件", type: async } + - { from: outbox, to: scheduler, label: "轮询 / 重试", type: async, width: 1.8 } + - { from: scheduler, to: ops_tables, label: "判级 / 报告 / 超时任务", type: async } + - { from: ops_api, to: storage, label: "材料与报告文件", type: write } diff --git a/docs/database/A卡原始监控指标-模型方表评估清单-V0.1.md b/docs/database/A卡原始监控指标-模型方表评估清单-V0.1.md new file mode 100644 index 0000000..af67277 --- /dev/null +++ b/docs/database/A卡原始监控指标-模型方表评估清单-V0.1.md @@ -0,0 +1,154 @@ +# A 卡原始监控指标——模型方表评估清单 V0.1 + +> 日期:2026-09-01 +> 评估对象:模型方 `model_deploy` 数据库及待补监控结构。 +> 目的:确认模型方能够提供哪些原始监控数据、通过哪些表提供,以及我方是否需要保留标准化月度快照。 + +## 1. 结论先行 + +模型方现有 `model_monitor_aggr` 只能部分承载 KS、PSI、IV 等标量指标,不能完整支撑 A 卡运维平台的月度监控、规则回放、报告和特征分布展示。 + +建议模型方提供 4 类结构化监控表或完全等价的数据结构: + +1. `model_monitor_batch`:月度批次、修订和发布状态。 +2. `model_monitor_result`:模型单月排序性、KS、PSI和样本量。 +3. `model_monitor_feature_metric`:特征级IV、CSI及变化。 +4. `model_monitor_distribution`:评分分箱和特征分箱分布。 + +以上数据应由模型计算任务自动写入,不由模型团队手工执行 SQL。 + +## 2. 模型方现有相关表 + +| 现有表 | 当前用途 | 可复用内容 | 不足 | 我方建议 | +|---|---|---|---|---| +| `model_deploy` | 模型部署主表 | `deployId`、模型名称、编码、状态、当前版本 | 缺业务模型大类、银行关系、通用模型等 | 作为模型主标识使用,不复制主表 | +| `model_version` | 模型版本 | 版本ID、版本号、版本名称、创建人、创建时间 | 缺迭代、陪跑、上线/下线和开发指标 | 作为版本主标识;缺失字段另补 | +| `model_details` | 模型详情 | 模型说明、输入/输出Schema | 不是月度监控数据 | 仅模型详情页读取 | +| `model_monitor_aggr` | 通用聚合指标 | `deployId/version/metricName/dimensionValue/valueNum/gmtCreated`;注释允许 `ks/psi/iv` | 无月份、批次、修订、发布状态、排序性、样本量、CSI、分箱结构 | 可继续保存运行聚合;不建议单独承担业务监控全量数据 | +| `model_monitor_call_log` | 模型调用原始日志 | 调用成功、耗时、节点、错误 | 属于服务运行监控,不是模型效果监控 | 不作为 A 卡月度监控来源 | +| `model_monitor_aggr_cursor` | 聚合器游标 | 聚合进度 | 纯技术状态 | 我方无需读取 | + +## 3. 建议模型方提供的 4 张表 + +### 3.1 `model_monitor_batch`——监控批次 + +一行代表一次月度监控数据发布。 + +建议字段: + +- `id BIGINT`:批次主键。 +- `source_batch_no VARCHAR(128)`:模型方幂等批次号。 +- `monitor_month DATE`:监控月份,固定当月1日。 +- `revision_no INT`:同月份修订号。 +- `status VARCHAR(24)`:`writing/published/failed/superseded`。 +- `expected_model_count/written_model_count`:预期和实际模型数。 +- `feature_row_count/distribution_row_count`:特征和分箱行数。 +- `checksum VARCHAR(64)`:批次校验和。 +- `generated_at/published_at DATETIME`:计算和发布时间。 +- `failed_reason VARCHAR(2000)`:失败原因。 + +关键约束:同一监控月份和修订号唯一;我方只读取 `published` 的最高修订。 + +### 3.2 `model_monitor_result`——模型单月监控结果 + +一行代表一个模型版本在一个月份的核心监控结果。 + +建议字段: + +- `id BIGINT`:结果主键。 +- `batch_id BIGINT`:所属批次。 +- `deploy_id BIGINT`:关联 `model_deploy.id`。 +- `version_id BIGINT`:关联 `model_version.id`。 +- `monitor_month DATE`:监控月份。 +- `ranking_result VARCHAR(24)`:`matched/unmatched/not_applicable`。 +- `ks_value DECIMAL(12,8)`:KS,建议统一存0~1比率。 +- `psi_value DECIMAL(12,8)`:PSI,建议统一存0~1比率。 +- `sample_count/good_count/bad_count BIGINT`:样本量。 +- `calculated_at DATETIME`:完成计算时间。 +- `extra JSON`:仅用于尚未结构化且已约定的数据,不作为核心字段替代品。 + +关键约束:同一批次下一个 `deploy_id` 只能有一条结果。 + +### 3.3 `model_monitor_feature_metric`——特征级指标 + +一行代表一个监控结果下的一个特征。 + +建议字段: + +- `id BIGINT`:主键。 +- `monitor_result_id BIGINT`:关联单月监控结果。 +- `feature_code/feature_name`:特征编码和名称。 +- `iv_value/previous_iv_value`:当期与上期IV。 +- `iv_drop_rate`:IV降幅。 +- `csi_value/previous_csi_value`:当期与上期CSI。 +- `csi_rise_rate`:CSI升幅。 +- `ks_contribution_change`:对KS变化的贡献。 +- `psi_contribution_change`:对PSI变化的贡献。 +- `calculated_at DATETIME`:计算时间。 + +关键约束:同一监控结果下 `feature_code` 唯一。 + +### 3.4 `model_monitor_distribution`——评分及特征分箱 + +一行代表一个评分区间或一个特征分箱。 + +建议字段: + +- `id BIGINT`:主键。 +- `monitor_result_id BIGINT`:关联单月监控结果。 +- `dimension_type VARCHAR(24)`:`score_band/feature_bin`。 +- `feature_code/feature_name`:评分分箱时可为空。 +- `bin_order/bin_code/bin_label`:分箱顺序、编码和显示名称。 +- `reference_period_label`:基准期说明。 +- `reference_count/reference_share`:基准期数量和占比。 +- `current_count/current_share`:当期数量和占比。 +- `good_count/bad_count/bad_rate`:好坏客户数据。 +- `psi_component/csi_component`:该分箱对PSI/CSI的贡献。 + +关键约束:`monitor_result_id + dimension_type + feature_code + bin_order` 唯一。 + +## 4. 四张表与前端页面关系 + +| 表 | 模型大类概览 | 监控概览 | 监控详情 | 监控/诊断报告 | 系统同步 | +|---|---|---|---|---|---| +| `model_monitor_batch` | 间接 | 间接 | 间接 | 绑定数据版本 | 展示批次和修订状态 | +| `model_monitor_result` | KS/PSI、趋势、同业均值 | 月度明细和等级输入 | 核心结论与趋势 | 报告核心指标 | 同步主数据 | +| `model_monitor_feature_metric` | 不直接使用 | 可选摘要 | IV/CSI前5及全指标 | 诊断报告特征分析 | 同步特征指标 | +| `model_monitor_distribution` | 不直接使用 | 不直接使用 | 排序性和特征分布 | 报告四张明细表 | 同步分箱数据 | + +## 5. 我方可以计算、模型方无需提供的内容 + +取得上述原始数据后,我方可自行计算: + +- 本行平均KS/PSI及同业平均值。 +- 近6个月趋势。 +- KS环比降幅。 +- 近6个月二级异常次数。 +- 异常等级及A/B/C监控结果等级。 +- 命中原因、处理建议、阈值影响测算。 +- 报告正文、待办、催办和缓存数据。 + +## 6. 必须与模型方确认的 10 项 + +1. 是否接受新增上述4张表,或提供完全等价的表/视图。 +2. `deploy_id`和版本引用使用 `model_version.id` 还是 `version`整数。 +3. KS、PSI、IV、CSI统一存0~1还是0~100。 +4. 排序性计算方法及枚举值。 +5. PSI和CSI基准期规则,是否为滚动基准期。 +6. 批次粒度是全月全量、按银行还是按模型。 +7. 历史勘误是否新增修订,禁止原地覆盖已发布结果。 +8. 特征编码是否跨版本稳定,特征名称变更如何留痕。 +9. 首次需要回补多少个月历史数据及预计数据量。 +10. 提供一套真实脱敏样例:一个模型、一个月份、完整特征和分箱数据。 + +## 7. 若模型方不愿新增4张表 + +最小折中方案: + +- `model_monitor_aggr`继续承载KS、PSI、IV、CSI等标量指标。 +- 必须固定 `metricName` 字典、单位、月份口径和 `dimensionValue` 规则。 +- 仍需新增批次/发布结构以及评分和特征分箱明细结构。 +- 不建议把所有数据都塞入 `extra JSON`,否则无法建立稳定索引、约束和验收口径。 + +该折中方案开发和长期维护成本更高,推荐优先采用4张结构化表。 + diff --git a/docs/database/A卡运维平台-功能页面表关系-V0.1.md b/docs/database/A卡运维平台-功能页面表关系-V0.1.md new file mode 100644 index 0000000..2658679 --- /dev/null +++ b/docs/database/A卡运维平台-功能页面表关系-V0.1.md @@ -0,0 +1,123 @@ +# A 卡运维平台功能、页面与表关系 V0.1 + +> 日期:2026-09-01 +> 依据:对外原型 V1.10、三库边界确认、`model_platform` 18 张表、`model_deploy` 16 张表及银行表待补结论。 +> 状态:分析稿;用于共同确认 `model_operations` V0.2,暂不执行删表或改表。 + +## 1. 当前结论 + +最终系统由三个数据库组成: + +- `model_platform`:模型开发平台,提供统一登录、角色、项目空间、脚本、调度和对象存储。 +- `model_deploy`:模型部署平台,提供模型、版本、部署详情、运行节点和聚合监控指标;银行表及模型—银行关系由模型方补充。 +- `model_operations`:我方运维业务库,承载判级、处理、报告、流程、文档、配置和操作统计。 + +已确认从我方库删除重复主数据表: + +- `ops_banks` +- `ops_model_instances` +- `ops_model_versions` + +删除后,我方目标表为 **21 张**。其中 16 张归属明确,5 张仍需共同决定是否保留为“标准化快照/读模型”。 + +## 2. 三库关联原则 + +- 不建立跨数据库物理外键。 +- 我方内部主键继续使用 `CHAR(26)` ULID。 +- 开发平台引用使用 `platform_user_id/platform_workspace_id CHAR(26)`。 +- 部署平台引用使用 `deploy_id/deploy_version_id/bank_id BIGINT`,最终类型以模型方银行表为准。 +- 报告、判级和流程必须保存生成时快照,不能因为模型方后续修改名称或版本而改变历史。 +- 前端不直接访问数据库,全部通过我方后端聚合三个库的数据。 + +## 3. 前端页面与功能、表关系 + +对外原型 V1.10 当前共有 **13 个前端页面**,页面与数据关系如下。 + +| 前端页面 | 主要功能 | 我方表(model_operations) | 外部只读表 | 主要写入动作 | +|---|---|---|---|---| +| 我的工作台 | KPI、待办、关注、催办、最近动态 | `ops_monitor_evaluations`、`ops_monitor_reviews`、`ops_reports`、`ops_workflows`、`ops_workflow_stages`、`ops_usage_events` | `model_deploy`、银行表;用户显示名来自 `model_platform.users` | 页面本身只读;操作跳转到处理、报告或流程接口 | +| 平台使用统计 | 登录、需求、报告阅读和下载统计 | `ops_usage_events` | `model_platform.users/roles` | 系统自动记录行为事件 | +| 模型大类概览 | 大类卡片、银行筛选、本行/同业 KS/PSI、趋势、等级下钻 | `ops_model_categories`、`ops_monitor_results`、`ops_monitor_evaluations` | `model_deploy.model_deploy`、`model_version`、模型方银行及关系表 | 只读聚合 | +| 已上线模型详情 | 模型版本、开发时点指标、生命周期、评分逻辑、材料入口 | `ops_documents`;必要时读取标准化监控快照 | `model_deploy`、`model_version`、`model_details`、银行及关系表;文件来自 `model_platform.storage_objects` | 上传评分逻辑或材料时写 `ops_documents` | +| 模型监控概览 | 月度明细、多维筛选、异常等级和监控结果等级 | `ops_monitor_results`、`ops_monitor_evaluations`、`ops_monitor_reviews` | 模型、版本、银行名称来自 `model_deploy` | 只读;处理动作进入复核表 | +| 模型监控详情 | 排序性、KS/PSI、IV/CSI、分布、命中规则、两段处理 | `ops_monitor_results`、`ops_monitor_feature_metrics`、`ops_monitor_distributions`、`ops_monitor_evaluations`、`ops_monitor_reviews`、`ops_reports` | `model_deploy`、`model_version`、`model_monitor_aggr` | 模型团队初审、业务团队终审写 `ops_monitor_reviews` | +| 监控 / 诊断报告 | 自动生成、模型团队编辑、发送业务团队、导出 | `ops_reports`、`ops_report_revisions`、`ops_report_template_versions`、`ops_prompt_versions`、`ops_monitor_evaluations` | 模型、版本、银行信息来自 `model_deploy` | 系统建报告;模型团队新增修订并发送;写 Outbox | +| 报告汇总 | 历史报告查询、阅读状态、筛选和导出 | `ops_reports`、`ops_report_revisions` | 模型、版本、银行信息来自 `model_deploy` | 阅读/下载写 `ops_usage_events` | +| 全流程进度 | 需求发起、七阶段流转、预计日期、复用通用模型、催办 | `ops_workflows`、`ops_workflow_stages`、`ops_documents`、`ops_outbox_events` | 银行、模型、版本来自 `model_deploy`;用户来自 `model_platform` | 业务发起、模型推进、业务确认均写流程表 | +| 文档知识库 | 按银行、模型、版本、环节检索流程材料 | `ops_documents`、`ops_workflows`、`ops_workflow_stages` | 文件元数据来自 `model_platform.storage_objects`;模型信息来自 `model_deploy` | 上传/确认材料写 `ops_documents` | +| 监控等级规则 | 13 行规则矩阵、版本发布、回滚、阈值影响测算 | `ops_rule_versions`、`ops_rule_items`、`ops_monitor_evaluations`、`ops_monitor_results` | 模型与银行信息来自 `model_deploy` | 管理员发布规则;系统按新版本生成判级快照 | +| 报告 Prompt 管理 | Prompt 编辑、版本、发布和历史样本回归 | `ops_prompt_versions`、`ops_prompt_regression_runs`、`ops_report_template_versions` | 历史监控样本来自部署/运维数据 | 模型团队维护 Prompt;系统写回归结果 | +| 系统配置 | 报告频率、输出日期、模板版本、通知、手动重算 | `ops_bank_report_configs`、`ops_report_template_versions`、`ops_outbox_events` | 银行列表来自模型方银行表;角色权限来自 `model_platform` | 管理员修改配置并触发后台事件 | + +## 4. 我方 21 张表逐表归属 + +### 4.1 数据接入与标准化(5 张,均需定案) + +| 表 | 作用 | 写入方 | 主要页面 | 当前判断 | +|---|---|---|---|---| +| `ops_model_categories` | 标准A卡/白户A卡/大额A卡/反欺诈评分业务分类 | 我方管理员或种子 | 模型大类概览、规则、流程 | 暂留;若模型方补业务大类字段,可改为映射表 | +| `ops_monitor_batches` | 月度数据批次、修订号和发布状态 | 同步任务 | 监控、报告、系统配置 | 建议保留,模型方当前无批次/修订/发布结构 | +| `ops_monitor_results` | 月度排序性、KS、PSI及样本快照 | 同步任务 | 大类、监控、报告 | 建议保留为标准化月度快照,不由模型团队手工写 | +| `ops_monitor_feature_metrics` | 特征级 IV/CSI及变化 | 同步任务 | 监控详情、诊断报告 | 建议保留;`model_monitor_aggr` 只有通用 metricName,结构不足 | +| `ops_monitor_distributions` | 评分区间和特征分箱分布 | 同步任务 | 监控详情、诊断报告 | 建议保留;模型方当前 SQL 无对应结构 | + +### 4.2 判级与处理(4 张,归属明确) + +| 表 | 写入方 | 主要页面 | +|---|---|---| +| `ops_rule_versions` | 管理员 | 监控等级规则 | +| `ops_rule_items` | 管理员 | 监控等级规则 | +| `ops_monitor_evaluations` | 判级服务 | 工作台、概览、详情、报告 | +| `ops_monitor_reviews` | 模型团队初审、业务团队终审 | 工作台、监控详情 | + +### 4.3 报告与 AI 治理(6 张,归属明确) + +| 表 | 写入方 | 主要页面 | +|---|---|---| +| `ops_report_template_versions` | 管理员 | 报告、系统配置 | +| `ops_prompt_versions` | 模型团队/管理员 | Prompt 管理、报告 | +| `ops_prompt_regression_runs` | 回归任务 | Prompt 管理 | +| `ops_reports` | 报告生成服务、模型团队发送 | 报告、报告汇总、工作台 | +| `ops_report_revisions` | 报告生成服务、模型团队编辑 | 报告、报告汇总 | +| `ops_bank_report_configs` | 管理员 | 系统配置 | + +### 4.4 流程、材料与使用统计(4 张,归属明确) + +| 表 | 写入方 | 主要页面 | +|---|---|---| +| `ops_workflows` | 业务团队发起、流程服务维护 | 全流程进度、工作台 | +| `ops_workflow_stages` | 模型团队/业务团队操作,流程服务落库 | 全流程进度、工作台 | +| `ops_documents` | 模型团队/业务团队上传,文档服务落库 | 流程、知识库、模型详情 | +| `ops_usage_events` | 系统自动记录 | 平台使用统计、报告汇总 | + +### 4.5 异步可靠性(2 张,归属明确) + +| 表 | 写入方 | 主要用途 | +|---|---|---| +| `ops_outbox_events` | 与业务操作同事务写入 | 判级、报告、提醒、超时、流程通知 | +| `ops_consumer_inbox` | 后台消费者 | 防止任务重复执行 | + +## 5. 关键写入责任 + +- 模型方只写 `model_deploy`,不直接写我方判级、报告和流程表。 +- 我方同步任务从 `model_deploy` 读取数据,规范化后写入 4 张 `ops_monitor_*` 快照表。 +- 业务团队和模型团队不直接执行 SQL;其页面操作通过后端服务写入复核、报告修订、流程阶段和文档表。 +- 管理员通过页面维护规则、模板、Prompt 和按银行配置。 +- 后台任务通过 Outbox/Inbox 处理判级、报告生成、催办和超时默认流转。 + +## 6. 需要共同定案的 5 项 + +1. **统一身份主源**:前端登录继续以 `model_platform.users/roles/workspaces` 为准,还是改用 `model_deploy.sys_user/sys_project_space`。建议继续使用现有 `model_platform` 登录。 +2. **模型大类来源**:模型方是否在 `model_deploy` 增加标准A/白户A/大额A/反欺诈业务分类;若不增加,我方保留 `ops_model_categories` 与部署模型的映射。 +3. **监控快照保留**:是否接受我方把 `model_monitor_aggr` 转换为稳定的月度结果、特征和分布快照。建议保留,避免历史报告随源数据变化。 +4. **银行关系结构**:模型方补充一对多还是多对多关系。通用模型存在多银行复用,建议 `bank + model_deploy_bank` 两张表。 +5. **缺失监控字段**:排序性、CSI、特征分箱、基准期/当期分布、样本量和批次修订由模型方新增结构还是通过固定 `extra JSON` 提供。建议明确字段表,不依赖无约束 JSON。 + +## 7. 建议的下一步 + +先不要再次执行 V0.1 完整建表脚本。按本文件共同确认上述 5 项后: + +1. 输出 `model_operations` V0.2 正式表清单。 +2. 生成 V0.1 → V0.2 的非破坏性迁移 SQL。 +3. 给模型方一份只读表、字段和样例数据需求清单。 +4. 再生成 ORM 与首条“模型列表—详情—单月监控结果”真实接口。 diff --git a/docs/database/A卡运维平台-角色权限矩阵初始化-V0.1.sql b/docs/database/A卡运维平台-角色权限矩阵初始化-V0.1.sql new file mode 100644 index 0000000..411369b --- /dev/null +++ b/docs/database/A卡运维平台-角色权限矩阵初始化-V0.1.sql @@ -0,0 +1,188 @@ +-- 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 ( + '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; + +-- 矩阵修正:业务团队只进入运维工作台,不允许进入开发工作台。 +UPDATE role_permissions AS rp +JOIN roles AS r ON r.role_id = rp.role_id +JOIN permissions AS p ON p.permission_id = rp.permission_id +SET rp.is_deleted = 1, + rp.deleted_at = CURRENT_TIMESTAMP(3) +WHERE r.role_code = 'business_team' + AND p.permission_code = 'dashboard:view' + AND rp.is_deleted = 0; + +-- 模型团队: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/database/A卡运维数据库-DBA授权申请-V0.1.md b/docs/database/A卡运维数据库-DBA授权申请-V0.1.md new file mode 100644 index 0000000..edd5adc --- /dev/null +++ b/docs/database/A卡运维数据库-DBA授权申请-V0.1.md @@ -0,0 +1,46 @@ +# A 卡运维数据库 DBA 授权申请 V0.1 + +> 目标:现有 `model_platform` 保持不变,新建 `model_operations`。以下为权限范围说明,不包含真实账号名和口令。 + +## 1. 运维后端 + +- 现有 `DATABASE_URL`:沿用平台后端连接,用于认证、工作空间和存储;运维代码只查询必要字段。 +- 新增 `OPERATIONS_DATABASE_URL`:连接 `model_operations`,读写运维业务表。 +- 不申请对 `model_platform` 新增、修改或删除表结构。 + +建议平台库最小读取范围: + +| 表 | 字段/用途 | +|---|---| +| `users` | `user_id/display_name/status/platform_role_id/is_deleted`,明确排除 `password_hash` | +| `roles` | `role_id/role_code/role_name/role_scope/is_deleted` | +| `workspaces` | `workspace_id/workspace_code/workspace_name/status/is_deleted` | +| `workspace_members` | `workspace_id/user_id/role_id/member_status/is_deleted` | +| `storage_objects` | 文件元数据;优先通过现有存储服务访问 | +| `versions/scripts` | 可选关联开发制品,不作为业务模型主数据 | +| `schedules/schedule_runs` | 可选关联模型计算任务,只读运行状态 | + +## 2. 模型数据写入方 + +建议为模型任务使用独立 `MODEL_WRITER_DATABASE_URL`: + +- `SELECT`:`ops_model_categories` 和 7 张模型源表。 +- `INSERT/UPDATE`:`ops_banks`、`ops_model_instances`、`ops_model_versions`、`ops_monitor_batches`、`ops_monitor_results`、`ops_monitor_feature_metrics`、`ops_monitor_distributions`。 +- `INSERT`:`ops_outbox_events`,事件类型限定为 `monitor_batch.published`。 +- 不授予 `DELETE`,不授予 15 张运维业务表写权限。 + +已发布批次后的源结果不可修改,该限制还需由写入服务校验;MySQL 表级授权本身无法按行状态限制 `UPDATE`。 + +## 3. 架构迁移账号 + +- 仅在发布窗口使用,可对 `model_operations` 执行 DDL 和种子数据脚本。 +- 不作为应用运行账号保存到服务配置。 +- 不授予对 `model_platform` 的 DDL 权限。 + +## 4. 审核项 + +- MySQL 版本需为 8.0,字符集 `utf8mb4`,排序规则 `utf8mb4_0900_ai_ci`。 +- 确认新库备份、恢复、容量告警和慢查询策略。 +- 确认两套连接池上限,避免同一 FastAPI 进程挤满数据库连接。 +- 确认 Schedule Executor 何时增加 `model_operations` Outbox 轮询连接。 + diff --git a/docs/database/CHANGELOG.md b/docs/database/CHANGELOG.md new file mode 100644 index 0000000..163c934 --- /dev/null +++ b/docs/database/CHANGELOG.md @@ -0,0 +1,26 @@ +# 数据库设计变更记录 + +## **当前最新版本:V0.1** + +### 功能页面表关系 V0.1 · 2026-09-01 + +- 按三库边界梳理对外原型 V1.10 的 13 个页面、21 张我方目标表及外部只读依赖。 +- 明确 16 张运维业务表归属,标出 5 张数据接入/标准化表仍需共同定案。 +- 记录页面级读写关系、人工/系统写入责任以及五项 P0 架构决策。 +- 当前仅为分析稿,不执行删表、迁移或正式数据写入。 + +### 原始监控指标模型方表评估清单 V0.1 · 2026-09-01 + +- 核查 `model_deploy` 现有6张相关表,确认 `model_monitor_aggr` 只能部分提供KS/PSI/IV标量指标。 +- 建议模型方新增或提供等价的监控批次、单月结果、特征指标、分箱分布4类结构化表。 +- 列出每张表的必要字段、唯一约束、前端用途及10项评估问题。 +- 明确本行/同业均值、趋势、判级和报告等衍生结果由我方计算,无需模型方提供。 + +### V0.1 · 2026-08-31 + +- 根据现有 `model_platform` 18 张表结构,确认仅复用身份、工作空间、对象存储、脚本版本和调度数据。 +- 新增独立 `model_operations` 库,共 24 张表和 1 个当前监控结果视图。 +- 模型平台受控写入 7 张源表;判级、复核、报告、流程等 15 张业务表归运维模块独占。 +- 新库自带 Outbox/Inbox,不写现有平台事件表;跨库标识只做逻辑引用,不建物理外键。 +- SQL 全部采用非破坏式建库建表,并按模块拆分为单文件不超过 500 行。 +- 新增 `model_operations-完整建表-V0.1.sql`,只合并新库 24 张表,可在 DBeaver/MySQL 客户端中一次性复制执行;不包含建库、视图或种子数据。 diff --git a/docs/database/build_full_schema.py b/docs/database/build_full_schema.py new file mode 100644 index 0000000..f065f85 --- /dev/null +++ b/docs/database/build_full_schema.py @@ -0,0 +1,158 @@ +"""Build the single-file model_operations schema from versioned modules.""" + +from __future__ import annotations + +from pathlib import Path + + +BASE = Path(__file__).parent +SOURCE_DIR = BASE / "model_operations-V0.1" +OUTPUT = BASE / "model_operations-完整建表-V0.1.sql" +SOURCES = [ + "10_reference_and_models.sql", + "20_monitoring_source.sql", + "30_governance_and_reviews.sql", + "40_reports_and_prompts.sql", + "50_workflows_documents_usage.sql", +] + + +def statements(text: str) -> list[str]: + """Split SQL safely enough for quotes/backticks and strip line comments.""" + output: list[str] = [] + current: list[str] = [] + quote: str | None = None + line_comment = False + index = 0 + while index < len(text): + char = text[index] + next_char = text[index + 1] if index + 1 < len(text) else "" + if line_comment: + if char == "\n": + line_comment = False + current.append(" ") + index += 1 + continue + if quote is None and char == "-" and next_char == "-": + line_comment = True + index += 2 + continue + if quote: + current.append(char) + if char == quote: + if quote == "'" and next_char == "'": + current.append(next_char) + index += 2 + continue + quote = None + index += 1 + continue + if char in ("'", "`"): + quote = char + current.append(char) + elif char == ";": + statement = " ".join("".join(current).split()) + if statement: + output.append(statement + ";") + current = [] + else: + current.append(char) + index += 1 + tail = " ".join("".join(current).split()) + if tail: + output.append(tail + ";") + return output + + +def format_create_table(statement: str) -> str: + """Format one CREATE TABLE compactly while keeping DBeaver-friendly lines.""" + open_at = statement.find("(") + if open_at < 0: + return statement + quote: str | None = None + depth = 0 + close_at = -1 + for index in range(open_at, len(statement)): + char = statement[index] + next_char = statement[index + 1] if index + 1 < len(statement) else "" + if quote: + if char == quote: + if quote == "'" and next_char == "'": + continue + quote = None + continue + if char in ("'", "`"): + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + close_at = index + break + if close_at < 0: + return statement + body = statement[open_at + 1 : close_at] + parts: list[str] = [] + current: list[str] = [] + quote = None + depth = 0 + for index, char in enumerate(body): + next_char = body[index + 1] if index + 1 < len(body) else "" + if quote: + current.append(char) + if char == quote: + if quote == "'" and next_char == "'": + continue + quote = None + continue + if char in ("'", "`"): + quote = char + current.append(char) + elif char == "(": + depth += 1 + current.append(char) + elif char == ")": + depth -= 1 + current.append(char) + elif char == "," and depth == 0: + parts.append(" ".join("".join(current).split())) + current = [] + else: + current.append(char) + tail_part = " ".join("".join(current).split()) + if tail_part: + parts.append(tail_part) + lines = [statement[:open_at].rstrip() + " ("] + for index in range(0, len(parts), 2): + chunk = ", ".join(parts[index : index + 2]) + if index + 2 < len(parts): + chunk += "," + lines.append(" " + chunk) + lines.append(") " + statement[close_at + 1 :].strip()) + return "\n".join(lines) + + +def main() -> None: + bundled: list[str] = [ + "-- A 卡模型智能运维平台 · model_operations 完整建表 V0.1", + "-- 内容:仅包含新库的 24 张表,不含建库、视图、种子数据或 model_platform 现有表。", + "-- 执行前:请在 DBeaver 中选中 model_operations 作为当前活动数据库。", + "-- DBeaver:请使用“执行 SQL 脚本”(Alt/Option + X),不要选中全文后按 Ctrl/Cmd + Enter。", + "-- 如使用“执行 SQL 语句”,每次只能选中一条 CREATE TABLE 单独执行。", + "", + ] + for source_name in SOURCES: + source_statements = statements((SOURCE_DIR / source_name).read_text(encoding="utf-8")) + bundled.append(f"-- ===== {source_name} =====") + for statement in source_statements: + if not statement.lower().startswith("create table "): + continue + bundled.append(format_create_table(statement)) + bundled.append("") + OUTPUT.write_text("\n".join(bundled).rstrip() + "\n", encoding="utf-8") + print(f"built {OUTPUT.name}: 24 tables") + + +if __name__ == "__main__": + main() diff --git a/docs/database/model_operations-V0.1/00_database.sql b/docs/database/model_operations-V0.1/00_database.sql new file mode 100644 index 0000000..6477b3a --- /dev/null +++ b/docs/database/model_operations-V0.1/00_database.sql @@ -0,0 +1,9 @@ +-- A 卡模型智能运维平台独立数据库 V0.1 +-- 安全特性:只创建不存在的数据库,不删除、不覆盖现有 model_platform。 + +CREATE DATABASE IF NOT EXISTS model_operations + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci; + +USE model_operations; + diff --git a/docs/database/model_operations-V0.1/10_reference_and_models.sql b/docs/database/model_operations-V0.1/10_reference_and_models.sql new file mode 100644 index 0000000..9aa18cd --- /dev/null +++ b/docs/database/model_operations-V0.1/10_reference_and_models.sql @@ -0,0 +1,127 @@ +USE model_operations; + +CREATE TABLE IF NOT EXISTS ops_model_categories ( + category_code VARCHAR(32) NOT NULL COMMENT 'std/bai/big/afd', + category_name VARCHAR(100) NOT NULL, + sort_order SMALLINT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (category_code), + KEY idx_ops_categories_status (status, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='固定模型大类字典,由运维模块维护'; + +CREATE TABLE IF NOT EXISTS ops_banks ( + bank_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + bank_code VARCHAR(64) NOT NULL, + bank_name VARCHAR(150) NOT NULL, + is_wuji_bank TINYINT(1) NOT NULL DEFAULT 0, + bank_status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', + source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', + source_bank_ref VARCHAR(128) NULL, + source_updated_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (bank_id), + UNIQUE KEY uk_ops_banks_code (workspace_id, bank_code), + UNIQUE KEY uk_ops_banks_source ( + workspace_id, source_system, source_bank_ref + ), + KEY idx_ops_banks_workspace_status ( + workspace_id, bank_status, is_deleted + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='银行主数据;模型平台可受控直写'; + +CREATE TABLE IF NOT EXISTS ops_model_instances ( + model_instance_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + bank_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_banks', + category_code VARCHAR(32) NOT NULL COMMENT '逻辑引用 ops_model_categories', + model_id VARCHAR(128) NOT NULL COMMENT '工作空间内全局唯一业务模型ID', + model_name VARCHAR(200) NOT NULL, + model_status VARCHAR(24) NOT NULL DEFAULT 'normal' + COMMENT 'normal/escort/escort_finished/offline', + current_version_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_versions', + is_common_model TINYINT(1) NOT NULL DEFAULT 0, + common_source_model_id CHAR(26) NULL + COMMENT '复用时逻辑引用通用模型实例', + common_model_name VARCHAR(200) NULL, + source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', + source_model_ref VARCHAR(128) NULL, + source_updated_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (model_instance_id), + UNIQUE KEY uk_ops_models_business_id (workspace_id, model_id), + UNIQUE KEY uk_ops_models_source ( + workspace_id, source_system, source_model_ref + ), + KEY fk_ops_models_bank (bank_id), + KEY fk_ops_models_current_version (current_version_id), + KEY fk_ops_models_common_source (common_source_model_id), + KEY idx_ops_models_filters ( + workspace_id, category_code, model_status, is_deleted + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='业务模型实例;模型平台可受控直写'; + +CREATE TABLE IF NOT EXISTS ops_model_versions ( + model_version_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + model_instance_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_model_instances', + version_label VARCHAR(64) NOT NULL COMMENT '例如 v2.3', + version_status VARCHAR(24) NOT NULL DEFAULT 'active' + COMMENT 'draft/escort/active/offline', + platform_versions_id CHAR(26) NULL + COMMENT '可选逻辑引用 model_platform.versions', + developer_user_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + developer_display_name VARCHAR(100) NULL COMMENT '历史展示快照', + development_date DATE NULL, + iteration_start_date DATE NULL, + last_iteration_date DATE NULL, + iteration_reason VARCHAR(1000) NULL, + escort_start_date DATE NULL, + escort_end_date DATE NULL, + online_date DATE NULL, + offline_date DATE NULL, + development_ks DECIMAL(12,8) NULL COMMENT '0-1比率', + development_psi DECIMAL(12,8) NULL COMMENT '0-1比率', + max_lift DECIMAL(12,8) NULL, + scoring_logic_storage_object_id CHAR(26) NULL + COMMENT '逻辑引用 model_platform.storage_objects', + source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', + source_version_ref VARCHAR(128) NULL, + source_updated_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (model_version_id), + UNIQUE KEY uk_ops_model_versions_label ( + model_instance_id, version_label + ), + UNIQUE KEY uk_ops_model_versions_source ( + workspace_id, source_system, source_version_ref + ), + KEY fk_ops_model_versions_model (model_instance_id), + KEY fk_ops_model_versions_platform_version (platform_versions_id), + KEY fk_ops_model_versions_developer (developer_user_id), + KEY idx_ops_model_versions_lifecycle ( + workspace_id, version_status, online_date, offline_date + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='模型版本与生命周期;模型平台可受控直写'; + diff --git a/docs/database/model_operations-V0.1/20_monitoring_source.sql b/docs/database/model_operations-V0.1/20_monitoring_source.sql new file mode 100644 index 0000000..8952288 --- /dev/null +++ b/docs/database/model_operations-V0.1/20_monitoring_source.sql @@ -0,0 +1,160 @@ +USE model_operations; + +CREATE TABLE IF NOT EXISTS ops_monitor_batches ( + batch_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', + source_batch_no VARCHAR(128) NOT NULL COMMENT '来源方幂等批次号', + monitor_month DATE NOT NULL COMMENT '固定存当月1日', + revision_no INT NOT NULL DEFAULT 1, + supersedes_batch_id CHAR(26) NULL COMMENT '逻辑引用上一修订批次', + batch_status VARCHAR(24) NOT NULL DEFAULT 'writing' + COMMENT 'writing/published/failed/superseded', + expected_model_count INT NOT NULL DEFAULT 0, + written_model_count INT NOT NULL DEFAULT 0, + feature_row_count BIGINT NOT NULL DEFAULT 0, + distribution_row_count BIGINT NOT NULL DEFAULT 0, + checksum_sha256 CHAR(64) NULL, + generated_at DATETIME(3) NULL, + published_at DATETIME(3) NULL, + published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + failed_reason VARCHAR(2000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (batch_id), + UNIQUE KEY uk_ops_monitor_batches_source_no ( + workspace_id, source_system, source_batch_no + ), + UNIQUE KEY uk_ops_monitor_batches_revision ( + workspace_id, source_system, monitor_month, revision_no + ), + KEY fk_ops_monitor_batches_supersedes (supersedes_batch_id), + KEY idx_ops_monitor_batches_publish ( + workspace_id, batch_status, monitor_month, revision_no + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='月度监控写入批次与发布门闩;模型平台可受控直写'; + +CREATE TABLE IF NOT EXISTS ops_monitor_results ( + monitor_result_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + batch_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_batches', + model_instance_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_model_instances', + model_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_model_versions', + monitor_month DATE NOT NULL COMMENT '固定存当月1日', + source_result_ref VARCHAR(128) NULL, + ranking_result VARCHAR(24) NULL + COMMENT 'matched/unmatched/not_applicable', + ks_value DECIMAL(12,8) NULL COMMENT '0-1比率', + psi_value DECIMAL(12,8) NULL COMMENT '0-1比率', + sample_count BIGINT NULL, + good_count BIGINT NULL, + bad_count BIGINT NULL, + source_result_json JSON NULL COMMENT '尚未结构化的可追溯源字段', + source_calculated_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (monitor_result_id), + UNIQUE KEY uk_ops_monitor_results_batch_model ( + batch_id, model_instance_id + ), + KEY fk_ops_monitor_results_batch (batch_id), + KEY fk_ops_monitor_results_model (model_instance_id), + KEY fk_ops_monitor_results_version (model_version_id), + KEY idx_ops_monitor_results_month ( + workspace_id, monitor_month, model_instance_id + ), + CONSTRAINT chk_ops_monitor_results_ks + CHECK (ks_value IS NULL OR (ks_value >= 0 AND ks_value <= 1)), + CONSTRAINT chk_ops_monitor_results_psi + CHECK (psi_value IS NULL OR psi_value >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='单模型单月原始监控结果;发布后不可更新'; + +CREATE TABLE IF NOT EXISTS ops_monitor_feature_metrics ( + feature_metric_id CHAR(26) NOT NULL, + monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', + feature_code VARCHAR(128) NOT NULL, + feature_name VARCHAR(200) NOT NULL, + iv_value DECIMAL(12,8) NULL, + previous_iv_value DECIMAL(12,8) NULL, + iv_drop_rate DECIMAL(12,8) NULL COMMENT '0-1比率', + csi_value DECIMAL(12,8) NULL, + previous_csi_value DECIMAL(12,8) NULL, + csi_rise_rate DECIMAL(12,8) NULL COMMENT '0-1比率', + ks_contribution_change DECIMAL(12,8) NULL, + psi_contribution_change DECIMAL(12,8) NULL, + source_metric_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (feature_metric_id), + UNIQUE KEY uk_ops_feature_metrics_result_feature ( + monitor_result_id, feature_code + ), + KEY fk_ops_feature_metrics_result (monitor_result_id), + KEY idx_ops_feature_metrics_iv_drop (monitor_result_id, iv_drop_rate), + KEY idx_ops_feature_metrics_csi_rise (monitor_result_id, csi_rise_rate) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='单月特征级 IV/CSI 与贡献变化;发布后不可更新'; + +CREATE TABLE IF NOT EXISTS ops_monitor_distributions ( + distribution_id CHAR(26) NOT NULL, + monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', + dimension_type VARCHAR(24) NOT NULL COMMENT 'score_band/feature_bin', + feature_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '评分分箱时为空串', + feature_name VARCHAR(200) NULL, + bin_order INT NOT NULL, + bin_code VARCHAR(128) NOT NULL, + bin_label VARCHAR(255) NOT NULL, + reference_period_label VARCHAR(64) NULL, + reference_count BIGINT NULL, + reference_share DECIMAL(12,8) NULL COMMENT '0-1比率', + current_count BIGINT NULL, + current_share DECIMAL(12,8) NULL COMMENT '0-1比率', + good_count BIGINT NULL, + bad_count BIGINT NULL, + bad_rate DECIMAL(12,8) NULL COMMENT '0-1比率', + psi_component DECIMAL(12,8) NULL, + csi_component DECIMAL(12,8) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (distribution_id), + UNIQUE KEY uk_ops_distributions_bin ( + monitor_result_id, dimension_type, feature_code, bin_order + ), + KEY fk_ops_distributions_result (monitor_result_id), + KEY idx_ops_distributions_feature ( + monitor_result_id, feature_code, bin_order + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='排序性评分分箱及特征分布;发布后不可更新'; + +CREATE OR REPLACE VIEW v_ops_current_monitor_results AS +SELECT result_row.* +FROM ops_monitor_results AS result_row +JOIN ops_monitor_batches AS batch_row + ON batch_row.batch_id = result_row.batch_id +JOIN ( + SELECT + workspace_id, + source_system, + monitor_month, + MAX(revision_no) AS revision_no + FROM ops_monitor_batches + WHERE batch_status = 'published' AND is_deleted = 0 + GROUP BY workspace_id, source_system, monitor_month +) AS latest + ON latest.workspace_id = batch_row.workspace_id + AND latest.source_system = batch_row.source_system + AND latest.monitor_month = batch_row.monitor_month + AND latest.revision_no = batch_row.revision_no +WHERE result_row.is_deleted = 0 + AND batch_row.batch_status = 'published' + AND batch_row.is_deleted = 0; + diff --git a/docs/database/model_operations-V0.1/30_governance_and_reviews.sql b/docs/database/model_operations-V0.1/30_governance_and_reviews.sql new file mode 100644 index 0000000..6e0d431 --- /dev/null +++ b/docs/database/model_operations-V0.1/30_governance_and_reviews.sql @@ -0,0 +1,177 @@ +USE model_operations; + +CREATE TABLE IF NOT EXISTS ops_rule_versions ( + rule_version_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + category_code VARCHAR(32) NOT NULL DEFAULT '*' + COMMENT '* 表示全部模型大类', + version_label VARCHAR(64) NOT NULL, + rule_status VARCHAR(24) NOT NULL DEFAULT 'draft' + COMMENT 'draft/published/retired', + threshold_json JSON NOT NULL COMMENT 'KS/PSI/环比切点快照', + supersedes_rule_version_id CHAR(26) NULL, + change_note VARCHAR(1000) NULL, + created_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + published_at DATETIME(3) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (rule_version_id), + UNIQUE KEY uk_ops_rule_versions_label ( + workspace_id, category_code, version_label + ), + KEY fk_ops_rule_versions_supersedes (supersedes_rule_version_id), + KEY idx_ops_rule_versions_status ( + workspace_id, category_code, rule_status, published_at + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='监控判级规则版本'; + +CREATE TABLE IF NOT EXISTS ops_rule_items ( + rule_item_id CHAR(26) NOT NULL, + rule_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_rule_versions', + sort_order SMALLINT NOT NULL, + ranking_result VARCHAR(24) NOT NULL COMMENT 'matched/unmatched', + ks_band_code VARCHAR(32) NOT NULL, + psi_band_code VARCHAR(32) NOT NULL, + ks_drop_band_code VARCHAR(32) NOT NULL, + conditions_json JSON NULL COMMENT '用于跨档或扩展条件', + abnormal_level VARCHAR(16) NOT NULL + COMMENT 'normal/level1/level2/level3', + monitor_grade CHAR(1) NOT NULL COMMENT 'A/B/C', + secondary_upgrade_threshold SMALLINT NULL + COMMENT '近6月二级异常累计升级阈值', + reason_code VARCHAR(64) NOT NULL, + reason_template VARCHAR(1000) NOT NULL, + action_text VARCHAR(1000) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (rule_item_id), + UNIQUE KEY uk_ops_rule_items_order (rule_version_id, sort_order), + UNIQUE KEY uk_ops_rule_items_reason (rule_version_id, reason_code), + KEY fk_ops_rule_items_version (rule_version_id), + CONSTRAINT chk_ops_rule_items_grade + CHECK (monitor_grade IN ('A', 'B', 'C')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='规则版本下的判级矩阵行'; + +CREATE TABLE IF NOT EXISTS ops_monitor_evaluations ( + evaluation_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', + rule_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_rule_versions', + rule_item_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_rule_items', + ks_mom_drop_rate DECIMAL(12,8) NULL COMMENT '0-1比率', + secondary_level2_hits_6m SMALLINT NOT NULL DEFAULT 0, + abnormal_level VARCHAR(16) NOT NULL + COMMENT 'normal/level1/level2/level3', + monitor_grade CHAR(1) NOT NULL COMMENT 'A/B/C', + reason_code VARCHAR(64) NOT NULL, + reason_text_snapshot VARCHAR(2000) NOT NULL, + action_snapshot VARCHAR(2000) NOT NULL, + is_current TINYINT(1) NOT NULL DEFAULT 1, + evaluated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + current_monitor_result_id CHAR(26) + GENERATED ALWAYS AS ( + CASE + WHEN is_current = 1 AND is_deleted = 0 THEN monitor_result_id + ELSE NULL + END + ) VIRTUAL, + PRIMARY KEY (evaluation_id), + UNIQUE KEY uk_ops_evaluations_result_rule ( + monitor_result_id, rule_version_id + ), + UNIQUE KEY uk_ops_evaluations_current (current_monitor_result_id), + KEY fk_ops_evaluations_result (monitor_result_id), + KEY fk_ops_evaluations_rule_version (rule_version_id), + KEY fk_ops_evaluations_rule_item (rule_item_id), + KEY idx_ops_evaluations_grade ( + workspace_id, monitor_grade, abnormal_level, evaluated_at + ), + CONSTRAINT chk_ops_evaluations_grade + CHECK (monitor_grade IN ('A', 'B', 'C')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='按规则版本生成的不可变监控判级快照'; + +CREATE TABLE IF NOT EXISTS ops_monitor_reviews ( + review_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', + evaluation_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_evaluations', + review_stage VARCHAR(24) NOT NULL + COMMENT 'model_initial/business_final', + review_status VARCHAR(24) NOT NULL DEFAULT 'pending' + COMMENT 'pending/handled/auto_closed', + decision_code VARCHAR(32) NULL COMMENT 'no_action/tune_or_rebuild', + handling_note VARCHAR(2000) NULL COMMENT '手工处理时必填,由服务层校验', + due_at DATETIME(3) NULL, + handled_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + handled_at DATETIME(3) NULL, + auto_closed_at DATETIME(3) NULL, + state_version INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (review_id), + UNIQUE KEY uk_ops_reviews_result_stage ( + monitor_result_id, review_stage + ), + KEY fk_ops_reviews_evaluation (evaluation_id), + KEY fk_ops_reviews_handler (handled_by), + KEY idx_ops_reviews_pending ( + workspace_id, review_status, review_stage, due_at + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='模型团队初审与业务团队终审'; + +CREATE TABLE IF NOT EXISTS ops_outbox_events ( + event_id CHAR(26) NOT NULL, + aggregate_type VARCHAR(64) NOT NULL, + aggregate_id VARCHAR(128) NOT NULL, + event_type VARCHAR(128) NOT NULL, + schema_version SMALLINT NOT NULL DEFAULT 1, + payload_json JSON NOT NULL, + event_status VARCHAR(16) NOT NULL DEFAULT 'pending' + COMMENT 'pending/published/failed', + available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + retry_count INT NOT NULL DEFAULT 0, + trace_id VARCHAR(64) NULL, + idempotency_key VARCHAR(128) NULL, + published_at DATETIME(3) NULL, + last_error VARCHAR(2000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (event_id), + UNIQUE KEY uk_ops_outbox_idempotency (idempotency_key), + KEY idx_ops_outbox_pending (event_status, available_at, created_at), + KEY idx_ops_outbox_aggregate (aggregate_type, aggregate_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='运维库事务 Outbox;由运维异步执行器轮询'; + +CREATE TABLE IF NOT EXISTS ops_consumer_inbox ( + consumer_name VARCHAR(128) NOT NULL, + event_id CHAR(26) NOT NULL, + process_status VARCHAR(16) NOT NULL DEFAULT 'processing' + COMMENT 'processing/succeeded/failed', + message_id VARCHAR(128) NULL, + processed_at DATETIME(3) NULL, + error_message VARCHAR(2000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (consumer_name, event_id), + KEY idx_ops_inbox_status ( + consumer_name, process_status, created_at + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='运维异步消费者幂等 Inbox'; + diff --git a/docs/database/model_operations-V0.1/40_reports_and_prompts.sql b/docs/database/model_operations-V0.1/40_reports_and_prompts.sql new file mode 100644 index 0000000..5f87d7f --- /dev/null +++ b/docs/database/model_operations-V0.1/40_reports_and_prompts.sql @@ -0,0 +1,172 @@ +USE model_operations; + +CREATE TABLE IF NOT EXISTS ops_report_template_versions ( + template_version_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + report_type VARCHAR(24) NOT NULL COMMENT 'monitor/diagnostic', + version_label VARCHAR(64) NOT NULL, + template_status VARCHAR(24) NOT NULL DEFAULT 'draft' + COMMENT 'draft/published/retired', + schema_json JSON NULL COMMENT '报告结构与字段定义', + template_storage_object_id CHAR(26) NULL + COMMENT '逻辑引用 model_platform.storage_objects', + change_note VARCHAR(1000) NULL, + created_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + published_at DATETIME(3) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (template_version_id), + UNIQUE KEY uk_ops_report_templates_version ( + workspace_id, report_type, version_label + ), + KEY idx_ops_report_templates_status ( + workspace_id, report_type, template_status, published_at + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='监控/诊断报告模板版本'; + +CREATE TABLE IF NOT EXISTS ops_prompt_versions ( + prompt_version_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + prompt_key VARCHAR(64) NOT NULL COMMENT 'monitor_A/diagnostic_BC 等', + version_label VARCHAR(64) NOT NULL, + prompt_name VARCHAR(200) NOT NULL, + prompt_text LONGTEXT NOT NULL, + prompt_status VARCHAR(24) NOT NULL DEFAULT 'draft' + COMMENT 'draft/review/published/retired', + change_note VARCHAR(1000) NULL, + created_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + published_at DATETIME(3) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (prompt_version_id), + UNIQUE KEY uk_ops_prompt_versions_label ( + workspace_id, prompt_key, version_label + ), + KEY idx_ops_prompt_versions_status ( + workspace_id, prompt_key, prompt_status, published_at + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='报告 Prompt 版本'; + +CREATE TABLE IF NOT EXISTS ops_prompt_regression_runs ( + regression_run_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + prompt_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_prompt_versions', + run_no INT NOT NULL, + run_status VARCHAR(24) NOT NULL DEFAULT 'queued' + COMMENT 'queued/running/passed/failed/cancelled', + sample_set_version VARCHAR(64) NOT NULL, + sample_count INT NOT NULL DEFAULT 0, + passed_count INT NOT NULL DEFAULT 0, + failed_count INT NOT NULL DEFAULT 0, + details_json JSON NULL, + started_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + started_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (regression_run_id), + UNIQUE KEY uk_ops_prompt_regression_no (prompt_version_id, run_no), + KEY fk_ops_prompt_regression_prompt (prompt_version_id), + KEY idx_ops_prompt_regression_status ( + workspace_id, run_status, created_at + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='Prompt 回归测试批次与结果摘要'; + +CREATE TABLE IF NOT EXISTS ops_reports ( + report_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + report_no VARCHAR(64) NOT NULL, + monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', + evaluation_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_evaluations', + report_month DATE NOT NULL COMMENT '固定存当月1日', + report_type VARCHAR(24) NOT NULL COMMENT 'monitor/diagnostic', + report_status VARCHAR(32) NOT NULL DEFAULT 'pending_model_read' + COMMENT 'pending_model_read/editing/sent_business', + template_version_id CHAR(26) NOT NULL, + prompt_version_id CHAR(26) NULL, + current_revision_id CHAR(26) NULL COMMENT '逻辑引用 ops_report_revisions', + output_date DATE NULL, + generated_at DATETIME(3) NULL, + sent_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + sent_at DATETIME(3) NULL, + business_first_read_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + business_first_read_at DATETIME(3) NULL, + state_version INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (report_id), + UNIQUE KEY uk_ops_reports_no (workspace_id, report_no), + UNIQUE KEY uk_ops_reports_result_type (monitor_result_id, report_type), + KEY fk_ops_reports_evaluation (evaluation_id), + KEY fk_ops_reports_template (template_version_id), + KEY fk_ops_reports_prompt (prompt_version_id), + KEY idx_ops_reports_summary ( + workspace_id, report_month, report_type, report_status + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='监控报告与诊断报告主记录'; + +CREATE TABLE IF NOT EXISTS ops_report_revisions ( + report_revision_id CHAR(26) NOT NULL, + report_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_reports', + revision_no INT NOT NULL, + revision_status VARCHAR(24) NOT NULL DEFAULT 'draft' + COMMENT 'draft/saved/sent', + body_json JSON NULL, + body_text LONGTEXT NULL, + source_snapshot_json JSON NOT NULL + COMMENT '生成时指标、规则、模板和Prompt快照', + pdf_storage_object_id CHAR(26) NULL + COMMENT '逻辑引用 model_platform.storage_objects', + excel_storage_object_id CHAR(26) NULL + COMMENT '逻辑引用 model_platform.storage_objects', + edited_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + edited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (report_revision_id), + UNIQUE KEY uk_ops_report_revisions_no (report_id, revision_no), + KEY fk_ops_report_revisions_report (report_id), + KEY fk_ops_report_revisions_editor (edited_by) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='报告不可变正文修订'; + +CREATE TABLE IF NOT EXISTS ops_bank_report_configs ( + bank_report_config_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + bank_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_banks', + report_frequency VARCHAR(24) NOT NULL DEFAULT 'monthly' + COMMENT 'monthly/quarterly/semiannual/annual', + output_day TINYINT UNSIGNED NOT NULL DEFAULT 15, + output_time TIME NOT NULL DEFAULT '06:00:00', + reminder_days SMALLINT UNSIGNED NOT NULL DEFAULT 5, + enabled TINYINT(1) NOT NULL DEFAULT 1, + state_version INT NOT NULL DEFAULT 0, + updated_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (bank_report_config_id), + UNIQUE KEY uk_ops_bank_report_configs_bank (workspace_id, bank_id), + KEY idx_ops_bank_report_configs_due ( + workspace_id, enabled, report_frequency, output_day + ), + CONSTRAINT chk_ops_bank_report_output_day + CHECK (output_day BETWEEN 1 AND 28) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='按银行设置报告频率、输出日期与提醒时间'; + diff --git a/docs/database/model_operations-V0.1/50_workflows_documents_usage.sql b/docs/database/model_operations-V0.1/50_workflows_documents_usage.sql new file mode 100644 index 0000000..484e477 --- /dev/null +++ b/docs/database/model_operations-V0.1/50_workflows_documents_usage.sql @@ -0,0 +1,142 @@ +USE model_operations; + +CREATE TABLE IF NOT EXISTS ops_workflows ( + workflow_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + request_no VARCHAR(64) NOT NULL, + workflow_title VARCHAR(255) NOT NULL, + bank_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_banks', + category_code VARCHAR(32) NOT NULL COMMENT '逻辑引用 ops_model_categories', + model_instance_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_instances', + request_type VARCHAR(32) NOT NULL COMMENT 'new/iterate/rebuild', + development_source VARCHAR(32) NOT NULL DEFAULT 'independent' + COMMENT 'independent/common_reuse', + common_source_model_id CHAR(26) NULL COMMENT '逻辑引用通用模型实例', + current_stage SMALLINT NOT NULL DEFAULT 1, + workflow_status VARCHAR(24) NOT NULL DEFAULT 'active' + COMMENT 'active/completed/cancelled', + initiated_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + initiated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + expected_feedback_at DATETIME(3) NULL, + planned_test_date DATE NULL, + planned_online_date DATE NULL, + completed_at DATETIME(3) NULL, + cancelled_at DATETIME(3) NULL, + state_version INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (workflow_id), + UNIQUE KEY uk_ops_workflows_request_no (workspace_id, request_no), + KEY fk_ops_workflows_bank (bank_id), + KEY fk_ops_workflows_model (model_instance_id), + KEY fk_ops_workflows_common_source (common_source_model_id), + KEY idx_ops_workflows_filters ( + workspace_id, workflow_status, category_code, current_stage, initiated_at + ), + CONSTRAINT chk_ops_workflows_stage + CHECK (current_stage BETWEEN 1 AND 8) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='模型新增、迭代或通用模型复用的全流程实例'; + +CREATE TABLE IF NOT EXISTS ops_workflow_stages ( + workflow_stage_id CHAR(26) NOT NULL, + workflow_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_workflows', + stage_no SMALLINT NOT NULL, + stage_name_snapshot VARCHAR(100) NOT NULL, + stage_status VARCHAR(24) NOT NULL DEFAULT 'pending' + COMMENT 'pending/active/waiting_confirmation/completed/skipped', + owner_role_code VARCHAR(64) NOT NULL COMMENT '登录角色代码快照', + planned_at DATETIME(3) NULL, + due_at DATETIME(3) NULL, + started_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + completed_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + confirmation_required TINYINT(1) NOT NULL DEFAULT 0, + confirmation_status VARCHAR(24) NULL + COMMENT 'pending/confirmed/rejected/not_required', + confirmed_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + confirmed_at DATETIME(3) NULL, + stage_note VARCHAR(2000) NULL, + state_version INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (workflow_stage_id), + UNIQUE KEY uk_ops_workflow_stages_no (workflow_id, stage_no), + KEY fk_ops_workflow_stages_workflow (workflow_id), + KEY idx_ops_workflow_stages_due (stage_status, due_at), + CONSTRAINT chk_ops_workflow_stages_no + CHECK (stage_no BETWEEN 1 AND 7) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='七阶段流程节点实例与确认留痕'; + +CREATE TABLE IF NOT EXISTS ops_documents ( + document_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + workflow_id CHAR(26) NULL COMMENT '逻辑引用 ops_workflows', + workflow_stage_id CHAR(26) NULL COMMENT '逻辑引用 ops_workflow_stages', + model_instance_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_instances', + model_version_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_versions', + document_type VARCHAR(64) NOT NULL, + document_name VARCHAR(255) NOT NULL, + document_version VARCHAR(64) NULL, + source_type VARCHAR(16) NOT NULL COMMENT 'storage/link', + storage_object_id CHAR(26) NULL + COMMENT '逻辑引用 model_platform.storage_objects', + external_url VARCHAR(1500) NULL, + uploaded_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + confirmation_required TINYINT(1) NOT NULL DEFAULT 0, + confirmation_status VARCHAR(24) NULL, + confirmed_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', + confirmed_at DATETIME(3) NULL, + metadata_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (document_id), + KEY fk_ops_documents_workflow (workflow_id), + KEY fk_ops_documents_stage (workflow_stage_id), + KEY fk_ops_documents_model (model_instance_id), + KEY fk_ops_documents_version (model_version_id), + KEY fk_ops_documents_storage (storage_object_id), + KEY idx_ops_documents_knowledge ( + workspace_id, document_type, model_instance_id, uploaded_at + ), + CONSTRAINT chk_ops_documents_source + CHECK ( + (source_type = 'storage' AND storage_object_id IS NOT NULL) + OR (source_type = 'link' AND external_url IS NOT NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='流程材料和文档知识库索引;文件本体复用平台对象存储'; + +CREATE TABLE IF NOT EXISTS ops_usage_events ( + usage_event_id CHAR(26) NOT NULL, + workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', + user_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', + role_code_snapshot VARCHAR(64) NULL, + event_type VARCHAR(64) NOT NULL + COMMENT 'login/request_submit/report_read/report_download', + target_type VARCHAR(64) NULL, + target_id VARCHAR(128) NULL, + event_metadata JSON NULL, + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (usage_event_id), + KEY idx_ops_usage_events_stats ( + workspace_id, occurred_at, event_type, user_id + ), + KEY idx_ops_usage_events_target (target_type, target_id, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='运维平台登录、需求和报告行为事件'; + diff --git a/docs/database/model_operations-V0.1/60_seed.sql b/docs/database/model_operations-V0.1/60_seed.sql new file mode 100644 index 0000000..d9d59fa --- /dev/null +++ b/docs/database/model_operations-V0.1/60_seed.sql @@ -0,0 +1,21 @@ +USE model_operations; + +INSERT INTO ops_model_categories ( + category_code, + category_name, + sort_order, + status, + is_deleted, + deleted_at +) VALUES + ('std', '标准A卡', 10, 'active', 0, NULL), + ('bai', '白户A卡', 20, 'active', 0, NULL), + ('big', '大额A卡', 30, 'active', 0, NULL), + ('afd', '反欺诈评分', 40, 'active', 0, NULL) +ON DUPLICATE KEY UPDATE + category_name = VALUES(category_name), + sort_order = VALUES(sort_order), + status = VALUES(status), + is_deleted = 0, + deleted_at = NULL; + diff --git a/docs/database/model_operations-V0.1/README.md b/docs/database/model_operations-V0.1/README.md new file mode 100644 index 0000000..c72ce8f --- /dev/null +++ b/docs/database/model_operations-V0.1/README.md @@ -0,0 +1,55 @@ +# model_operations 数据库 V0.1 + +本目录只提供建库建表脚本,不会修改现有 `model_platform` 库。当前为设计稿,执行生产数据库前仍需 DBA 审核账号、备份和变更窗口。 + +如需一次性复制执行 24 张新表,直接使用同级文件 `model_operations-完整建表-V0.1.sql`;该文件不包含建库、视图或种子数据。 + +先将 `model_operations` 设为当前活动数据库,再使用 DBeaver 的“执行 SQL 脚本”(Alt/Option + X)。合并文件不再包含 `USE`;Ctrl/Cmd + Enter 仍只适合单条 `CREATE TABLE`。 + +## 执行顺序 + +```bash +mysql -h -P -u -p < 00_database.sql +mysql -h -P -u -p model_operations < 10_reference_and_models.sql +mysql -h -P -u -p model_operations < 20_monitoring_source.sql +mysql -h -P -u -p model_operations < 30_governance_and_reviews.sql +mysql -h -P -u -p model_operations < 40_reports_and_prompts.sql +mysql -h -P -u -p model_operations < 50_workflows_documents_usage.sql +mysql -h -P -u -p model_operations < 60_seed.sql +``` + +脚本使用 `CREATE DATABASE/TABLE IF NOT EXISTS`,不包含 `DROP`、`TRUNCATE` 或业务数据删除。索引全部内联在建表语句中,避免 MySQL 8 不支持 `CREATE INDEX IF NOT EXISTS` 的问题。 + +执行前可运行 `python3 validate_schema.py`,检查 24 张表、1 个视图、文件行数和破坏性语句。 + +## 两库边界 + +### model_platform:只读依赖 + +| 表 | 运维模块用途 | 允许读取的关键字段 | +|---|---|---| +| `users` | 当前用户、处理人和上传人显示 | `user_id/display_name/status/platform_role_id`;禁止读取 `password_hash` | +| `roles` | 登录角色代码和名称 | `role_id/role_code/role_name/role_scope` | +| `workspaces` | 运维数据空间 | `workspace_id/workspace_code/workspace_name/status` | +| `workspace_members` | 校验用户能否访问工作空间 | `workspace_id/user_id/role_id/member_status` | +| `storage_objects` | 报告、评分逻辑和流程材料元数据 | 通过现有存储服务访问,运维表只保存 `storage_object_id` | +| `versions/scripts` | 可选关联模型开发产物 | 仅引用稳定版本和脚本标识,不把它当业务模型版本 | +| `schedules/schedule_runs` | 可选追踪监控计算任务 | 只读任务和运行状态,不直接改调度数据 | + +`permissions`、`role_permissions`、`upload_sessions`、平台 `outbox_events` 等表不由运维模块直接读写;权限仍由现有登录/权限模块负责。 + +### model_operations:运维模块自有 + +- 24 张表:7 张模型源表、15 张运维业务表、2 张独立 Outbox/Inbox 技术表。 +- 所有 `workspace_id/user_id/storage_object_id/platform_versions_id` 均为跨库逻辑引用,不建立跨库物理外键。 +- 模型平台写入 7 张源表,并可向 `ops_outbox_events` 插入 `monitor_batch.published` 事件;不得写判级、复核、报告和流程表。 +- 运维应用拥有本库业务读写权限,但不得更新已发布的原始监控结果。 + +## 应用连接 + +后端需要两套连接配置: + +- `DATABASE_URL`:现有 `model_platform`,沿用认证、工作空间、存储和调度能力。 +- `OPERATIONS_DATABASE_URL`:新建 `model_operations`,承载全部运维业务数据。 + +首条真实接口只需要后端双连接;异步判级、报告和提醒接入时,再让 Schedule Executor 增加运维库 Outbox 轮询连接。 diff --git a/docs/database/model_operations-V0.1/validate_schema.py b/docs/database/model_operations-V0.1/validate_schema.py new file mode 100644 index 0000000..0cc0468 --- /dev/null +++ b/docs/database/model_operations-V0.1/validate_schema.py @@ -0,0 +1,70 @@ +"""Static safety and inventory checks for the model_operations V0.1 DDL.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +BASE = Path(__file__).parent +EXPECTED_TABLES = { + "ops_model_categories", + "ops_banks", + "ops_model_instances", + "ops_model_versions", + "ops_monitor_batches", + "ops_monitor_results", + "ops_monitor_feature_metrics", + "ops_monitor_distributions", + "ops_rule_versions", + "ops_rule_items", + "ops_monitor_evaluations", + "ops_monitor_reviews", + "ops_outbox_events", + "ops_consumer_inbox", + "ops_report_template_versions", + "ops_prompt_versions", + "ops_prompt_regression_runs", + "ops_reports", + "ops_report_revisions", + "ops_bank_report_configs", + "ops_workflows", + "ops_workflow_stages", + "ops_documents", + "ops_usage_events", +} + + +def main() -> None: + sql_files = sorted(BASE.glob("*.sql")) + combined = "\n".join(path.read_text(encoding="utf-8") for path in sql_files) + tables = { + match.lower() + for match in re.findall( + r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z0-9_]+)", + combined, + flags=re.IGNORECASE, + ) + } + missing = EXPECTED_TABLES - tables + extra = tables - EXPECTED_TABLES + assert not missing, f"missing tables: {sorted(missing)}" + assert not extra, f"unexpected tables: {sorted(extra)}" + assert len(re.findall(r"CREATE\s+OR\s+REPLACE\s+VIEW", combined, re.I)) == 1 + assert not re.search(r"\b(DROP|TRUNCATE|DELETE\s+FROM)\b", combined, re.I) + assert not re.search( + r"\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES|TABLE)\s+model_platform\.", + combined, + re.I, + ), "cross-database SQL object reference found" + for path in sql_files: + line_count = len(path.read_text(encoding="utf-8").splitlines()) + assert line_count <= 500, f"{path.name} exceeds 500 lines" + print( + f"validated {len(sql_files)} SQL files: " + f"{len(tables)} tables, 1 view, no destructive statements" + ) + + +if __name__ == "__main__": + main() diff --git a/docs/database/model_operations-完整建表-V0.1.sql b/docs/database/model_operations-完整建表-V0.1.sql new file mode 100644 index 0000000..209e6b1 --- /dev/null +++ b/docs/database/model_operations-完整建表-V0.1.sql @@ -0,0 +1,40 @@ +-- A 卡模型智能运维平台 · model_operations 完整建表 V0.1 +-- 内容:仅包含新库的 24 张表,不含建库、视图、种子数据或 model_platform 现有表。 +-- 执行前请确认当前账号具备 model_operations 建表权限。 +-- DBeaver:请使用“执行 SQL 脚本”(Alt/Option + X),不要选中全文后按 Ctrl/Cmd + Enter。 +-- 如使用“执行 SQL 语句”,每次只能选中一条 USE 或 CREATE TABLE 单独执行。 + +-- ===== 10_reference_and_models.sql ===== +USE model_operations; +CREATE TABLE IF NOT EXISTS ops_model_categories ( category_code VARCHAR(32) NOT NULL COMMENT 'std/bai/big/afd', category_name VARCHAR(100) NOT NULL, sort_order SMALLINT NOT NULL DEFAULT 0, status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (category_code), KEY idx_ops_categories_status (status, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='固定模型大类字典,由运维模块维护'; +CREATE TABLE IF NOT EXISTS ops_banks ( bank_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', bank_code VARCHAR(64) NOT NULL, bank_name VARCHAR(150) NOT NULL, is_wuji_bank TINYINT(1) NOT NULL DEFAULT 0, bank_status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', source_bank_ref VARCHAR(128) NULL, source_updated_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (bank_id), UNIQUE KEY uk_ops_banks_code (workspace_id, bank_code), UNIQUE KEY uk_ops_banks_source ( workspace_id, source_system, source_bank_ref ), KEY idx_ops_banks_workspace_status ( workspace_id, bank_status, is_deleted ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='银行主数据;模型平台可受控直写'; +CREATE TABLE IF NOT EXISTS ops_model_instances ( model_instance_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', bank_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_banks', category_code VARCHAR(32) NOT NULL COMMENT '逻辑引用 ops_model_categories', model_id VARCHAR(128) NOT NULL COMMENT '工作空间内全局唯一业务模型ID', model_name VARCHAR(200) NOT NULL, model_status VARCHAR(24) NOT NULL DEFAULT 'normal' COMMENT 'normal/escort/escort_finished/offline', current_version_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_versions', is_common_model TINYINT(1) NOT NULL DEFAULT 0, common_source_model_id CHAR(26) NULL COMMENT '复用时逻辑引用通用模型实例', common_model_name VARCHAR(200) NULL, source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', source_model_ref VARCHAR(128) NULL, source_updated_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (model_instance_id), UNIQUE KEY uk_ops_models_business_id (workspace_id, model_id), UNIQUE KEY uk_ops_models_source ( workspace_id, source_system, source_model_ref ), KEY fk_ops_models_bank (bank_id), KEY fk_ops_models_current_version (current_version_id), KEY fk_ops_models_common_source (common_source_model_id), KEY idx_ops_models_filters ( workspace_id, category_code, model_status, is_deleted ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务模型实例;模型平台可受控直写'; +CREATE TABLE IF NOT EXISTS ops_model_versions ( model_version_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', model_instance_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_model_instances', version_label VARCHAR(64) NOT NULL COMMENT '例如 v2.3', version_status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'draft/escort/active/offline', platform_versions_id CHAR(26) NULL COMMENT '可选逻辑引用 model_platform.versions', developer_user_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', developer_display_name VARCHAR(100) NULL COMMENT '历史展示快照', development_date DATE NULL, iteration_start_date DATE NULL, last_iteration_date DATE NULL, iteration_reason VARCHAR(1000) NULL, escort_start_date DATE NULL, escort_end_date DATE NULL, online_date DATE NULL, offline_date DATE NULL, development_ks DECIMAL(12,8) NULL COMMENT '0-1比率', development_psi DECIMAL(12,8) NULL COMMENT '0-1比率', max_lift DECIMAL(12,8) NULL, scoring_logic_storage_object_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.storage_objects', source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', source_version_ref VARCHAR(128) NULL, source_updated_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (model_version_id), UNIQUE KEY uk_ops_model_versions_label ( model_instance_id, version_label ), UNIQUE KEY uk_ops_model_versions_source ( workspace_id, source_system, source_version_ref ), KEY fk_ops_model_versions_model (model_instance_id), KEY fk_ops_model_versions_platform_version (platform_versions_id), KEY fk_ops_model_versions_developer (developer_user_id), KEY idx_ops_model_versions_lifecycle ( workspace_id, version_status, online_date, offline_date ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='模型版本与生命周期;模型平台可受控直写'; + +-- ===== 20_monitoring_source.sql ===== +CREATE TABLE IF NOT EXISTS ops_monitor_batches ( batch_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', source_system VARCHAR(64) NOT NULL DEFAULT 'model_platform', source_batch_no VARCHAR(128) NOT NULL COMMENT '来源方幂等批次号', monitor_month DATE NOT NULL COMMENT '固定存当月1日', revision_no INT NOT NULL DEFAULT 1, supersedes_batch_id CHAR(26) NULL COMMENT '逻辑引用上一修订批次', batch_status VARCHAR(24) NOT NULL DEFAULT 'writing' COMMENT 'writing/published/failed/superseded', expected_model_count INT NOT NULL DEFAULT 0, written_model_count INT NOT NULL DEFAULT 0, feature_row_count BIGINT NOT NULL DEFAULT 0, distribution_row_count BIGINT NOT NULL DEFAULT 0, checksum_sha256 CHAR(64) NULL, generated_at DATETIME(3) NULL, published_at DATETIME(3) NULL, published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', failed_reason VARCHAR(2000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (batch_id), UNIQUE KEY uk_ops_monitor_batches_source_no ( workspace_id, source_system, source_batch_no ), UNIQUE KEY uk_ops_monitor_batches_revision ( workspace_id, source_system, monitor_month, revision_no ), KEY fk_ops_monitor_batches_supersedes (supersedes_batch_id), KEY idx_ops_monitor_batches_publish ( workspace_id, batch_status, monitor_month, revision_no ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='月度监控写入批次与发布门闩;模型平台可受控直写'; +CREATE TABLE IF NOT EXISTS ops_monitor_results ( monitor_result_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', batch_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_batches', model_instance_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_model_instances', model_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_model_versions', monitor_month DATE NOT NULL COMMENT '固定存当月1日', source_result_ref VARCHAR(128) NULL, ranking_result VARCHAR(24) NULL COMMENT 'matched/unmatched/not_applicable', ks_value DECIMAL(12,8) NULL COMMENT '0-1比率', psi_value DECIMAL(12,8) NULL COMMENT '0-1比率', sample_count BIGINT NULL, good_count BIGINT NULL, bad_count BIGINT NULL, source_result_json JSON NULL COMMENT '尚未结构化的可追溯源字段', source_calculated_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (monitor_result_id), UNIQUE KEY uk_ops_monitor_results_batch_model ( batch_id, model_instance_id ), KEY fk_ops_monitor_results_batch (batch_id), KEY fk_ops_monitor_results_model (model_instance_id), KEY fk_ops_monitor_results_version (model_version_id), KEY idx_ops_monitor_results_month ( workspace_id, monitor_month, model_instance_id ), CONSTRAINT chk_ops_monitor_results_ks CHECK (ks_value IS NULL OR (ks_value >= 0 AND ks_value <= 1)), CONSTRAINT chk_ops_monitor_results_psi CHECK (psi_value IS NULL OR psi_value >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='单模型单月原始监控结果;发布后不可更新'; +CREATE TABLE IF NOT EXISTS ops_monitor_feature_metrics ( feature_metric_id CHAR(26) NOT NULL, monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', feature_code VARCHAR(128) NOT NULL, feature_name VARCHAR(200) NOT NULL, iv_value DECIMAL(12,8) NULL, previous_iv_value DECIMAL(12,8) NULL, iv_drop_rate DECIMAL(12,8) NULL COMMENT '0-1比率', csi_value DECIMAL(12,8) NULL, previous_csi_value DECIMAL(12,8) NULL, csi_rise_rate DECIMAL(12,8) NULL COMMENT '0-1比率', ks_contribution_change DECIMAL(12,8) NULL, psi_contribution_change DECIMAL(12,8) NULL, source_metric_json JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (feature_metric_id), UNIQUE KEY uk_ops_feature_metrics_result_feature ( monitor_result_id, feature_code ), KEY fk_ops_feature_metrics_result (monitor_result_id), KEY idx_ops_feature_metrics_iv_drop (monitor_result_id, iv_drop_rate), KEY idx_ops_feature_metrics_csi_rise (monitor_result_id, csi_rise_rate) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='单月特征级 IV/CSI 与贡献变化;发布后不可更新'; +CREATE TABLE IF NOT EXISTS ops_monitor_distributions ( distribution_id CHAR(26) NOT NULL, monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', dimension_type VARCHAR(24) NOT NULL COMMENT 'score_band/feature_bin', feature_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '评分分箱时为空串', feature_name VARCHAR(200) NULL, bin_order INT NOT NULL, bin_code VARCHAR(128) NOT NULL, bin_label VARCHAR(255) NOT NULL, reference_period_label VARCHAR(64) NULL, reference_count BIGINT NULL, reference_share DECIMAL(12,8) NULL COMMENT '0-1比率', current_count BIGINT NULL, current_share DECIMAL(12,8) NULL COMMENT '0-1比率', good_count BIGINT NULL, bad_count BIGINT NULL, bad_rate DECIMAL(12,8) NULL COMMENT '0-1比率', psi_component DECIMAL(12,8) NULL, csi_component DECIMAL(12,8) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (distribution_id), UNIQUE KEY uk_ops_distributions_bin ( monitor_result_id, dimension_type, feature_code, bin_order ), KEY fk_ops_distributions_result (monitor_result_id), KEY idx_ops_distributions_feature ( monitor_result_id, feature_code, bin_order ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排序性评分分箱及特征分布;发布后不可更新'; + +-- ===== 30_governance_and_reviews.sql ===== +CREATE TABLE IF NOT EXISTS ops_rule_versions ( rule_version_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', category_code VARCHAR(32) NOT NULL DEFAULT '*' COMMENT '* 表示全部模型大类', version_label VARCHAR(64) NOT NULL, rule_status VARCHAR(24) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/retired', threshold_json JSON NOT NULL COMMENT 'KS/PSI/环比切点快照', supersedes_rule_version_id CHAR(26) NULL, change_note VARCHAR(1000) NULL, created_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', published_at DATETIME(3) NULL, is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (rule_version_id), UNIQUE KEY uk_ops_rule_versions_label ( workspace_id, category_code, version_label ), KEY fk_ops_rule_versions_supersedes (supersedes_rule_version_id), KEY idx_ops_rule_versions_status ( workspace_id, category_code, rule_status, published_at ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='监控判级规则版本'; +CREATE TABLE IF NOT EXISTS ops_rule_items ( rule_item_id CHAR(26) NOT NULL, rule_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_rule_versions', sort_order SMALLINT NOT NULL, ranking_result VARCHAR(24) NOT NULL COMMENT 'matched/unmatched', ks_band_code VARCHAR(32) NOT NULL, psi_band_code VARCHAR(32) NOT NULL, ks_drop_band_code VARCHAR(32) NOT NULL, conditions_json JSON NULL COMMENT '用于跨档或扩展条件', abnormal_level VARCHAR(16) NOT NULL COMMENT 'normal/level1/level2/level3', monitor_grade CHAR(1) NOT NULL COMMENT 'A/B/C', secondary_upgrade_threshold SMALLINT NULL COMMENT '近6月二级异常累计升级阈值', reason_code VARCHAR(64) NOT NULL, reason_template VARCHAR(1000) NOT NULL, action_text VARCHAR(1000) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (rule_item_id), UNIQUE KEY uk_ops_rule_items_order (rule_version_id, sort_order), UNIQUE KEY uk_ops_rule_items_reason (rule_version_id, reason_code), KEY fk_ops_rule_items_version (rule_version_id), CONSTRAINT chk_ops_rule_items_grade CHECK (monitor_grade IN ('A', 'B', 'C')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='规则版本下的判级矩阵行'; +CREATE TABLE IF NOT EXISTS ops_monitor_evaluations ( evaluation_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', rule_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_rule_versions', rule_item_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_rule_items', ks_mom_drop_rate DECIMAL(12,8) NULL COMMENT '0-1比率', secondary_level2_hits_6m SMALLINT NOT NULL DEFAULT 0, abnormal_level VARCHAR(16) NOT NULL COMMENT 'normal/level1/level2/level3', monitor_grade CHAR(1) NOT NULL COMMENT 'A/B/C', reason_code VARCHAR(64) NOT NULL, reason_text_snapshot VARCHAR(2000) NOT NULL, action_snapshot VARCHAR(2000) NOT NULL, is_current TINYINT(1) NOT NULL DEFAULT 1, evaluated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, current_monitor_result_id CHAR(26) GENERATED ALWAYS AS ( CASE WHEN is_current = 1 AND is_deleted = 0 THEN monitor_result_id ELSE NULL END ) VIRTUAL, PRIMARY KEY (evaluation_id), UNIQUE KEY uk_ops_evaluations_result_rule ( monitor_result_id, rule_version_id ), UNIQUE KEY uk_ops_evaluations_current (current_monitor_result_id), KEY fk_ops_evaluations_result (monitor_result_id), KEY fk_ops_evaluations_rule_version (rule_version_id), KEY fk_ops_evaluations_rule_item (rule_item_id), KEY idx_ops_evaluations_grade ( workspace_id, monitor_grade, abnormal_level, evaluated_at ), CONSTRAINT chk_ops_evaluations_grade CHECK (monitor_grade IN ('A', 'B', 'C')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='按规则版本生成的不可变监控判级快照'; +CREATE TABLE IF NOT EXISTS ops_monitor_reviews ( review_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', evaluation_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_evaluations', review_stage VARCHAR(24) NOT NULL COMMENT 'model_initial/business_final', review_status VARCHAR(24) NOT NULL DEFAULT 'pending' COMMENT 'pending/handled/auto_closed', decision_code VARCHAR(32) NULL COMMENT 'no_action/tune_or_rebuild', handling_note VARCHAR(2000) NULL COMMENT '手工处理时必填,由服务层校验', due_at DATETIME(3) NULL, handled_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', handled_at DATETIME(3) NULL, auto_closed_at DATETIME(3) NULL, state_version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (review_id), UNIQUE KEY uk_ops_reviews_result_stage ( monitor_result_id, review_stage ), KEY fk_ops_reviews_evaluation (evaluation_id), KEY fk_ops_reviews_handler (handled_by), KEY idx_ops_reviews_pending ( workspace_id, review_status, review_stage, due_at ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='模型团队初审与业务团队终审'; +CREATE TABLE IF NOT EXISTS ops_outbox_events ( event_id CHAR(26) NOT NULL, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(128) NOT NULL, event_type VARCHAR(128) NOT NULL, schema_version SMALLINT NOT NULL DEFAULT 1, payload_json JSON NOT NULL, event_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/published/failed', available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), retry_count INT NOT NULL DEFAULT 0, trace_id VARCHAR(64) NULL, idempotency_key VARCHAR(128) NULL, published_at DATETIME(3) NULL, last_error VARCHAR(2000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (event_id), UNIQUE KEY uk_ops_outbox_idempotency (idempotency_key), KEY idx_ops_outbox_pending (event_status, available_at, created_at), KEY idx_ops_outbox_aggregate (aggregate_type, aggregate_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运维库事务 Outbox;由运维异步执行器轮询'; +CREATE TABLE IF NOT EXISTS ops_consumer_inbox ( consumer_name VARCHAR(128) NOT NULL, event_id CHAR(26) NOT NULL, process_status VARCHAR(16) NOT NULL DEFAULT 'processing' COMMENT 'processing/succeeded/failed', message_id VARCHAR(128) NULL, processed_at DATETIME(3) NULL, error_message VARCHAR(2000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (consumer_name, event_id), KEY idx_ops_inbox_status ( consumer_name, process_status, created_at ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运维异步消费者幂等 Inbox'; + +-- ===== 40_reports_and_prompts.sql ===== +CREATE TABLE IF NOT EXISTS ops_report_template_versions ( template_version_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', report_type VARCHAR(24) NOT NULL COMMENT 'monitor/diagnostic', version_label VARCHAR(64) NOT NULL, template_status VARCHAR(24) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/retired', schema_json JSON NULL COMMENT '报告结构与字段定义', template_storage_object_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.storage_objects', change_note VARCHAR(1000) NULL, created_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', published_at DATETIME(3) NULL, is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (template_version_id), UNIQUE KEY uk_ops_report_templates_version ( workspace_id, report_type, version_label ), KEY idx_ops_report_templates_status ( workspace_id, report_type, template_status, published_at ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='监控/诊断报告模板版本'; +CREATE TABLE IF NOT EXISTS ops_prompt_versions ( prompt_version_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', prompt_key VARCHAR(64) NOT NULL COMMENT 'monitor_A/diagnostic_BC 等', version_label VARCHAR(64) NOT NULL, prompt_name VARCHAR(200) NOT NULL, prompt_text LONGTEXT NOT NULL, prompt_status VARCHAR(24) NOT NULL DEFAULT 'draft' COMMENT 'draft/review/published/retired', change_note VARCHAR(1000) NULL, created_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), published_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', published_at DATETIME(3) NULL, is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (prompt_version_id), UNIQUE KEY uk_ops_prompt_versions_label ( workspace_id, prompt_key, version_label ), KEY idx_ops_prompt_versions_status ( workspace_id, prompt_key, prompt_status, published_at ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报告 Prompt 版本'; +CREATE TABLE IF NOT EXISTS ops_prompt_regression_runs ( regression_run_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', prompt_version_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_prompt_versions', run_no INT NOT NULL, run_status VARCHAR(24) NOT NULL DEFAULT 'queued' COMMENT 'queued/running/passed/failed/cancelled', sample_set_version VARCHAR(64) NOT NULL, sample_count INT NOT NULL DEFAULT 0, passed_count INT NOT NULL DEFAULT 0, failed_count INT NOT NULL DEFAULT 0, details_json JSON NULL, started_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', started_at DATETIME(3) NULL, finished_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (regression_run_id), UNIQUE KEY uk_ops_prompt_regression_no (prompt_version_id, run_no), KEY fk_ops_prompt_regression_prompt (prompt_version_id), KEY idx_ops_prompt_regression_status ( workspace_id, run_status, created_at ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Prompt 回归测试批次与结果摘要'; +CREATE TABLE IF NOT EXISTS ops_reports ( report_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', report_no VARCHAR(64) NOT NULL, monitor_result_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_results', evaluation_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_monitor_evaluations', report_month DATE NOT NULL COMMENT '固定存当月1日', report_type VARCHAR(24) NOT NULL COMMENT 'monitor/diagnostic', report_status VARCHAR(32) NOT NULL DEFAULT 'pending_model_read' COMMENT 'pending_model_read/editing/sent_business', template_version_id CHAR(26) NOT NULL, prompt_version_id CHAR(26) NULL, current_revision_id CHAR(26) NULL COMMENT '逻辑引用 ops_report_revisions', output_date DATE NULL, generated_at DATETIME(3) NULL, sent_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', sent_at DATETIME(3) NULL, business_first_read_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', business_first_read_at DATETIME(3) NULL, state_version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (report_id), UNIQUE KEY uk_ops_reports_no (workspace_id, report_no), UNIQUE KEY uk_ops_reports_result_type (monitor_result_id, report_type), KEY fk_ops_reports_evaluation (evaluation_id), KEY fk_ops_reports_template (template_version_id), KEY fk_ops_reports_prompt (prompt_version_id), KEY idx_ops_reports_summary ( workspace_id, report_month, report_type, report_status ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='监控报告与诊断报告主记录'; +CREATE TABLE IF NOT EXISTS ops_report_revisions ( report_revision_id CHAR(26) NOT NULL, report_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_reports', revision_no INT NOT NULL, revision_status VARCHAR(24) NOT NULL DEFAULT 'draft' COMMENT 'draft/saved/sent', body_json JSON NULL, body_text LONGTEXT NULL, source_snapshot_json JSON NOT NULL COMMENT '生成时指标、规则、模板和Prompt快照', pdf_storage_object_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.storage_objects', excel_storage_object_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.storage_objects', edited_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', edited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (report_revision_id), UNIQUE KEY uk_ops_report_revisions_no (report_id, revision_no), KEY fk_ops_report_revisions_report (report_id), KEY fk_ops_report_revisions_editor (edited_by) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报告不可变正文修订'; +CREATE TABLE IF NOT EXISTS ops_bank_report_configs ( bank_report_config_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', bank_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_banks', report_frequency VARCHAR(24) NOT NULL DEFAULT 'monthly' COMMENT 'monthly/quarterly/semiannual/annual', output_day TINYINT UNSIGNED NOT NULL DEFAULT 15, output_time TIME NOT NULL DEFAULT '06:00:00', reminder_days SMALLINT UNSIGNED NOT NULL DEFAULT 5, enabled TINYINT(1) NOT NULL DEFAULT 1, state_version INT NOT NULL DEFAULT 0, updated_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (bank_report_config_id), UNIQUE KEY uk_ops_bank_report_configs_bank (workspace_id, bank_id), KEY idx_ops_bank_report_configs_due ( workspace_id, enabled, report_frequency, output_day ), CONSTRAINT chk_ops_bank_report_output_day CHECK (output_day BETWEEN 1 AND 28) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='按银行设置报告频率、输出日期与提醒时间'; + +-- ===== 50_workflows_documents_usage.sql ===== +CREATE TABLE IF NOT EXISTS ops_workflows ( workflow_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', request_no VARCHAR(64) NOT NULL, workflow_title VARCHAR(255) NOT NULL, bank_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_banks', category_code VARCHAR(32) NOT NULL COMMENT '逻辑引用 ops_model_categories', model_instance_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_instances', request_type VARCHAR(32) NOT NULL COMMENT 'new/iterate/rebuild', development_source VARCHAR(32) NOT NULL DEFAULT 'independent' COMMENT 'independent/common_reuse', common_source_model_id CHAR(26) NULL COMMENT '逻辑引用通用模型实例', current_stage SMALLINT NOT NULL DEFAULT 1, workflow_status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/completed/cancelled', initiated_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', initiated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), expected_feedback_at DATETIME(3) NULL, planned_test_date DATE NULL, planned_online_date DATE NULL, completed_at DATETIME(3) NULL, cancelled_at DATETIME(3) NULL, state_version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (workflow_id), UNIQUE KEY uk_ops_workflows_request_no (workspace_id, request_no), KEY fk_ops_workflows_bank (bank_id), KEY fk_ops_workflows_model (model_instance_id), KEY fk_ops_workflows_common_source (common_source_model_id), KEY idx_ops_workflows_filters ( workspace_id, workflow_status, category_code, current_stage, initiated_at ), CONSTRAINT chk_ops_workflows_stage CHECK (current_stage BETWEEN 1 AND 8) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='模型新增、迭代或通用模型复用的全流程实例'; +CREATE TABLE IF NOT EXISTS ops_workflow_stages ( workflow_stage_id CHAR(26) NOT NULL, workflow_id CHAR(26) NOT NULL COMMENT '逻辑引用 ops_workflows', stage_no SMALLINT NOT NULL, stage_name_snapshot VARCHAR(100) NOT NULL, stage_status VARCHAR(24) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/waiting_confirmation/completed/skipped', owner_role_code VARCHAR(64) NOT NULL COMMENT '登录角色代码快照', planned_at DATETIME(3) NULL, due_at DATETIME(3) NULL, started_at DATETIME(3) NULL, completed_at DATETIME(3) NULL, completed_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', confirmation_required TINYINT(1) NOT NULL DEFAULT 0, confirmation_status VARCHAR(24) NULL COMMENT 'pending/confirmed/rejected/not_required', confirmed_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', confirmed_at DATETIME(3) NULL, stage_note VARCHAR(2000) NULL, state_version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (workflow_stage_id), UNIQUE KEY uk_ops_workflow_stages_no (workflow_id, stage_no), KEY fk_ops_workflow_stages_workflow (workflow_id), KEY idx_ops_workflow_stages_due (stage_status, due_at), CONSTRAINT chk_ops_workflow_stages_no CHECK (stage_no BETWEEN 1 AND 7) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='七阶段流程节点实例与确认留痕'; +CREATE TABLE IF NOT EXISTS ops_documents ( document_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', workflow_id CHAR(26) NULL COMMENT '逻辑引用 ops_workflows', workflow_stage_id CHAR(26) NULL COMMENT '逻辑引用 ops_workflow_stages', model_instance_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_instances', model_version_id CHAR(26) NULL COMMENT '逻辑引用 ops_model_versions', document_type VARCHAR(64) NOT NULL, document_name VARCHAR(255) NOT NULL, document_version VARCHAR(64) NULL, source_type VARCHAR(16) NOT NULL COMMENT 'storage/link', storage_object_id CHAR(26) NULL COMMENT '逻辑引用 model_platform.storage_objects', external_url VARCHAR(1500) NULL, uploaded_by CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), confirmation_required TINYINT(1) NOT NULL DEFAULT 0, confirmation_status VARCHAR(24) NULL, confirmed_by CHAR(26) NULL COMMENT '逻辑引用 model_platform.users', confirmed_at DATETIME(3) NULL, metadata_json JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (document_id), KEY fk_ops_documents_workflow (workflow_id), KEY fk_ops_documents_stage (workflow_stage_id), KEY fk_ops_documents_model (model_instance_id), KEY fk_ops_documents_version (model_version_id), KEY fk_ops_documents_storage (storage_object_id), KEY idx_ops_documents_knowledge ( workspace_id, document_type, model_instance_id, uploaded_at ), CONSTRAINT chk_ops_documents_source CHECK ( (source_type = 'storage' AND storage_object_id IS NOT NULL) OR (source_type = 'link' AND external_url IS NOT NULL) ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='流程材料和文档知识库索引;文件本体复用平台对象存储'; +CREATE TABLE IF NOT EXISTS ops_usage_events ( usage_event_id CHAR(26) NOT NULL, workspace_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.workspaces', user_id CHAR(26) NOT NULL COMMENT '逻辑引用 model_platform.users', role_code_snapshot VARCHAR(64) NULL, event_type VARCHAR(64) NOT NULL COMMENT 'login/request_submit/report_read/report_download', target_type VARCHAR(64) NULL, target_id VARCHAR(128) NULL, event_metadata JSON NULL, occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), is_deleted TINYINT(1) NOT NULL DEFAULT 0, deleted_at DATETIME(3) NULL, PRIMARY KEY (usage_event_id), KEY idx_ops_usage_events_stats ( workspace_id, occurred_at, event_type, user_id ), KEY idx_ops_usage_events_target (target_type, target_id, occurred_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运维平台登录、需求和报告行为事件'; diff --git a/docs/database/reference/model_platform-schema-20260831.sql b/docs/database/reference/model_platform-schema-20260831.sql new file mode 100644 index 0000000..9fa60b6 --- /dev/null +++ b/docs/database/reference/model_platform-schema-20260831.sql @@ -0,0 +1,544 @@ +create table model_platform.consumer_inbox +( + consumer_name varchar(128) not null, + event_id char(26) not null, + process_status varchar(16) default 'processing' not null comment 'processing/succeeded/failed', + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + message_id varchar(128) null comment 'Inbox message ID', + processed_at datetime(3) null, + error_message varchar(2000) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + primary key (consumer_name, event_id) +) + comment '消费者幂等 Inbox,防止 Stream 重投导致重复执行'; + +create index idx_consumer_inbox_status + on model_platform.consumer_inbox (consumer_name, process_status, created_at); + +create table model_platform.data_resources +( + resource_id char(26) not null + primary key, + workspace_id char(26) not null, + storage_object_id char(26) not null, + owner_user_id char(26) not null, + resource_name varchar(255) not null, + visibility varchar(16) default 'private' not null, + status varchar(16) default 'active' not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + description varchar(1000) null, + schema_json json null comment '字段结构、行数等可选元数据', + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null +) + comment '数据资源'; + +create index idx_data_resources_owner + on model_platform.data_resources (owner_user_id, status); + +create index idx_data_resources_workspace + on model_platform.data_resources (workspace_id, visibility, status); + +create table model_platform.outbox_events +( + event_id char(26) not null + primary key, + aggregate_type varchar(64) not null, + aggregate_id varchar(128) not null, + event_type varchar(128) not null, + schema_version smallint default 1 not null, + payload_json json not null, + event_status varchar(16) default 'pending' not null comment 'pending/published/failed', + available_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + retry_count int default 0 not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + trace_id varchar(64) null, + idempotency_key varchar(128) null, + published_at datetime(3) null, + last_error varchar(2000) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null +) + comment '事务 Outbox;提交后发布到内部事件总线'; + +create index idx_outbox_aggregate + on model_platform.outbox_events (aggregate_type, aggregate_id, created_at); + +create index idx_outbox_idempotency + on model_platform.outbox_events (idempotency_key); + +create index idx_outbox_pending + on model_platform.outbox_events (event_status, available_at, created_at); + +create table model_platform.permissions +( + permission_id char(26) not null + primary key, + permission_code varchar(128) not null, + permission_name varchar(100) not null, + module_code varchar(64) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + description varchar(500) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_permissions_code + unique (permission_code) +) + comment '权限点'; + +create index idx_permissions_module + on model_platform.permissions (module_code); + +create table model_platform.role_permissions +( + role_id char(26) not null, + permission_id char(26) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + primary key (role_id, permission_id) +) + comment '角色权限'; + +create index fk_role_permissions_permission + on model_platform.role_permissions (permission_id); + +create table model_platform.roles +( + role_id char(26) not null + primary key, + role_code varchar(64) not null, + role_name varchar(100) not null, + role_scope varchar(16) not null comment 'platform/workspace', + is_builtin tinyint(1) default 0 not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + description varchar(500) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_roles_code + unique (role_code) +) + comment '角色'; + +create table model_platform.schedule_edges +( + edge_id char(26) not null + primary key, + schedule_id char(26) not null, + source_node_id char(26) not null, + target_node_id char(26) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + condition_expr varchar(1000) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_schedule_edges_pair + unique (schedule_id, source_node_id, target_node_id) +) + comment 'DAG 有向边'; + +create index fk_schedule_edges_source + on model_platform.schedule_edges (source_node_id); + +create index idx_schedule_edges_target + on model_platform.schedule_edges (target_node_id); + +create table model_platform.schedule_node_runs +( + node_run_id char(26) not null + primary key, + run_id char(26) not null, + node_id char(26) not null, + versions_id char(26) not null, + attempt_no int default 1 not null, + node_status varchar(24) default 'queued' not null comment 'queued/running/succeeded/failed/skipped/cancelled/timed_out', + state_version int default 0 not null comment '乐观锁版本', + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + started_at datetime(3) null, + finished_at datetime(3) null, + duration_ms bigint null, + exit_code int null, + message varchar(2000) null, + metrics_json json null, + logs_object_id char(26) null, + result_object_id char(26) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_node_runs_attempt + unique (run_id, node_id, attempt_no) +) + comment '调度节点运行与重试'; + +create index fk_node_runs_logs + on model_platform.schedule_node_runs (logs_object_id); + +create index fk_node_runs_node + on model_platform.schedule_node_runs (node_id); + +create index fk_node_runs_result + on model_platform.schedule_node_runs (result_object_id); + +create index idx_node_runs_status + on model_platform.schedule_node_runs (run_id, node_status); + +create index idx_node_runs_version + on model_platform.schedule_node_runs (versions_id); + +create table model_platform.schedule_nodes +( + node_id char(26) not null + primary key, + schedule_id char(26) not null, + node_key varchar(64) not null comment '画布内稳定标识', + node_name varchar(255) not null, + versions_id char(26) not null, + python_version varchar(8) default '3.12' not null comment '节点执行 Python 版本(3.8/3.10/3.12)', + timeout_seconds int default 600 not null, + retry_count int default 0 not null, + retry_interval_sec int default 5 not null, + position_x decimal(10, 2) default (0.00) not null, + position_y decimal(10, 2) default (0.00) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + arguments_json json null, + env_refs_json json null comment '只存密钥引用,不存明文密钥', + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_schedule_nodes_key + unique (schedule_id, node_key) +) + comment 'DAG 节点,必须引用稳定版本'; + +create index idx_schedule_nodes_version + on model_platform.schedule_nodes (versions_id); + +create table model_platform.schedule_runs +( + run_id char(26) not null + primary key, + schedule_id char(26) not null, + workspace_id char(26) not null, + workflow_version int not null, + trigger_type varchar(16) not null comment 'manual/cron/api/retry', + idempotency_key varchar(128) not null, + run_status varchar(24) default 'queued' not null comment 'queued/running/succeeded/failed/cancelled/timed_out', + state_version int default 0 not null comment '乐观锁版本', + schedule_snapshot json not null comment '执行时 DAG 快照', + queued_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + triggered_by char(26) null, + started_at datetime(3) null, + finished_at datetime(3) null, + duration_ms bigint null, + error_code varchar(64) null, + error_message text null, + logs_object_id char(26) null, + result_object_id char(26) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_schedule_runs_idempotency + unique (idempotency_key) +) + comment '调度运行'; + +create index fk_schedule_runs_logs + on model_platform.schedule_runs (logs_object_id); + +create index fk_schedule_runs_result + on model_platform.schedule_runs (result_object_id); + +create index fk_schedule_runs_user + on model_platform.schedule_runs (triggered_by); + +create index idx_schedule_runs_schedule + on model_platform.schedule_runs (schedule_id, created_at); + +create index idx_schedule_runs_status + on model_platform.schedule_runs (run_status, queued_at); + +create index idx_schedule_runs_workspace_status + on model_platform.schedule_runs (workspace_id, run_status, queued_at); + +create table model_platform.schedules +( + schedule_id char(26) not null + primary key, + workspace_id char(26) not null, + schedule_name varchar(255) not null, + trigger_type varchar(16) default 'cron' not null comment 'manual/cron/api', + timezone varchar(64) default 'Asia/Shanghai' not null, + enabled tinyint(1) default 0 not null, + workflow_version int default 1 not null, + max_concurrency int default 1 not null, + failure_policy varchar(24) default 'stop' not null comment 'stop/continue', + created_by char(26) not null, + updated_by char(26) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + description varchar(1000) null, + cron_expression varchar(128) null, + last_run_at datetime(3) null, + next_run_at datetime(3) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null +) + comment '调度方案'; + +create index fk_schedules_created_by + on model_platform.schedules (created_by); + +create index fk_schedules_updated_by + on model_platform.schedules (updated_by); + +create index idx_schedules_due + on model_platform.schedules (enabled, next_run_at); + +create index idx_schedules_workspace + on model_platform.schedules (workspace_id, enabled, updated_at); + +create table model_platform.scripts +( + script_id char(26) not null + primary key, + workspace_id char(26) not null, + current_object_id char(26) not null comment '当前工作副本', + owner_user_id char(26) not null, + script_name varchar(255) not null, + script_type varchar(16) not null comment 'python/notebook', + visibility varchar(16) default 'private' not null, + status varchar(16) default 'active' not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + is_deleted tinyint(1) default 0 not null, + is_locked tinyint(1) default 1 not null, + deleted_at datetime(3) null +) + comment '可执行 Python/Notebook 脚本'; + +create index idx_scripts_owner + on model_platform.scripts (owner_user_id, status); + +create index idx_scripts_workspace + on model_platform.scripts (workspace_id, script_type, visibility, status); + +create table model_platform.storage_objects +( + storage_object_id char(26) not null + primary key, + workspace_id char(26) not null, + object_type varchar(16) not null comment 'file/directory', + usage_type varchar(32) not null comment 'working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result', + storage_backend varchar(16) not null comment 's3', + storage_uri varchar(1500) not null, + file_name varchar(255) not null, + size_bytes bigint default 0 not null, + visibility varchar(16) default 'private' not null comment 'private/workspace/public', + is_immutable tinyint(1) default 0 not null, + object_status varchar(24) default 'available' not null comment 'uploading/available/deleting/deleted/failed', + created_by char(26) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + owner_user_id char(26) null, + relative_path varchar(1024) null comment 'Workspace 相对路径', + path_hash binary(32) null comment 'SHA-256(relative_path),由应用写入', + bucket_name varchar(128) null, + object_key varchar(1024) null, + object_key_hash binary(32) null comment 'SHA-256(object_key),由应用写入', + object_key_hash_active binary(32) as ((case + when (`object_status` = _utf8mb4'available') then `object_key_hash` + else NULL end)) comment 'VIRTUAL generated column used by uk_storage_bucket_key_active', + file_extension varchar(32) null, + mime_type varchar(255) null, + content_hash char(64) null comment 'SHA-256 hex', + object_etag varchar(255) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + trash_key varchar(1100) null comment 'Path inside the trash bucket where the soft-deleted bytes live. Format: ''{source_bucket}/{object_key}'' so a restore is a same-key copy back to the source bucket. NULL while the row is still available.', + constraint uk_storage_bucket_key_active + unique (storage_backend, bucket_name, object_key_hash_active) +) + comment 'Workspace 文件和 RustFS 对象的统一元数据;目录树走 materialized path (relative_path),不要 join 邻接表列——已删除。'; + +create index fk_storage_created_by + on model_platform.storage_objects (created_by); + +create index idx_storage_content_hash + on model_platform.storage_objects (content_hash); + +create index idx_storage_owner + on model_platform.storage_objects (owner_user_id, object_status); + +create index idx_storage_workspace_path + on model_platform.storage_objects (workspace_id, storage_backend, path_hash); + +create index idx_storage_workspace_relative_path + on model_platform.storage_objects (workspace_id, relative_path(255)); + +create index idx_storage_workspace_usage + on model_platform.storage_objects (workspace_id, usage_type, object_status); + +create table model_platform.upload_sessions +( + upload_id char(26) not null + primary key, + workspace_id char(26) not null, + user_id char(26) not null, + idempotency_key varchar(128) not null, + bucket_name varchar(128) not null, + object_key varchar(1024) not null, + object_key_hash binary(32) not null, + upload_status varchar(24) default 'created' not null comment 'created/uploading/completed/expired/aborted/failed', + expires_at datetime(3) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + multipart_upload_id varchar(255) null, + expected_size_bytes bigint null, + expected_hash char(64) null, + content_type varchar(255) null, + storage_object_id char(26) null, + completed_at datetime(3) null, + file_name varchar(255) default '' not null, + usage_type varchar(32) default 'working_copy' not null comment 'data_resource/version_artifact/snapshot/run_log/run_result/working_copy/public_script', + visibility varchar(16) default 'private' not null comment 'private/workspace/public', + is_immutable tinyint(1) default 0 not null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_upload_sessions_idempotency + unique (idempotency_key) +) + comment 'RustFS 预签名上传会话;URL 本身不持久化'; + +create index fk_upload_sessions_storage_object + on model_platform.upload_sessions (storage_object_id); + +create index fk_upload_sessions_user + on model_platform.upload_sessions (user_id); + +create index idx_upload_sessions_expiry + on model_platform.upload_sessions (upload_status, expires_at); + +create index idx_upload_sessions_object_key + on model_platform.upload_sessions (bucket_name, object_key_hash); + +create index idx_upload_sessions_workspace + on model_platform.upload_sessions (workspace_id, user_id, created_at); + +create table model_platform.users +( + user_id char(26) not null + primary key, + username varchar(64) not null, + display_name varchar(100) not null, + password_hash varchar(255) not null, + status varchar(16) default 'active' not null comment 'active/disabled/locked', + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + email varchar(255) null, + platform_role_id char(26) null, + avatar_uri varchar(1000) null, + last_login_at datetime(3) null, + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_users_email + unique (email), + constraint uk_users_username + unique (username) +) + comment '平台用户'; + +create index fk_users_platform_role + on model_platform.users (platform_role_id); + +create index idx_users_status + on model_platform.users (status); + +create table model_platform.versions +( + versions_id char(26) not null comment '稳定版本唯一 ID' + primary key, + workspace_id char(26) not null, + script_id char(26) not null, + source_object_id char(26) not null comment '发布时的源对象', + artifact_object_id char(26) not null comment 'S3 不可变版本制品', + version_no int not null, + version_label varchar(32) not null comment '例如 v1.0', + source_path varchar(1024) not null comment '发布时路径快照', + artifact_path varchar(1500) not null, + content_hash char(64) not null, + file_size_bytes bigint default 0 not null, + visibility varchar(16) default 'private' not null, + created_by char(26) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + release_note varchar(1000) null, + schedule_hidden_at datetime(3) null comment '从调度稳定版本列表移除的时间;不影响版本和运行历史', + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_versions_artifact + unique (artifact_object_id), + constraint uk_versions_script_hash + unique (script_id, content_hash), + constraint uk_versions_script_no + unique (script_id, version_no) +) + comment '不可变稳定版本;调度节点必须引用 versions_id'; + +create index fk_versions_source_object + on model_platform.versions (source_object_id); + +create index idx_versions_creator + on model_platform.versions (created_by, created_at); + +create index idx_versions_workspace_created + on model_platform.versions (workspace_id, created_at); + +create table model_platform.workspace_members +( + workspace_id char(26) not null, + user_id char(26) not null, + role_id char(26) not null, + member_status varchar(16) default 'active' not null, + joined_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + primary key (workspace_id, user_id) +) + comment 'Workspace 成员与角色'; + +create index idx_workspace_members_role + on model_platform.workspace_members (role_id); + +create index idx_workspace_members_user + on model_platform.workspace_members (user_id, member_status); + +create table model_platform.workspaces +( + workspace_id char(26) not null + primary key, + workspace_code varchar(64) not null, + workspace_name varchar(150) not null, + active_root_uri varchar(1500) not null comment '活动工作区,建议 NFS/PVC/file URI', + quota_bytes bigint default 0 not null comment '0 表示不限额', + used_bytes bigint default 0 not null, + status varchar(24) default 'active' not null comment 'creating/active/suspended/deleting/deleted', + created_by char(26) not null, + created_at datetime(3) default CURRENT_TIMESTAMP(3) not null, + updated_at datetime(3) default CURRENT_TIMESTAMP(3) not null on update CURRENT_TIMESTAMP(3), + description varchar(1000) null, + artifact_bucket varchar(128) null comment 'S3 bucket', + artifact_prefix varchar(512) null comment 'S3 object key prefix', + is_deleted tinyint(1) default 0 not null, + deleted_at datetime(3) null, + constraint uk_workspaces_code + unique (workspace_code) +) + comment 'Workspace'; + +create index fk_workspaces_created_by + on model_platform.workspaces (created_by); + +create index idx_workspaces_status + on model_platform.workspaces (status); + diff --git a/docs/项目推进/A卡运维模块-三角色权限验收说明-V0.1.md b/docs/项目推进/A卡运维模块-三角色权限验收说明-V0.1.md new file mode 100644 index 0000000..e9b7234 --- /dev/null +++ b/docs/项目推进/A卡运维模块-三角色权限验收说明-V0.1.md @@ -0,0 +1,65 @@ +# A 卡模型运维模块三角色权限验收说明 V0.1 + +> 状态:前后端权限接入完成,等待真实账号执行验收 +> 日期:2026-09-02 + +## 一、权限来源 + +1. 当前用户登录后通过 `/api/v1/auth/me` 返回 `role_code` 和 `permissions`。 +2. `/api/v1/platform/permissions` 只用于管理员维护权限目录,不作为当前用户权限来源。 +3. 运维前端菜单、页面直达和操作按钮读取 `/auth/me` 的权限集合。 +4. 运维后端在 `OperationsContext` 中加载同一权限集合,并对已实现接口再次校验。 +5. 管理员由服务端管理员身份兜底拥有全部运维权限。 + +## 二、角色约定 + +- `admin`:管理员。 +- `developer`:模型团队。 +- `business_team`:业务团队。 + +开发工作台使用 `dashboard:view`;运维工作台使用 `operations:workbench:view`。业务团队不得拥有 `dashboard:view`。 + +## 三、页面验收 + +### 业务团队 + +可以访问:运维工作台、平台模型大类、细分银行、监控概览、监控详情、监控诊断报告、历史报告汇总和全流程进度。 + +可以操作:监控详情终审、报告导出、流程发起、流程反馈和流程确认。 + +不能访问:开发工作台、平台使用统计、已上线模型详情、文档知识库、监控等级规则、报告 Prompt 管理和系统配置。 + +### 模型团队 + +可以访问:开发工作台、运维工作台、模型大类、细分银行、已上线模型详情、监控概览、监控详情、监控诊断报告、历史报告汇总、全流程进度、文档知识库、监控等级规则和报告 Prompt 管理。 + +可以操作:监控详情初审、报告编辑/导出/发送、流程提交材料/推进/上线、规则只读、Prompt 编辑和提交回归。 + +不能访问:平台使用统计和系统配置。 + +### 管理员 + +可以访问全部开发、运维和系统管理页面。 + +可以操作全部运维配置、规则发布/回滚/测算、Prompt 管理、用户管理、项目管理和角色权限管理。 + +## 四、验收用例 + +1. 使用业务账号登录,确认 `/auth/me` 返回 `business_team`,菜单不出现开发工作台。 +2. 业务账号直接访问 `/workbench`,应跳转到 `/operations`。 +3. 业务账号直接访问 `/operations/deployed-models`,应返回无权限页面或被路由拦截。 +4. 业务账号打开模型监控详情,只出现终审处理能力,不出现初审能力。 +5. 使用模型账号登录,确认 `developer` 映射为模型团队,开发工作台和模型侧操作可用。 +6. 模型账号打开监控详情,只出现初审处理能力,不出现终审能力。 +7. 模型账号打开规则页面,只读查看,不能发布、回滚或测算。 +8. 使用管理员账号登录,确认全部页面和全部管理操作可用。 +9. 删除或软删除某个权限关联后,重新登录确认菜单和后端接口同时拒绝访问。 +10. 三种角色分别请求无权限接口,后端返回 `403 OPERATIONS_PERMISSION_DENIED`。 + +## 五、执行前提 + +1. 在 `model_platform` 执行角色权限矩阵 SQL。 +2. 给业务账号分配 `business_team` 角色。 +3. 撤销业务团队的 `dashboard:view`。 +4. 三个账号退出后重新登录,重新加载权限。 +5. 记录菜单截图、`/auth/me` 响应和接口状态码作为验收附件。 diff --git a/docs/项目推进/A卡运维模块-数据库外部写入协议-V0.1.md b/docs/项目推进/A卡运维模块-数据库外部写入协议-V0.1.md new file mode 100644 index 0000000..dfc831c --- /dev/null +++ b/docs/项目推进/A卡运维模块-数据库外部写入协议-V0.1.md @@ -0,0 +1,144 @@ +# A 卡模型运维模块数据库外部写入协议 V0.1 + +> 状态:模型方评估稿;首条接口链路按本协议准备数据 +> 日期:2026-09-02 +> 目标:明确模型方写入哪些表,运维平台读取哪些字段,哪些字段由运维平台自行生成 + +## 一、当前首条链路的实际数据来源 + +当前后端首条链路不是读取 `ops_banks/ops_model_instances/ops_model_versions`,而是: + +1. 模型部署和版本信息从 `model_deploy` 库读取。 +2. 月度监控批次和监控结果从 `model_operations` 库读取。 +3. 判级、复核和处理快照从 `model_operations` 库读取。 + +因此,模型方需要先按现有部署库表写入模型基础信息,再按运维库监控源表写入月度结果。`ops_banks`、`ops_model_instances`、`ops_model_versions` 如继续保留,应在后续确认后再接入,不能与当前链路同时作为模型主源。 + +## 二、模型方负责写入的部署库表 + +### 2.1 `model_deploy` + +至少需要提供: + +- `id`:部署实例主键。 +- `code`:业务模型 ID,建议 Workspace 内唯一。 +- `name`:模型名称。 +- `modelType`:模型类型。 +- `status`:部署状态;当前接口映射为正常/下线。 +- `role`:部署角色;陪跑状态需要与现有枚举确认。 +- `currentVersion`:当前版本号。 +- `group_code`:Workspace 或模型大类映射信息。 +- `space_id`:关联 `sys_project_space.id`。 +- `gmtCreated`、`gmtModified`:创建和最后变更时间。 + +### 2.2 `model_bank` + +- `bankNo`:银行编码。 +- `bankName`:银行名称。 +- `isWuji`:是否无极银行。 +- `status`:银行是否有效。 + +### 2.3 `model_deploy_bank_map` + +- `deployId`:对应 `model_deploy.id`。 +- `bankNo`:对应 `model_bank.bankNo`。 +- `status`:关系是否有效。 + +### 2.4 `model_version` + +- `id`:版本主键。 +- `deployId`:对应部署实例。 +- `version`:版本号。 +- `versionName`:版本展示名称。 +- `isCurrent`:是否当前版本。 +- `gmtCreated`、`gmtModified`:版本时间。 + +### 2.5 Workspace 映射 + +模型部署记录至少需要满足以下一种映射: + +- `sys_project_space.name = 认证 Workspace 名称`; +- `sys_project_space.name = 认证 Workspace code`; +- `model_deploy.group_code = 认证 Workspace code`。 + +最终映射方式需要模型方和运维方共同确认。 + +## 三、模型方负责写入的运维库源表 + +### 3.1 `ops_monitor_batches` + +模型方每次生成一个月度批次,至少写入: + +- `batch_id`:批次 ID。 +- `workspace_id`:认证 Workspace ID。 +- `source_system`:来源系统。 +- `source_batch_no`:来源批次号,必须幂等。 +- `monitor_month`:监控月份,保存为该月第一天。 +- `revision_no`:同月修订号,从 1 开始递增。 +- `batch_status`:先写 `writing`,校验完成后改为 `published`。 +- `expected_model_count`、`written_model_count`:模型数量校验。 +- `feature_row_count`、`distribution_row_count`:明细行数校验。 +- `checksum_sha256`、`generated_at`、`published_at`。 + +只有 `published` 批次对运维页面可见。 + +### 3.2 `ops_monitor_results` + +每个模型每个月一行,至少写入: + +- `monitor_result_id`:结果 ID。 +- `workspace_id`:必须与批次和认证 Workspace 一致。 +- `batch_id`:所属批次。 +- `model_instance_id`:建议写 `model_deploy.id`,以便当前接口直接关联。 +- `model_version_id`:对应 `model_version.id`。 +- `monitor_month`:监控月份。 +- `source_result_ref`:来源结果编号,可辅助追溯。 +- `ranking_result`:`matched`、`unmatched` 或 `not_applicable`。 +- `ks_value`、`psi_value`:数据库保存 0~1 比率。 +- `sample_count`、`good_count`、`bad_count`。 +- `source_result_json`、`source_calculated_at`:可选追溯字段。 + +当前查询服务兼容用 `model_deploy.id`、`model_deploy.code` 或版本 ID 作为关联标识,但生产环境必须固定一种主规则。 + +### 3.3 `ops_monitor_feature_metrics` + +后续特征级页面需要时写入:特征编码、名称、IV、CSI、前期值、变化率以及来源 JSON。该表不用于首条三页面链路的最低验收。 + +### 3.4 `ops_monitor_distributions` + +后续评分分布和特征分布页面需要时写入:分箱顺序、分箱标签、基准期/当期账户数和占比、好坏账户数、坏账率、PSI/CSI 分量。该表不用于首条三页面链路的最低验收。 + +## 四、运维平台自行生成的数据 + +以下数据不由模型方写入: + +1. A/B/C 监控结果等级。 +2. 一级/二级/三级异常等级。 +3. 命中原因、处理建议和阈值解释。 +4. 模型团队初审、业务团队终审和默认不处理记录。 +5. 报告正文、报告版本、Prompt 版本和发送状态。 +6. 流程进度、材料确认、知识库索引和平台使用统计。 + +模型方只负责提供原始指标及其追溯信息,不能直接写入判级和处理结果字段。 + +## 五、发布协议 + +1. 创建 `writing` 批次。 +2. 写入模型月度结果和后续明细。 +3. 校验模型数量、结果数量、指标范围、Workspace 和关联 ID。 +4. 完成校验后将批次改为 `published`。 +5. 同月旧批次改为 `superseded`,不覆盖历史数据。 +6. 失败批次保留 `failed` 状态并记录原因。 + +## 六、最小联调数据 + +首条链路只需要: + +- 1 条有效银行。 +- 1 条有效模型部署。 +- 1 条有效银行关系。 +- 1 条有效模型版本。 +- 1 条 `published` 监控批次。 +- 1 条对应月份的监控结果。 + +如果需要展示等级和处理状态,再补 1 条当前判级记录及对应复核记录。 diff --git a/docs/项目推进/A卡运维模块-本周目标完成情况-V0.1.md b/docs/项目推进/A卡运维模块-本周目标完成情况-V0.1.md new file mode 100644 index 0000000..6ca4793 --- /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. **三角色真实账号验收**:权限代码和 SQL 已准备完成,还需要用业务、模型和管理员账号实际验证菜单、页面、按钮和接口。 + +## 四、现在可以先写的文档 + +以下内容已先完成初稿,不依赖真实非空数据: + +1. **技术架构与实现说明 V0.3**:已完成,标出当前双数据库、FastAPI、React 和真实接口边界。 +2. **数据库外部写入协议 V0.1**:已完成,列出模型方写入表、字段、批次发布和关联要求。 +3. **运维 API 清单 V0.1**:已完成,固化已实现的 5 个接口并列出规划接口。 +4. **三角色登录/RBAC 验收说明 V0.1**:已完成,列出角色、菜单、页面、接口和操作验收项。 +5. **首条纵向链路联调手册 V0.1**:已完成,写清测试数据、请求顺序、预期响应和核对点。 +6. **本周目标验收记录 V0.1**:已完成基线,待非空数据后补实际请求和截图结果。 + +## 五、建议本周剩余顺序 + +1. 先让模型方补一套最小非空数据,不等待全部页面完成。 +2. 先完成列表—详情—单月结果的真实联调和数值核对。 +3. 同时用三种角色做登录、菜单和接口权限验收。 +4. 根据联调结果补充本周目标验收记录,不再重复调整已冻结的基础文档。 +5. 在首条链路验收后扩展工作台、监控明细、报告和流程等页面的接口。 + +## 六、当前不可对外承诺的内容 + +在非空数据和三角色验收完成前,不建议对外表述为“首条真实接口链路已完整跑通”。更准确的表述是: + +> 已完成首条真实接口链路的工程实现、接口注册、前端接入和空数据验证;待模型方提供最小非空数据后完成业务数据联调验收。 diff --git a/docs/项目推进/A卡运维模块-运维API清单-V0.1.md b/docs/项目推进/A卡运维模块-运维API清单-V0.1.md new file mode 100644 index 0000000..6a37930 --- /dev/null +++ b/docs/项目推进/A卡运维模块-运维API清单-V0.1.md @@ -0,0 +1,132 @@ +# A 卡模型运维模块运维 API 清单 V0.1 + +> 状态:首条真实接口链路已实现;其余业务接口列为后续实现项 +> 日期:2026-09-02 +> 鉴权:复用登录 Cookie;需要 Workspace 的接口必须传 `workspace_id` + +## 一、统一约定 + +1. 浏览器请求使用同源 `/api/v1/*`,Cookie 由浏览器自动携带。 +2. 成功响应统一使用 `{ request_id, data, meta }` 包装。 +3. `workspace_id` 为 26 位 Workspace ID;后端同时校验登录身份、Workspace 成员关系和运维权限。 +4. 月份参数格式固定为 `YYYY-MM`,数据库月份使用当月第一天保存。 +5. 数据库中的 KS、PSI、IV、CSI 等比率按 0~1 保存,API 返回前端展示百分数。 +6. 没有数据时返回空数组或业务层空结果,不使用演示数据填充页面。 + +## 二、已实现接口 + +### 2.1 运维健康检查 + +`GET /api/v1/operations/health` + +- 用途:检查运维库、部署只读库和关键表状态。 +- 鉴权:服务健康检查接口,是否要求登录以部署配置为准。 +- 返回:服务状态、数据库状态、关键表检查结果。 + +### 2.2 模型列表 + +`GET /api/v1/operations/models` + +查询参数: + +- `workspace_id`:必填。 +- `bank`:可选,银行名称。 +- `category`:可选,`std`、`bai`、`big`、`afd`。 +- `status`:可选,模型状态。 +- `keyword`:可选,匹配银行、模型名称、模型 ID 或版本。 + +权限:`operations:model-overview:view`。 + +数据来源: + +- `model_deploy.model_deploy` +- `model_deploy.model_bank` +- `model_deploy.model_deploy_bank_map` +- `model_deploy.model_version` +- `model_operations.ops_monitor_batches` +- `model_operations.ops_monitor_results` +- 当前判级和处理快照表(存在时读取) + +返回字段包括:银行、模型大类、模型名称、模型 ID、模型版本、模型状态、最近迭代日期、排序性、KS、PSI、KS 环比降幅、异常命中次数、最近处理日期和历史处理建议。 + +### 2.3 模型详情 + +`GET /api/v1/operations/models/{model_id}` + +查询参数: + +- `workspace_id`:必填。 + +权限:`operations:monitoring-detail:view`。 + +模型 ID 当前支持使用 `model_deploy.code`;后端同时兼容部署表主键和版本关联标识。模型不存在返回: + +```json +{ + "detail": { + "code": "MODEL_NOT_FOUND", + "message": "模型不存在" + } +} +``` + +### 2.4 单月监控结果 + +`GET /api/v1/operations/models/{model_id}/monitor-results` + +查询参数: + +- `workspace_id`:必填。 +- `month`:必填,格式 `YYYY-MM`。 + +权限:`operations:monitoring-detail:view`。 + +返回字段:模型实例 ID、监控月份、排序性、KS、PSI、KS 环比降幅和近 6 个月二级异常命中次数。 + +错误码: + +- `INVALID_MONITOR_MONTH`:月份格式不正确。 +- `MODEL_NOT_FOUND`:模型不存在。 +- `MONITOR_RESULT_NOT_FOUND`:该月份没有已发布监控结果。 +- `OPERATIONS_SCHEMA_NOT_READY`:关键数据表未完成初始化。 + +### 2.5 运维工作台 + +`GET /api/v1/operations/workbench` + +查询参数: + +- `workspace_id`:必填。 + +权限:`operations:workbench:view`。 + +返回工作台的提醒、KPI、我的待办、我的关注和最近动态。空数据时各数组为空,KPI 数值为 0。 + +## 三、权限与页面映射 + +- `operations:workbench:view`:运维工作台。 +- `operations:usage:view`:平台使用统计。 +- `operations:model-overview:view`:模型大类概览。 +- `operations:bank-overview:view`:细分银行概览。 +- `operations:deployed-models:view`:已上线模型详情。 +- `operations:monitoring-overview:view`:模型监控概览/监控明细。 +- `operations:monitoring-detail:view`:模型监控详情。 +- `operations:report:view`、`operations:report:edit`、`operations:report:export`、`operations:report:send`:监控诊断报告。 +- `operations:report-summary:view`:历史报告汇总。 +- `operations:workflow:view`:全流程进度。 +- `operations:knowledge:view`:文档知识库。 +- `operations:rules:view`、`operations:rules:publish`、`operations:rules:rollback`、`operations:rules:simulate`:监控等级规则。 +- `operations:prompt:view`、`operations:prompt:edit`、`operations:prompt:regression`:报告 Prompt 管理。 +- `operations:settings:view`:运维系统配置。 + +## 四、规划接口 + +后续按页面逐步实现: + +1. 模型大类和银行概览查询接口。 +2. 监控明细多条件筛选、特征指标和评分分布接口。 +3. 模型团队初审、业务团队终审和默认不处理接口。 +4. 报告生成、编辑、发送、历史汇总和文件导出接口。 +5. 流程、阶段材料、知识库、规则、Prompt 和系统配置接口。 + +规划接口必须沿用本文件的鉴权、Workspace、错误码、空数据和百分比单位约定。 diff --git a/docs/项目推进/A卡运维模块-首条纵向链路联调手册-V0.1.md b/docs/项目推进/A卡运维模块-首条纵向链路联调手册-V0.1.md new file mode 100644 index 0000000..1249f05 --- /dev/null +++ b/docs/项目推进/A卡运维模块-首条纵向链路联调手册-V0.1.md @@ -0,0 +1,105 @@ +# A 卡模型运维模块首条纵向链路联调手册 V0.1 + +> 状态:接口和前端调用已完成;真实非空数据到位后即可执行 +> 日期:2026-09-02 + +## 一、联调目标 + +验证以下链路使用同一套模型和监控结果口径: + +```text +model_deploy / model_operations + → GET 模型列表 + → GET 模型详情 + → GET 指定月份监控结果 + → React 页面展示 +``` + +## 二、前置条件 + +1. 后端已配置 `model_platform`、`model_deploy` 和 `model_operations` 三个数据库连接。 +2. 前端 `VITE_OPERATIONS_API_MODE=api`。 +3. 已执行数据库初始化和角色权限 SQL。 +4. 已有一个可访问目标 Workspace 的登录账号。 +5. 模型方按《数据库外部写入协议》提供最小非空数据。 + +## 三、请求顺序 + +以下命令中的 ``、`` 和 `` 替换为实际值。 + +### 3.1 检查服务 + +```bash +curl -i http://127.0.0.1:8010/api/v1/health +curl -i http://127.0.0.1:8010/api/v1/operations/health +``` + +### 3.2 获取当前会话和权限 + +```bash +curl -sS \ + -H 'Cookie: access_token=' \ + http://127.0.0.1:8010/api/v1/auth/me +``` + +确认返回 `role_code`、`permissions` 和目标 Workspace。 + +### 3.3 查询模型列表 + +```bash +curl -sS \ + -H 'Cookie: access_token=' \ + 'http://127.0.0.1:8010/api/v1/operations/models?workspace_id=' +``` + +记录返回的 `model_id`、模型名称、版本和银行。 + +### 3.4 查询模型详情 + +```bash +curl -sS \ + -H 'Cookie: access_token=' \ + 'http://127.0.0.1:8010/api/v1/operations/models/?workspace_id=' +``` + +核对列表与详情的银行、模型名称、模型 ID、版本、KS、PSI 和排序性。 + +### 3.5 查询单月监控结果 + +```bash +curl -sS \ + -H 'Cookie: access_token=' \ + 'http://127.0.0.1:8010/api/v1/operations/models//monitor-results?workspace_id=&month=2026-07' +``` + +核对详情页和单月接口的监控月份、KS、PSI、排序性、环比降幅和异常命中次数。 + +## 四、前端验收路径 + +1. 打开 `/operations/models`。 +2. 从模型列表点击目标模型。 +3. 进入模型监控详情。 +4. 选择目标监控月份。 +5. 检查页面顶部模型信息和单月监控结果。 +6. 打开浏览器 Network,确认三次请求均为 `/api/v1/operations/*`,没有本地 Mock 请求。 + +## 五、空数据验收 + +在模型方数据未到位前,也应确认: + +- 模型列表页面正常展示空列表,不跳转说明页。 +- 工作台 KPI 卡片保留且显示 0。 +- 缺表时返回明确的 `OPERATIONS_SCHEMA_NOT_READY`。 +- 不存在月份返回 `MONITOR_RESULT_NOT_FOUND`,详情页面保持结构。 +- 前端没有硬编码模型、银行、报告和流程示例数据。 + +## 六、最终验收标准 + +真实非空数据到位后,以下内容必须全部满足: + +1. 列表返回至少 1 个模型。 +2. 列表点击可以进入详情。 +3. 详情可以获取指定月份结果。 +4. 三个接口中的模型 ID、版本、月份、KS、PSI、排序性一致。 +5. `published` 批次可见,`writing`、`failed` 和 `superseded` 批次不可作为当前结果展示。 +6. 业务团队、模型团队和管理员分别通过权限验收。 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c5fa16e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Model-platform gateway used by the Vite development proxy. +VITE_GATEWAY_URL=http://127.0.0.1:8890 + +# 运维页面只允许通过后端真实接口取数。 +VITE_OPERATIONS_API_MODE=api diff --git a/frontend/README.md b/frontend/README.md index a3844c1..80505e4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -19,4 +19,27 @@ pnpm install pnpm dev ``` +运维模块只通过后端真实 API 取数;当前本地 `frontend/.env` 配置为: + +```bash +VITE_OPERATIONS_API_MODE=api +``` + +当前运维界面已同步对外原型 V1.11:模型大类与细分银行纵向卡片、银行/同业 +双线趋势、待办/关注拆分、一屏监控明细以及监控诊断报告/历史报告汇总等最新术语。 + +切换后核心纵向链路会请求: + +- `GET /api/v1/operations/models` +- `GET /api/v1/operations/models/{model_id}` +- `GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM` + +运维模块只消费登录接口返回的角色,不维护角色或权限。当前界面映射为: + +- `admin` / `is_system_admin=true` → 管理员 +- `developer` / `model_team` → 模型团队 +- `business` / `business_team` / `biz` → 业务团队 + +菜单和操作按钮按登录角色适配;实际授权由独立权限模块负责。业务团队没有模型开发 Workspace 时,运维域使用“运维全局视图”,不会卡在 Workspace 加载状态。 + 生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。 diff --git a/frontend/app/app.css b/frontend/app/app.css index c3f6cfa..88563d3 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -48,6 +48,26 @@ --spacing-gap-md: 9px; } @theme inline { + /* shadcn 语义色映射:STYLE_GUIDE §2.6 */ + --color-background: var(--color-bg); + --color-foreground: var(--color-ink); + --color-card: var(--color-bg-panel); + --color-card-foreground: var(--color-ink); + --color-popover: var(--color-bg-panel); + --color-popover-foreground: var(--color-ink); + --color-primary: var(--color-brand); + --color-primary-foreground: #ffffff; + --color-secondary: var(--color-line-soft); + --color-secondary-foreground: var(--color-ink); + --color-muted: var(--color-line-soft); + --color-muted-foreground: var(--color-ink-muted); + --color-accent: var(--color-brand-soft); + --color-accent-foreground: var(--color-brand-strong); + --color-destructive: var(--color-danger); + --color-border: var(--color-line); + --color-input: var(--color-line-soft); + --color-ring: var(--color-brand); + --color-sidebar: var(--sidebar-background); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -273,6 +293,35 @@ } } +@media print { + body.printing-operations-report { + min-width: 0; + overflow: visible; + background: var(--color-bg-panel); + } + + body.printing-operations-report * { + visibility: hidden !important; + } + + body.printing-operations-report .operations-report-document, + body.printing-operations-report .operations-report-document * { + visibility: visible !important; + } + + body.printing-operations-report .operations-report-document { + position: absolute; + inset: 0; + width: 100%; + overflow: visible; + box-shadow: none; + } + + body.printing-operations-report [data-slot="button"] { + display: none !important; + } +} + /* Shared UI classes still used by admin / schedules pages */ .icon-button { display: grid; @@ -300,4 +349,4 @@ background: linear-gradient(145deg, #3b92ed, #1869c9); font-size: 14px; font-weight: 700; -} \ No newline at end of file +} diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index c68d154..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); @@ -45,6 +47,11 @@ export function Topbar({ onSetWorkspaceMenuOpen(false); setUserMenuOpen((open) => !open); }; + const roleLabel = user?.is_system_admin || user?.role_code === "admin" + ? "管理员" + : ["business", "business_team", "biz"].includes(user?.role_code ?? "") + ? "业务团队" + : "模型团队"; return (
@@ -52,6 +59,8 @@ export function Topbar({ @@ -145,7 +154,7 @@ export function Topbar({ {user?.display_name ?? "未知用户"} - {user?.role_code === "admin" ? "管理员" : "开发人员"} + {roleLabel} {/* */} diff --git a/frontend/app/components/ui/table.tsx b/frontend/app/components/ui/table.tsx index 24fcd27..c7140d4 100644 --- a/frontend/app/components/ui/table.tsx +++ b/frontend/app/components/ui/table.tsx @@ -2,11 +2,15 @@ import * as React from "react" import { cn } from "~/lib/utils" -function Table({ className, ...props }: React.ComponentProps<"table">) { +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<"table"> & { containerClassName?: string }) { return (
= { 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..2f3d287 100644 --- a/frontend/app/features/admin/UserFormDialog.tsx +++ b/frontend/app/features/admin/UserFormDialog.tsx @@ -1,3 +1,5 @@ +import { useMemo, useState } from "react"; +import { ChevronDown } from "lucide-react"; import type { Employee, Role, Workspace } from "../../services/api"; import { AppFormDialog, @@ -18,7 +20,7 @@ export type UserFormState = { username: string; display_name: string; email: string; - role_code: "admin" | "developer"; + role_code: string; password: string; status: "active" | "disabled" | "locked"; }; @@ -59,6 +61,18 @@ export function UserFormDialog({ onClose: () => void; onResetPassword?: () => void; }) { + const roleOptions = useMemo( + () => (editing ? roles : createRoleOptions), + [editing, roles, createRoleOptions], + ); + const [roleMenuOpen, setRoleMenuOpen] = useState(false); + const roleDisabled = editing + ? editing.user_id === currentUserId || roles.length === 0 + : createRoleOptions.length === 0; + const selectedRole = + roleOptions.find((role) => role.role_code === form.role_code) ?? + roleOptions[0]; + return ( {!editing && ( ; +}; + +function isCategoryId(value: string | null): value is ModelCategoryId { + return MODEL_CATEGORIES.some((category) => category.id === value); +} + +function changeOf(series: number[]): number { + if (series.length < 2) return 0; + return Number(((series.at(-1) ?? 0) - (series.at(-2) ?? 0)).toFixed(2)); +} + +function TrendSummary({ own, peer }: { own: number[]; peer: number[] }) { + const values = [ + ["本行均值", `${(own.at(-1) ?? 0).toFixed(2)}%`, "text-primary"], + ["本行较上月", `${changeOf(own) >= 0 ? "+" : ""}${changeOf(own).toFixed(2)}pp`, "text-primary"], + ["同业均值", `${(peer.at(-1) ?? 0).toFixed(2)}%`, "text-warning"], + ["同业较上月", `${changeOf(peer) >= 0 ? "+" : ""}${changeOf(peer).toFixed(2)}pp`, "text-warning"], + ]; + return
{values.map(([label, value, tone]) =>
{label}{value}
)}
; +} + +function BankDeviationTable({ metric, models, averageValue, onOpenDetail }: { + metric: "KS" | "PSI"; + models: ModelRecord[]; + averageValue: number; + onOpenDetail: (modelId: string) => void; +}) { + const isPsi = metric === "PSI"; + const rows = [...models].filter((model) => isPsi ? model.psi > averageValue : model.ks < averageValue).sort((left, right) => isPsi ? right.psi - left.psi : left.ks - right.ks); + return ( + + {isPsi ? : }{metric} {isPsi ? "高于" : "低于"}同业平均值同业平均 {averageValue.toFixed(2)}% · {rows.length} 条 +
模型 / 版本当月 {metric}等级 / 月份最近处理上次处理建议{rows.length ? rows.map((model) => { const value = isPsi ? model.psi : model.ks; return {model.name}{model.version} · {model.modelId}{value.toFixed(2)}%{isPsi ? "↑" : "↓"} {Math.abs(value - averageValue).toFixed(2)}pp onOpenDetail(model.modelId)} />2026-07{model.processedAt ?? "未处理"}{model.previousAdvice}; }) : 当前没有偏离同业平均值的模型}
+ + ); +} + +export default function BankOverviewPage() { + const navigate = useNavigate(); + const { models } = useOperationsData(); + const [searchParams, setSearchParams] = useSearchParams(); + const trendRef = useRef(null); + const banks = useMemo(() => [...new Set(models.map((model) => model.bank))], [models]); + const initialBank = banks.includes(searchParams.get("bank") ?? "") ? searchParams.get("bank") as string : banks[0] ?? ""; + const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std"; + const [bank, setBank] = useState(initialBank); + const [category, setCategory] = useState(initialCategory); + const [range, setRange] = useState("6"); + const [fromMonth, setFromMonth] = useState("2026-02"); + const [toMonth, setToMonth] = useState("2026-07"); + const [drilldown, setDrilldown] = useState<{ category: ModelCategoryId; grade: ModelGrade; models: ModelRecord[] } | null>(null); + const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : [...MONITOR_MONTHS]; + const selectedModels = models.filter((model) => model.bank === bank && model.category === category && model.status !== "下线"); + const peerModels = models.filter((model) => model.category === category && model.status !== "下线"); + const ownKs = average(selectedModels.map((model) => model.ks)); + const ownPsi = average(selectedModels.map((model) => model.psi)); + const peerKs = average(peerModels.map((model) => model.ks)); + const peerPsi = average(peerModels.map((model) => model.psi)); + const ownKsSeries = modelsTrend(selectedModels, "ks", months); + const ownPsiSeries = modelsTrend(selectedModels, "psi", months); + const peerKsSeries = categoryTrend(category, "ks", months, models); + const peerPsiSeries = categoryTrend(category, "psi", months, models); + + const cards = useMemo(() => { + const realCards = MODEL_CATEGORIES.map((item) => { + const bankModels = models.filter((model) => model.bank === bank && model.category === item.id && model.status !== "下线"); + const allPeers = models.filter((model) => model.category === item.id && model.status !== "下线"); + 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; + }, [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) => { + 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 }); + }; + + return ( +
+
+ + +

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

+ + + {cards.map((item) => { + const total = item.grades.A + item.grades.B + item.grades.C || 1; + const selected = item.id === category; + const hasModels = item.modelCount > 0; + 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 !== 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)} />)}
+
; + })} +
+ + + 银行指标趋势本行均值与同业均值双线对比 ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} />{range === "custom" && <>} + {selectedModels.length ? <>
navigate(`/operations/monitoring/${modelId}`)} /> navigate(`/operations/monitoring/${modelId}`)} />
平均 KS 趋势{bank} · {categoryName(category)} · {months[0]} 至 {months.at(-1)}平均 PSI 趋势{bank} · {categoryName(category)} · {months[0]} 至 {months.at(-1)}
:
{bank}当前暂无{categoryName(category)}在用模型,请选择其他大类。
}
+
+
+ + { if (!open) setDrilldown(null); }}>{drilldown ? `${bank} · ${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}共 {drilldown?.models.length ?? 0} 个模型,支持逐行进入监控详情。模型ID最近迭代日期平均迭代周期排序性KSPSIKS环比降幅异常等级操作{drilldown?.models.length ? drilldown.models.map((model) => { const cycle = modelIterationCycleMonths(model); return {model.modelId}{model.iteratedAt}{cycle === null ? "—" : `${cycle} 个月`}{model.ranking}{model.ks.toFixed(2)}%{model.psi.toFixed(2)}%{model.ksDrop.toFixed(2)}%; }) : 该等级下暂无模型}
+
+ ); +} diff --git a/frontend/app/features/operations/CategoryCardRail.tsx b/frontend/app/features/operations/CategoryCardRail.tsx new file mode 100644 index 0000000..0944e3e --- /dev/null +++ b/frontend/app/features/operations/CategoryCardRail.tsx @@ -0,0 +1,87 @@ +import { Children, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +export function CategoryCardRail({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const items = Children.toArray(children); + const viewportRef = useRef(null); + const trackRef = useRef(null); + const [rowHeight, setRowHeight] = useState(); + const [overflowing, setOverflowing] = useState(false); + const [atTop, setAtTop] = useState(true); + const [atBottom, setAtBottom] = useState(false); + + const updateState = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const max = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + setOverflowing(max > 2); + setAtTop(viewport.scrollTop <= 2); + setAtBottom(viewport.scrollTop >= max - 2); + }, []); + + const measure = useCallback(() => { + const first = trackRef.current?.querySelector("[data-rail-card]"); + if (!first) return; + setRowHeight(Math.ceil(first.getBoundingClientRect().height + 2)); + requestAnimationFrame(updateState); + }, [updateState]); + + useEffect(() => { + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + if (trackRef.current) observer.observe(trackRef.current); + return () => observer.disconnect(); + }, [items.length, measure]); + + const scrollRow = (direction: -1 | 1) => { + const viewport = viewportRef.current; + const cards = trackRef.current?.querySelectorAll("[data-rail-card]"); + if (!viewport || !cards?.length) return; + const absoluteRows = [...new Set([...cards].map((card) => Math.round(card.offsetTop)))].sort((a, b) => a - b); + const origin = absoluteRows[0] ?? 0; + const rows = absoluteRows.map((top) => top - origin); + const current = viewport.scrollTop; + const target = direction > 0 + ? rows.find((top) => top > current + 3) ?? rows.at(-1) ?? 0 + : [...rows].reverse().find((top) => top < current - 3) ?? rows[0] ?? 0; + viewport.scrollTo({ top: target, behavior: "smooth" }); + }; + + return ( +
+
+
+ {items.map((item, index) => ( +
{item}
+ ))} +
+
+ {overflowing && ( +
+ 在卡片区域上下滚动查看更多模型大类 + + +
+ )} +
+ ); +} diff --git a/frontend/app/features/operations/DeployedModelsPage.tsx b/frontend/app/features/operations/DeployedModelsPage.tsx new file mode 100644 index 0000000..21737ba --- /dev/null +++ b/frontend/app/features/operations/DeployedModelsPage.tsx @@ -0,0 +1,200 @@ +import { useMemo, useRef, useState } from "react"; +import { ArrowRight, Download, FileSpreadsheet, Upload } from "lucide-react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; + +import { Button } from "~/components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; +import { usePersistentState } from "~/hooks/use-persistent-state"; +import { exportRowsToExcel } from "~/lib/exportExcel"; +import { FilterSelect, OperationsPageHeader, StatusBadge } from "./OperationsUi"; +import { useOperationsData } from "./OperationsDataContext"; +import type { ModelRecord } from "./modelData"; + +type Filters = { + bank: string; + name: string; + modelId: string; + modelType: string; +}; + +function unique(values: string[]): string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN")); +} + +function BarList({ + values, + labels, + suffix = "%", + tone = "brand", +}: { + values: number[]; + labels: string[]; + suffix?: string; + tone?: "brand" | "warning" | "success"; +}) { + const max = Math.max(...values, 1); + const colorClass = tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : "bg-brand"; + return ( +
+ {values.length ? values.map((value, index) => ( +
+ {labels[index]} + + {value.toFixed(2)}{suffix} +
+ )) :
暂无指标数据
} +
+ ); +} + +export default function DeployedModelsPage() { + const navigate = useNavigate(); + const { models } = useOperationsData(); + const pool = useMemo(() => models.filter((model) => model.status !== "下线"), [models]); + const [filters, setFilters] = useState({ bank: "", name: "", modelId: "", modelType: "" }); + const [selectedModelId, setSelectedModelId] = useState(pool[0]?.modelId ?? ""); + const [scoreFiles, setScoreFiles] = usePersistentState>("a-card-score-files", {}); + const scoreInputRef = useRef(null); + const filtered = pool.filter((model) => ( + (!filters.bank || model.bank === filters.bank) + && (!filters.name || model.name === filters.name) + && (!filters.modelId || model.modelId === filters.modelId) + && (!filters.modelType || (model.commonModel ? "通用模型" : "个性化模型") === filters.modelType) + )); + const model = filtered.find((item) => item.modelId === selectedModelId) ?? filtered[0] ?? null; + const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"]; + const rankingRates: number[] = []; + const liftValues: number[] = []; + const psiValues: number[] = []; + const scoreFile = model ? scoreFiles[model.modelId] : undefined; + + const update = (key: K, value: Filters[K]) => { + setFilters((current) => ({ ...current, [key]: value })); + setSelectedModelId(""); + }; + + const uploadScoreFile = (file: File | undefined) => { + if (!model || !file) return; + if (!/\.(xlsx|xls)$/i.test(file.name)) { + toast.error("请选择 .xlsx 或 .xls 文件"); + return; + } + if (file.size > 20 * 1024 * 1024) { + toast.error("文件不能超过 20 MB"); + return; + } + setScoreFiles((current) => ({ ...current, [model.modelId]: { name: file.name, size: file.size, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }) } })); + toast.success("评分逻辑文件已加入本地上传队列"); + }; + + const downloadScoreFile = () => { + if (!model || !scoreFile) return; + toast.info("评分逻辑文件下载接口尚未接入"); + }; + + return ( +
+
+ navigate(`/operations/monitoring/${model.modelId}`)}>查看监控详情 } + /> + + + + 模型筛选 + 筛出 {filtered.length} 个模型 + + +
+ item.bank))} onChange={(value) => update("bank", value)} /> + item.name))} onChange={(value) => update("name", value)} /> + item.modelId)} onChange={(value) => update("modelId", value)} /> + update("modelType", value)} /> +
+ ({ label: `${item.bank} · ${item.name} ${item.version}(${item.modelId})`, value: item.modelId }))} + onChange={setSelectedModelId} + /> +
+
+ + {model ? ( + <> + + + {model.bank} · {model.name} {model.version} + {model.modelId} + {model.commonModel ? `通用 · ${model.commonModel}` : "个性化模型"} + + + {[ + ["银行 × 模型ID", `${model.bank} × ${model.modelId}`], + ["版本", model.version], + ["开发人员", "—"], + ["上线日期", "—"], + ["最近迭代", model.iteratedAt], + ["陪跑开始", "—"], + ["陪跑结束", "—"], + ["下线日期", "—"], + ["状态", model.status], + ].map(([label, value]) => ( +
{label}{value}
+ ))} +
+
+ +
+ + 开发时点 · 排序性各评分区间坏客户占比 +

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

+
+ + 开发时点 · KS 与 LIFT暂无开发时点指标 + + + + 开发时点 · PSI暂无开发时点指标 +

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

+
+
+ +
+ + 评分逻辑以 Excel 文件维护,不在页面逐行展开 + +
+ + {scoreFile?.name ?? "暂无评分逻辑文件"}{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : "未关联评分逻辑文件"} + +
+ { uploadScoreFile(event.target.files?.[0]); event.target.value = ""; }} /> + +

增量模型由开发评审材料自动带入;存量模型由模型团队手工上传。

+
+
+ + + 模型开发材料材料存放于开发评审域,本页仅提供链接 + + + 材料环节操作 + 暂无模型开发材料 +
+
+
+
+ + ) : ( + 当前筛选条件下没有匹配的已上线模型 + )} +
+
+ ); +} diff --git a/frontend/app/features/operations/KnowledgeBasePage.tsx b/frontend/app/features/operations/KnowledgeBasePage.tsx new file mode 100644 index 0000000..def8d93 --- /dev/null +++ b/frontend/app/features/operations/KnowledgeBasePage.tsx @@ -0,0 +1,75 @@ +import { useEffect, useMemo, useState } from "react"; +import { Download, FolderArchive, RotateCcw } from "lucide-react"; +import { 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"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; +import { exportRowsToExcel } from "~/lib/exportExcel"; +import { FilterSelect, OperationsPageHeader } from "./OperationsUi"; +import { workflowDocuments, type KnowledgeDocument } from "./workflowData"; + +type Filters = { bank: string; modelName: string; modelId: string; stage: string }; +const EMPTY_FILTERS: Filters = { bank: "", modelName: "", modelId: "", stage: "" }; + +function unique(values: string[]): string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN")); +} + +function exportDocuments(rows: KnowledgeDocument[]) { + const header = ["流程", "银行", "模型名称", "模型版本", "模型ID", "环节", "材料名称", "上传人", "上传时间", "确认状态"]; + return exportRowsToExcel({ fileName: "文档知识库", sheetName: "文档知识库", headers: header, rows: rows.map((row) => [row.workflowTitle, row.bank, row.modelName, row.modelVersion, row.modelId, row.stage, row.name, row.uploadedBy, row.uploadedAt, row.confirmed ? "已确认" : "待确认"]) }); +} + +export default function KnowledgeBasePage() { + const documents = useMemo(() => workflowDocuments(), []); + const [searchParams, setSearchParams] = useSearchParams(); + const [filters, setFilters] = useState(() => ({ + ...EMPTY_FILTERS, + bank: searchParams.get("bank") ?? "", + modelName: searchParams.get("modelName") ?? "", + modelId: searchParams.get("modelId") ?? "", + stage: searchParams.get("stage") ?? "", + })); + const rows = useMemo(() => documents.filter((document) => ( + (!filters.bank || document.bank === filters.bank) + && (!filters.modelName || document.modelName === filters.modelName) + && (!filters.modelId || document.modelId === filters.modelId) + && (!filters.stage || document.stage === filters.stage) + )), [documents, filters]); + const update = (key: K, value: Filters[K]) => setFilters((current) => ({ ...current, [key]: value })); + + useEffect(() => { + const params = new URLSearchParams(); + (Object.keys(filters) as Array).forEach((key) => { if (filters[key]) params.set(key, filters[key]); }); + setSearchParams(params, { replace: true }); + }, [filters, setSearchParams]); + + return ( +
+
+ void exportDocuments(rows)}>导出 Excel} + /> + + 流程材料共 {rows.length} 份材料 + + item.bank))} onChange={(value) => update("bank", value)} /> + item.modelName))} onChange={(value) => update("modelName", value)} /> + item.modelId))} onChange={(value) => update("modelId", value)} /> + item.stage))} onChange={(value) => update("stage", value)} /> + + + + 流程银行模型名称模型版本模型ID环节材料名称上传人上传时间确认状态操作 + {rows.length ? rows.map((document) => {document.workflowTitle}{document.bank}{document.modelName}{document.modelVersion}{document.commonModel && 通用}{document.modelId}{document.stage}{document.name}{document.uploadedBy}{document.uploadedAt}{document.confirmed ? 已确认 : 待确认}) : 当前筛选条件下暂无材料} +
+
+
+
+
+ ); +} diff --git a/frontend/app/features/operations/ModelOverviewPage.tsx b/frontend/app/features/operations/ModelOverviewPage.tsx new file mode 100644 index 0000000..e6baa3c --- /dev/null +++ b/frontend/app/features/operations/ModelOverviewPage.tsx @@ -0,0 +1,163 @@ +import { useMemo, useRef, useState } from "react"; +import { Download, TrendingDown, TrendingUp } from "lucide-react"; +import { useNavigate, useSearchParams } from "react-router"; + +import { Button } from "~/components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; +import { exportRowsToExcel } from "~/lib/exportExcel"; +import { CategoryCardRail } from "./CategoryCardRail"; +import { FilterSelect, GradeBadge, MetricLineChart, OperationsPageHeader } from "./OperationsUi"; +import { useOperationsData } from "./OperationsDataContext"; +import { + MODEL_CATEGORIES, + MONITOR_MONTHS, + abnormalLevelOf, + average, + averageIterationCycle, + categoryName, + categoryTrend, + gradeOf, + latestIterationDate, + modelIterationCycleMonths, + monthsBetween, + type ModelCategoryId, + type ModelGrade, + type ModelRecord, +} from "./modelData"; + +type CategoryCardItem = { + id: string; + name: string; + models: ModelRecord[]; + banks: number; + versions: number; + latestIteration: string; + averageCycle: number | null; + averageKs: number; + averagePsi: number; + gradeCounts: Record; +}; + +function isCategoryId(value: string | null): value is ModelCategoryId { + return MODEL_CATEGORIES.some((category) => category.id === value); +} + +function DeviationTable({ metric, models, averageValue, onOpenDetail }: { + metric: "KS" | "PSI"; + models: ModelRecord[]; + averageValue: number; + onOpenDetail: (modelId: string) => void; +}) { + const isPsi = metric === "PSI"; + const rows = [...models] + .filter((model) => (isPsi ? model.psi > averageValue : model.ks < averageValue)) + .sort((left, right) => (isPsi ? right.psi - left.psi : left.ks - right.ks)); + + return ( + + + + {isPsi ? : } + {metric} {isPsi ? "高于" : "低于"}平均值 + + 平均 {averageValue.toFixed(2)}% · {rows.length} 条 + + + + + 银行 / 模型当月 {metric}等级 / 月份最近处理上次处理建议 + {rows.length ? rows.map((model) => { + const value = isPsi ? model.psi : model.ks; + return {model.bank}{model.name} · {model.version}{value.toFixed(2)}%{isPsi ? "↑" : "↓"} {Math.abs(value - averageValue).toFixed(2)}pp onOpenDetail(model.modelId)} />2026-07{model.processedAt ?? "未处理"}{model.previousAdvice}; + }) : 当前没有偏离平均值的模型} +
+
+
+ ); +} + +export default function ModelOverviewPage() { + const navigate = useNavigate(); + const { models } = useOperationsData(); + const [searchParams, setSearchParams] = useSearchParams(); + const trendRef = useRef(null); + const initialCategory = isCategoryId(searchParams.get("category")) ? searchParams.get("category") as ModelCategoryId : "std"; + const [category, setCategory] = useState(initialCategory); + const [range, setRange] = useState("6"); + const [fromMonth, setFromMonth] = useState("2026-02"); + const [toMonth, setToMonth] = useState("2026-07"); + const [drilldown, setDrilldown] = useState<{ category: ModelCategoryId; grade: ModelGrade; models: ModelRecord[] } | null>(null); + const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : [...MONITOR_MONTHS]; + const selectedName = categoryName(category); + const selectedModels = models.filter((model) => model.category === category && model.status !== "下线"); + const averageKs = average(selectedModels.map((model) => model.ks)); + const averagePsi = average(selectedModels.map((model) => model.psi)); + const ksSeries = categoryTrend(category, "ks", months, models); + const psiSeries = categoryTrend(category, "psi", months, models); + + const categoryCards = useMemo(() => { + 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: averageIterationCycle(categoryModels), averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts }; + }); + return realCards; + }, [models]); + + const selectCategory = (value: ModelCategoryId, scroll = false) => { + setCategory(value); + setSearchParams({ category: value }); + if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); + }; + + const openGrade = (item: CategoryCardItem, grade: ModelGrade) => { + setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) }); + }; + + return ( +
+
+ + + + {categoryCards.map((item) => { + const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1; + const selected = item.id === category; + const activate = () => selectCategory(item.id as ModelCategoryId, true); + return ( + { if (event.key === "Enter" || event.key === " ") activate(); }}> + {item.name}全部银行 + +
{[["银行数", 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)} />)}
+
+
+ ); + })} +
+ + + 指标趋势全部银行口径;支持按模型大类与时间范围查看 ({ label: item.name, value: item.id }))} onChange={(value) => { if (isCategoryId(value)) selectCategory(value); }} />{range === "custom" && <>} + +
navigate(`/operations/monitoring/${modelId}`)} /> navigate(`/operations/monitoring/${modelId}`)} />
+
平均 KS 趋势{selectedName} · {months[0]} 至 {months.at(-1)} · 最新一期 {averageKs.toFixed(2)}%平均 PSI 趋势{selectedName} · {months[0]} 至 {months.at(-1)} · 最新一期 {averagePsi.toFixed(2)}%
+
+
+
+ + { if (!open) setDrilldown(null); }}> + + {drilldown ? `${categoryName(drilldown.category)} · ${drilldown.grade} 等级模型` : "等级模型"}共 {new Set(drilldown?.models.map((model) => model.bank)).size} 家银行、{drilldown?.models.length ?? 0} 个模型;最近迭代日期和平均迭代周期按模型展示。 + 银行模型ID最近迭代日期平均迭代周期排序性KSPSIKS环比降幅异常等级操作{drilldown?.models.length ? drilldown.models.map((model) => { const cycle = modelIterationCycleMonths(model); return {model.bank}{model.modelId}{model.iteratedAt}{cycle === null ? "—" : `${cycle} 个月`}{model.ranking}{model.ks.toFixed(2)}%{model.psi.toFixed(2)}%{model.ksDrop.toFixed(2)}%{abnormalLevelOf(model)}; }) : 该等级下暂无模型}
+ {drilldown?.models.length ? : null} +
+
+
+ ); +} diff --git a/frontend/app/features/operations/MonitoringDetailPage.tsx b/frontend/app/features/operations/MonitoringDetailPage.tsx new file mode 100644 index 0000000..1d3f824 --- /dev/null +++ b/frontend/app/features/operations/MonitoringDetailPage.tsx @@ -0,0 +1,322 @@ +import { useEffect, useMemo, useState } from "react"; +import { ArrowRight, CheckCircle2, Clock3, FileText, Info, RefreshCw, TriangleAlert } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; + +import { Button } from "~/components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; +import { Textarea } from "~/components/ui/textarea"; +import { + AbnormalBadge, + FilterSelect, + GradeBadge, + MetricLineChart, + OperationsPageHeader, + SortingComboChart, + StatusBadge, +} from "./OperationsUi"; +import { + FEATURE_METRICS, + SORTING_DISTRIBUTION, + abnormalLevelOf, + abnormalReasonOf, + gradeOf, + modelTrend, + monthsBetween, +} from "./modelData"; +import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext"; +import { useCanOperationsAction } from "./operationsRole"; + +function recentMonths(count: number): string[] { + const end = 2026 * 12 + 6; + return Array.from({ length: count }, (_, index) => { + const value = end - count + 1 + index; + return `${Math.floor(value / 12)}-${String(value % 12 + 1).padStart(2, "0")}`; + }); +} + +export default function MonitoringDetailPage() { + const navigate = useNavigate(); + const params = useParams(); + const { models } = useOperationsData(); + 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]; + const model = detail.monitoringResult ?? baseModel ?? models[0]!; + const [range, setRange] = useState("6"); + const [fromMonth, setFromMonth] = useState("2026-02"); + const [toMonth, setToMonth] = useState("2026-07"); + const [compareId, setCompareId] = useState(""); + const [selectedFeature, setSelectedFeature] = useState(null); + const [reviewStage, setReviewStage] = useState<0 | 1 | 2>(0); + 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; + const compareKsTrend = compareModel ? modelTrend(compareModel, "ks", months) : null; + const comparePsiTrend = compareModel ? modelTrend(compareModel, "psi", months) : null; + const grade = gradeOf(model); + const abnormal = abnormalLevelOf(model); + 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 = 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)); + + const submitReview = () => { + if (!reviewNote.trim()) { + toast.error("请填写处理说明"); + return; + } + if (reviewStage === 0) { + setReviewStage(1); + toast.success(`模型团队初审已提交:${reviewDecision}`); + } else { + setReviewStage(2); + toast.success(`业务团队终审已提交:${reviewDecision}`); + } + setReviewNote(""); + }; + + useEffect(() => { + setReviewStage(0); + setReviewDecision("暂不处理"); + setReviewNote(""); + setSelectedFeature(null); + setCompareId(""); + }, [modelId]); + + if (detail.loading) { + return
; + } + + if (detail.error) { + return

模型监控详情加载失败

{detail.error ?? "模型不存在"}

; + } + + return ( +
+
+ navigate("/operations/monitoring")} + actions={( +
+ item.status !== "下线").map((item) => ({ + label: `${item.bank} · ${item.name} ${item.version}`, + value: item.modelId, + }))} + onChange={(modelId) => { + if (modelId) navigate(`/operations/monitoring/${modelId}`); + }} + /> + + item.status !== "下线" && item.modelId !== model.modelId).map((item) => ({ label: `${item.bank} · ${item.modelId}`, value: item.modelId }))} + onChange={setCompareId} + /> + {range === "custom" && ( + <> + + + + )} +
+ )} + /> + + + + ① 监控结论 + {monitorMonth ? `${monitorMonth} 监控周期 · 结果优先展示` : "暂无监控结果"} + + + + + + +
+ {[ + ["监控结果等级", grade], + ["排序性", model.ranking], + ["KS", `${model.ks.toFixed(2)}%`], + ["PSI", `${model.psi.toFixed(2)}%`], + ["KS 环比降幅", `${model.ksDrop.toFixed(2)}%`], + ["异常等级", abnormal], + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ + {grade === "A" ? ( +
+ +
本期无异常

各项指标均在阈值内,生成模型监控报告并继续按月监控。

+
+ ) : ( +
+ +
+ 异常原因({abnormal}) +

{abnormalReasonOf(model)}

+ {(model.ks < 30 || model.psi > 50) &&

已达到主动干预条件,请建模团队优先处理。

} +
+
+ )} + +
+
+ +
+ 处理进度 +

{grade === "A" ? "本期无异常,无需进入处理流程。" : effectiveReviewStage === 0 ? "待模型团队初审,初审完成后流转至业务团队终审。" : effectiveReviewStage === 1 ? "模型团队已完成初审,待业务团队终审。" : "本期处理已结案。"}

+
+ = 1 ? "bg-success text-white" : "bg-primary text-white"}`}>1 模型团队初审 + + = 2 ? "bg-success text-white" : effectiveReviewStage === 1 ? "bg-primary text-white" : "bg-muted"}`}>2 业务团队终审 +
+ {canReview &&