feat: add A-card operations frontend and backend foundation

This commit is contained in:
郑龙捷
2026-09-02 09:54:03 +08:00
parent 9badd3597f
commit ad71259ba5
106 changed files with 12461 additions and 1796 deletions
+2
View File
@@ -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
+38 -1
View File
@@ -5,4 +5,41 @@
模块合并,外部 REST 契约保持不变。
底层走的是 `common.storage.AsyncStorageBackend` 抽象,按
`settings.storage_backend` 切换 s3 / local 两种实现。
`settings.storage_backend` 切换 s3 / local 两种实现。
## A 卡模型运维模块
运维能力作为同一 FastAPI 进程内的独立 bounded context 接入:
- 平台库继续使用 `DATABASE_URL`,负责登录、角色和工作空间校验。
- 运维库使用 `OPERATIONS_DATABASE_URL`;未配置时自动复用平台库连接信息,
仅将库名切换为 `model_operations`
- `PLATFORM_READ_DATABASE_URL``DEPLOY_DATABASE_URL` 使用独立只读
SessionMySQL 事务会显式执行 `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=<ULID>
GET /api/v1/operations/models/{model_id}?workspace_id=<ULID>
GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM&workspace_id=<ULID>
```
+1
View File
@@ -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]
@@ -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"]
+139
View File
@@ -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",
]
@@ -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"]
@@ -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"]
+37
View File
@@ -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")
@@ -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",
]
@@ -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",
]
@@ -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"]
@@ -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",
]
@@ -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"]
@@ -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",
]
@@ -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",
]
@@ -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"]
+233
View File
@@ -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
+110
View File
@@ -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