diff --git a/.env.example b/.env.example index 7ea6fae..165660e 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,27 @@ 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 +OPERATIONS_DATA_MODE=database 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 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..f75347c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,4 +5,41 @@ 模块合并,外部 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 完全分离,运维路由只读写 `ops_*`。 +- 当前已落地 P0 ORM,以及模型列表、模型详情、指定月份监控结果三条接口。 +- `OPERATIONS_DATA_MODE=mock` 时仍走真实鉴权、Workspace校验和API响应, + 仅将业务查询替换为后端Mock数据;切换为 `database` 后读取运维库。 +- 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= +``` 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..b152e3f --- /dev/null +++ b/backend/src/backend/api/operations/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from backend.api.operations.health import router as health_router +from backend.api.operations.models import router as models_router + +router = APIRouter(prefix="/api/v1/operations", tags=["operations"]) +router.include_router(health_router) +router.include_router(models_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..64f42cb --- /dev/null +++ b/backend/src/backend/api/operations/_deps.py @@ -0,0 +1,139 @@ +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 Roles +from fastapi import Depends, HTTPException, Request, status +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 + role: OperationsRole + + @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}", + }, + ) + return OperationsContext( + request_id=context.request_id, + user_id=context.user.user_id, + workspace_id=context.workspace.workspace_id, + role=normalized, + ) + + +__all__ = [ + "OperationsContext", + "deploy_database_session", + "operations_cache", + "operations_context", + "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..f149bda --- /dev/null +++ b/backend/src/backend/api/operations/health.py @@ -0,0 +1,71 @@ +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() + + +@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()")) + checks[database_name] = { + "status": "ok" if current_database == database_name else "error", + "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..61a720a --- /dev/null +++ b/backend/src/backend/api/operations/models.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.operations._deps import ( + OperationsContext, + operations_context, + operations_database_session, +) +from backend.schemas.operations import ModelCategoryCode, ModelStatusLabel +from backend.services.operations import ( + get_model, + get_monthly_monitoring_result, + list_models, + parse_monitor_month, +) +from backend.services.operations.mock_data import ( + get_mock_model, + get_mock_monthly_result, + list_mock_models, +) +from common.config import settings + +router = APIRouter() + + +def _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(operations_context), + session: AsyncSession = Depends(operations_database_session), +) -> dict[str, Any]: + models = list_mock_models( + bank=bank, + category=category, + status=model_status, + keyword=keyword, + ) if settings.operations_data_mode == "mock" else await list_models( + session, + context.workspace_id, + bank=bank, + category=category, + status=model_status, + keyword=keyword, + ) + return _envelope(context, models) + + +@router.get("/models/{model_id}") +async def get_operation_model( + model_id: str, + context: OperationsContext = Depends(operations_context), + session: AsyncSession = Depends(operations_database_session), +) -> dict[str, Any]: + model = get_mock_model(model_id) if settings.operations_data_mode == "mock" else await get_model(session, context.workspace_id, model_id) + 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(operations_context), + session: AsyncSession = Depends(operations_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 + result = get_mock_monthly_result(model_id, month) if settings.operations_data_mode == "mock" else await get_monthly_monitoring_result( + session, + context.workspace_id, + model_id, + monitor_month, + ) + 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/main.py b/backend/src/backend/main.py index cd8eab5..9b45c2b 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.schedules.runs import router as schedule_runs_router @@ -65,6 +67,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 调用自己。字典键使用真实桶名,便于上传会话和存储 @@ -94,8 +125,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() @@ -112,6 +148,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..e5338f6 --- /dev/null +++ b/backend/src/backend/services/operations/__init__.py @@ -0,0 +1,17 @@ +from backend.services.operations.cache import OperationsCache +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 + +__all__ = [ + "OperationsCache", + "OperationsEventStream", + "get_model", + "get_monthly_monitoring_result", + "list_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/mock_data.py b/backend/src/backend/services/operations/mock_data.py new file mode 100644 index 0000000..87b2ff2 --- /dev/null +++ b/backend/src/backend/services/operations/mock_data.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import datetime + +from backend.schemas.operations import MonthlyMonitoringResultDto, OperationsModelDto + +_MODEL_SPECS = [ + ("江城银行", "std", "标准A卡", "JC-STD-001", "v2.3", "正常", "2026-06-18", "不符", 36.84, 27.40, 24.10, 3, True, "2026-06-18", "建议启动模型微调或重构评估", "标准A卡通用版 v2.3"), + ("滨海银行", "std", "标准A卡", "BH-STD-001", "v2.3", "陪跑", "2026-07-28", "相符", 44.10, 6.20, 3.40, 0, False, "2026-06-03", "维持现状,继续监控", "标准A卡通用版 v2.3"), + ("华东银行", "std", "标准A卡", "HD-STD-001", "v2.2", "正常", "2026-03-20", "相符", 43.50, 8.80, 6.10, 0, False, "2026-05-20", "维持现状,继续监控", "标准A卡通用版 v2.3"), + ("南岭银行", "std", "标准A卡", "NL-STD-001", "v1.6", "正常", "2025-08-22", "相符", 41.12, 13.60, 22.50, 1, False, "2026-06-12", "建议重点关注 PSI 与 KS 环比变化", "标准A卡通用版 v2.3"), + ("云岭银行", "std", "标准A卡", "YL-STD-001", "v2.1", "正常", "2026-01-09", "相符", 45.80, 5.10, 2.20, 0, False, "2026-04-28", "维持现状,继续监控", "标准A卡通用版 v2.3"), + ("通汇银行", "std", "标准A卡", "TH-STD-001", "v2.0", "正常", "2025-11-30", "不符", 38.73, 9.40, 11.80, 4, False, "2026-05-16", "建议开展模型微调可行性评估", "标准A卡通用版 v2.3"), + ("北岸银行", "std", "标准A卡", "BA-STD-001", "v1.9", "下线", "2024-12-11", "—", 0.00, 0.00, 0.00, 0, False, "2026-03-02", "模型已下线,不再参与月度监控", None), + ("江城银行", "big", "大额A卡", "JC-BIG-001", "v1.4", "正常", "2026-07-03", "相符", 46.30, 4.80, 1.50, 0, False, "2026-06-24", "维持现状,继续监控", "大额A卡通用版 v1.4"), + ("华东银行", "big", "大额A卡", "HD-BIG-001", "v1.3", "正常", "2026-04-16", "相符", 44.90, 7.30, 4.90, 0, False, "2026-05-30", "维持现状,继续监控", "大额A卡通用版 v1.4"), + ("云岭银行", "big", "大额A卡", "YL-BIG-001", "v1.2", "陪跑结束", "2026-08-05", "相符", 42.70, 9.60, 8.20, 0, False, None, "暂无历史处理建议", "大额A卡通用版 v1.4"), + ("滨海银行", "big", "大额A卡", "BH-BIG-001", "v1.1", "正常", "2025-09-25", "相符", 39.40, 11.20, 9.70, 2, False, "2026-04-19", "建议持续跟踪,下期复核", "大额A卡通用版 v1.4"), + ("南岭银行", "bai", "白户A卡", "NL-BAI-001", "v1.5", "正常", "2026-05-22", "不符", 34.20, 18.70, 15.30, 2, True, "2026-06-09", "建议启动模型微调或重构评估", None), + ("通汇银行", "bai", "白户A卡", "TH-BAI-001", "v1.4", "正常", "2026-02-11", "相符", 37.60, 12.10, 7.40, 1, False, "2026-05-11", "建议重点关注并持续跟踪", "白户A卡通用版 v1.4"), + ("江城银行", "bai", "白户A卡", "JC-BAI-001", "v1.3", "正常", "2025-12-05", "相符", 40.80, 9.90, 5.60, 0, False, "2026-03-26", "维持现状,继续监控", "白户A卡通用版 v1.4"), + ("华东银行", "afd", "反欺诈评分", "HD-AFD-001", "v2.0", "正常", "2026-04-10", "不符", 28.60, 31.50, 18.90, 3, False, "2026-06-21", "建议启动模型微调或重构评估", None), + ("滨海银行", "afd", "反欺诈评分", "BH-AFD-001", "v1.8", "正常", "2025-10-30", "相符", 36.40, 8.10, 4.20, 3, True, "2026-05-25", "建议重点关注并持续跟踪", "反欺诈评分通用版 v1.8"), + ("云岭银行", "afd", "反欺诈评分", "YL-AFD-001", "v1.7", "正常", "2025-08-19", "相符", 38.20, 14.60, 12.70, 2, False, "2026-04-15", "建议持续跟踪,下期复核", None), +] + + +def _model(spec: tuple[object, ...]) -> OperationsModelDto: + bank, category, name, model_id, version, model_status, iteration_date, ranking, ks, psi, drop, hits, wuji, processed, advice, common = spec + return OperationsModelDto( + model_instance_id=f"mock-{model_id}", + bank_name=str(bank), + model_category=category, # type: ignore[arg-type] + model_name=str(name), + model_id=str(model_id), + model_version=str(version), + model_status=model_status, # type: ignore[arg-type] + last_iteration_date=str(iteration_date), + ranking_result=ranking, # type: ignore[arg-type] + ks=float(ks), + psi=float(psi), + ks_mom_drop=float(drop), + secondary_hits_6m=int(hits), + is_wuji_bank=bool(wuji), + last_processed_at=processed, + previous_advice=str(advice), + common_model_name=common, + ) + + +MOCK_MODELS = tuple(_model(spec) for spec in _MODEL_SPECS) +_MODEL_BY_ID = {model.model_id: model for model in MOCK_MODELS} + + +def list_mock_models( + *, + bank: str | None = None, + category: str | None = None, + status: str | None = None, + keyword: str | None = None, +) -> list[OperationsModelDto]: + term = keyword.strip().lower() if keyword else "" + return [ + model.model_copy() + for model in MOCK_MODELS + if not bank or model.bank_name == bank + if not category or model.model_category == category + if not status or model.model_status == status + if not term or term in " ".join((model.bank_name, model.model_name, model.model_id, model.model_version)).lower() + ] + + +def get_mock_model(model_id: str) -> OperationsModelDto | None: + model = _MODEL_BY_ID.get(model_id) + return model.model_copy() if model else None + + +def get_mock_monthly_result(model_id: str, month: str) -> MonthlyMonitoringResultDto | None: + model = _MODEL_BY_ID.get(model_id) + if model is None: + return None + if model.model_status == "下线" and month != "2026-02": + return None + try: + index = max(0, min(5, (datetime.date.fromisoformat(f"{month}-01").year - 2026) * 12 + datetime.date.fromisoformat(f"{month}-01").month - 2)) + except ValueError: + return None + distance = 5 - index + return MonthlyMonitoringResultDto( + model_instance_id=model.model_instance_id, + monitor_month=month, + ranking_result=model.ranking_result, + ks=round(max(0, model.ks - distance * 0.32), 2), + psi=round(max(0, model.psi - distance * 0.74), 2), + ks_mom_drop=round(max(0, model.ks_mom_drop - distance * 0.9), 2), + secondary_hits_6m=max(0, model.secondary_hits_6m - distance), + ) + + +__all__ = [ + "MOCK_MODELS", + "get_mock_model", + "get_mock_monthly_result", + "list_mock_models", +] diff --git a/backend/src/backend/services/operations/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/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/common/src/common/config.py b/common/src/common/config.py index 734c410..0cfcfd5 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,6 +64,43 @@ 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) + operations_data_mode: str = Field( + default="database", + description="Operations data source: database or mock.", + ) # ── JWT ─────────────────────────────────────────────────────── jwt_secret: str = Field( @@ -268,6 +306,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.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..4416098 --- /dev/null +++ b/docs/architecture/CHANGELOG.md @@ -0,0 +1,19 @@ +# 架构设计变更记录 + +## **当前最新版本:V0.2** + +### 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卡运维数据库-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/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..fa18d9f --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# Model-platform gateway used by the Vite development proxy. +VITE_GATEWAY_URL=http://127.0.0.1:8890 + +# "mock" keeps the A-card operations pages on local frontend fixtures. +# Switch to "api" when /api/v1/operations endpoints are available. +VITE_OPERATIONS_API_MODE=mock diff --git a/frontend/README.md b/frontend/README.md index a3844c1..fa2aa1f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -19,4 +19,27 @@ pnpm install pnpm dev ``` +运维模块默认支持前端 Mock;当前本地 `frontend/.env` 已切换为后端 API: + +```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 e04338a..2425f45 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -24,10 +24,16 @@ export function Topbar({ onSetCurrentWorkspace, onLogout, }: TopbarProps) { + const roleLabel = user?.is_system_admin || user?.role_code === "admin" + ? "管理员" + : ["business", "business_team", "biz"].includes(user?.role_code ?? "") + ? "业务团队" + : "模型团队"; const pageTitles: Record = { home: "工作台", scripts: "构建脚本", schedules: "调度配置", + operations: "A卡模型运维", system: "系统管理", }; @@ -124,7 +130,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 (
; + demo?: boolean; +}; + +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, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], modelCount: 1, versions: 1, latestIteration: "2026-07-18", averageCycle: 11, ownKs: 37.9, ownPsi: 16.6, peerKs: 39.6, peerPsi: 14.2, grades: { A: 0, B: 1, C: 0 }, demo: true }]; + }, [bank, models]); + + const syncParams = (nextBank: string, nextCategory: ModelCategoryId) => setSearchParams({ bank: nextBank, category: nextCategory }); + const selectBank = (value: string) => { setBank(value); syncParams(value, category); }; + const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); syncParams(bank, value); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); }; + const openGrade = (item: BankCategoryCard, grade: ModelGrade) => { + if (item.demo) return toast.info("该卡片仅用于展示细分银行第 5 个大类的纵向滚动效果"); + const gradeModels = item.models.filter((model) => gradeOf(model) === grade); + if (gradeModels.length === 1) return navigate(`/operations/monitoring/${gradeModels[0]?.modelId}`); + setDrilldown({ category: item.id as ModelCategoryId, grade, models: gradeModels }); + }; + + return ( +
+
+ navigate("/operations/deployed-models")}>查看已上线模型 } /> + +

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

+ + + {cards.map((item) => { + const total = item.grades.A + item.grades.B + item.grades.C || 1; + const selected = !item.demo && item.id === category; + const hasModels = item.modelCount > 0; + const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同结构") : selectCategory(item.id as ModelCategoryId, true); + return { if (event.key === "Enter" || event.key === " ") activate(); }}>{item.name}{item.demo ? "扩展示意" : `银行-${bank}`} +
{[["银行数", hasModels ? 1 : 0], ["模型数", item.modelCount], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
+
最近迭代日期
{item.latestIteration}
平均迭代周期
{hasModels ? `${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..34ea784 --- /dev/null +++ b/frontend/app/features/operations/DeployedModelsPage.tsx @@ -0,0 +1,213 @@ +import { useMemo, useRef, useState } from "react"; +import { ArrowRight, Download, FileSpreadsheet, FolderOpen, 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 { MODEL_LIFECYCLE, 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 metricSeries(model: ModelRecord, base: readonly number[], variance: number): number[] { + const seed = [...model.modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0); + return base.map((value, index) => Number((value * (1 + ((seed >> index) % 7 - 3) / variance)).toFixed(2))); +} + +function BarList({ + values, + labels, + 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.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 lifecycle = model ? MODEL_LIFECYCLE[model.modelId] : null; + const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"]; + const rankingRates = model ? metricSeries(model, [9.8, 7.2, 5.1, 3.4, 2.0, 1.1], 28) : []; + const liftValues = model ? metricSeries(model, [3.2, 2.4, 1.7, 1.1, 0.7, 0.4], 20) : []; + const psiValues = model ? metricSeries(model, [1.2, 0.9, 0.6, 0.5, 0.4, 0.3], 20) : []; + const 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) return; + void exportRowsToExcel({ + fileName: scoreFile?.name.replace(/\.xlsx?$/i, "") ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑`, + sheetName: "评分逻辑", + headers: ["入模特征", "划分区间", "对应评分"], + rows: [["age", "[18,25)", 12], ["age", "[25,35)", 26], ["income", "[0,5000)", 8], ["income", "[5000,+)", 31], ["query_3m", "[0,2]", 22], ["query_3m", "[3,+)", 6]], + }); + }; + + 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 && lifecycle ? ( + <> + + + {model.bank} · {model.name} {model.version} + {model.modelId} + {model.commonModel ? `通用 · ${model.commonModel}` : "个性化模型"} + + + {[ + ["银行 × 模型ID", `${model.bank} × ${model.modelId}`], + ["版本", model.version], + ["开发人员", lifecycle.developer], + ["上线日期", lifecycle.onlineAt ?? "—"], + ["最近迭代", model.iteratedAt], + ["陪跑开始", lifecycle.escortStartAt ?? "—"], + ["陪跑结束", lifecycle.escortEndAt ?? "—"], + ["下线日期", lifecycle.offlineAt ?? "—"], + ["状态", model.status], + ].map(([label, value]) => ( +
{label}{value}
+ ))} +
+
+ +
+ + 开发时点 · 排序性各评分区间坏客户占比 +

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

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

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

+
+
+ +
+ + 评分逻辑以 Excel 文件维护,不在页面逐行展开 + +
+ + {scoreFile?.name ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑.xlsx`}{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : `模型团队 ${lifecycle.developer} · ${lifecycle.onlineAt ?? model.iteratedAt}`} + +
+ { uploadScoreFile(event.target.files?.[0]); event.target.value = ""; }} /> + +

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

+
+
+ + + 模型开发材料材料存放于开发评审域,本页仅提供链接 + + + 材料环节操作 + {[ + ["模型设计方案", "方案设计"], ["开发结果材料", "开发迭代"], ["新老模型对比", "模型验证"], ["评审会议纪要", "评审决议"], ["一致性报告", "测试陪跑"], + ].map(([name, stage]) => {name}{stage})} +
+
+
+
+ + ) : ( + 当前筛选条件下没有匹配的已上线模型 + )} +
+
+ ); +} diff --git a/frontend/app/features/operations/KnowledgeBasePage.tsx b/frontend/app/features/operations/KnowledgeBasePage.tsx new file mode 100644 index 0000000..1cfff34 --- /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..779f827 --- /dev/null +++ b/frontend/app/features/operations/ModelOverviewPage.tsx @@ -0,0 +1,167 @@ +import { useMemo, useRef, useState } from "react"; +import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react"; +import { useNavigate, useSearchParams } from "react-router"; +import { toast } from "sonner"; + +import { Button } from "~/components/ui/button"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +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, + categoryName, + categoryTrend, + gradeOf, + latestIterationDate, + modelIterationCycleMonths, + monthsBetween, + type ModelCategoryId, + type ModelGrade, + type ModelRecord, +} from "./modelData"; + +const CATEGORY_AVERAGE_CYCLE: Record = { std: 9.3, bai: 12, big: 8, afd: 14 }; + +type CategoryCardItem = { + id: string; + name: string; + models: ModelRecord[]; + banks: number; + versions: number; + latestIteration: string; + averageCycle: number; + averageKs: number; + averagePsi: number; + gradeCounts: Record; + demo?: boolean; +}; + +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: CATEGORY_AVERAGE_CYCLE[item.id], averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts }; + }); + return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], banks: 5, versions: 4, latestIteration: "2026-07-18", averageCycle: 10.6, averageKs: 39.6, averagePsi: 14.2, gradeCounts: { A: 2, B: 2, C: 1 }, demo: true }]; + }, [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) => { + if (item.demo) return toast.info("该卡片仅用于展示第 5 个及以上模型大类的纵向滚动效果"); + setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) }); + }; + + return ( +
+
+ navigate("/operations/monitoring")}>查看监控明细 } /> + + + {categoryCards.map((item) => { + const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1; + const selected = !item.demo && item.id === category; + const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同卡片结构") : selectCategory(item.id as ModelCategoryId, true); + return ( + { if (event.key === "Enter" || event.key === " ") activate(); }}> + {item.name}{item.demo ? "扩展示意" : "全部银行"} + +
{[["银行数", item.banks], ["模型数", item.demo ? 5 : item.models.length], ["版本数", item.versions]].map(([label, value]) =>
{label}
{value}
)}
+
最近迭代日期
{item.latestIteration}
平均迭代周期
{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..10a9703 --- /dev/null +++ b/frontend/app/features/operations/MonitoringDetailPage.tsx @@ -0,0 +1,337 @@ +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 { REPORTS } from "./reportData"; +import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext"; +import { isOperationsActionVisibleForRole, useOperationsRole } 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 operationsRole = useOperationsRole(); + const modelId = params.modelId ?? models[0]?.modelId ?? ""; + const detail = useOperationsModelDetail(modelId, "2026-07"); + const baseModel = detail.model ?? models.find((item) => item.modelId === modelId) ?? models[0]; + 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 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 linkedReport = REPORTS.find((report) => report.modelId === model.modelId && report.monitorMonth === "2026-07") ?? null; + const chosenFeature = FEATURE_METRICS.find((item) => item.key === selectedFeature) ?? null; + const ivTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.ivDrop - left.ivDrop), []); + const csiTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.csiRise - left.csiRise), []); + const canInitialReview = isOperationsActionVisibleForRole(operationsRole, "monitor:initial-review"); + const canFinalReview = isOperationsActionVisibleForRole(operationsRole, "monitor:final-review"); + const 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" && ( + <> + + + + )} +
+ )} + /> + + + + ① 监控结论 + 2026-07 监控周期 · 结果优先展示 + + + + + + +
+ {[ + ["监控结果等级", 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 &&