fix(incidents): bind durable readback tenant context

This commit is contained in:
ogt
2026-07-11 20:10:17 +08:00
parent 476c6a6315
commit ca5ed596dc
6 changed files with 79 additions and 18 deletions

View File

@@ -155,6 +155,12 @@ class IncidentTimelineResponse(BaseModel):
""",
)
async def list_incidents(
project_id: str = Query(
"awoooi",
min_length=1,
max_length=64,
description="租戶 IDlegacy incident surface 預設綁定 awoooi。",
),
generate_missing_decisions: bool = Query(
False,
description=(
@@ -180,7 +186,9 @@ async def list_incidents(
try:
# 透過 Service 取得活躍事件
incidents = await incident_service.get_active_incidents()
incidents = await incident_service.get_active_incidents(
project_id=project_id,
)
# 按時間排序 (最新優先)
# 2026-03-26 修復: 處理 timezone-aware 與 naive datetime 混合問題
@@ -289,7 +297,15 @@ async def list_incidents(
summary="取得單一事件",
description="取得特定事件的詳細資訊。",
)
async def get_incident(incident_id: str) -> IncidentResponse:
async def get_incident(
incident_id: str,
project_id: str = Query(
"awoooi",
min_length=1,
max_length=64,
description="租戶 IDlegacy incident surface 預設綁定 awoooi。",
),
) -> IncidentResponse:
"""
取得單一事件
@@ -306,7 +322,10 @@ async def get_incident(incident_id: str) -> IncidentResponse:
incident_service = get_incident_service()
try:
incident = await incident_service.get_for_readback(incident_id)
incident = await incident_service.get_for_readback(
incident_id,
project_id=project_id,
)
if incident is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View File

@@ -168,13 +168,13 @@ class IncidentDBRepository(IIncidentRepository):
record = result.scalar_one_or_none()
return _record_to_incident(record) if record else None
async def get_active(self) -> list[Incident]:
async def get_active(self, *, project_id: str | None = None) -> list[Incident]:
"""取得所有活躍的 Incident"""
active_statuses = [
IncidentStatus.INVESTIGATING,
IncidentStatus.MITIGATING,
]
async with get_db_context() as db:
async with get_db_context(project_id) as db:
result = await db.execute(
select(IncidentRecord)
.where(IncidentRecord.status.in_(active_statuses))

View File

@@ -125,7 +125,11 @@ class IIncidentRepository(Protocol):
"""根據 ID 取得 Incident"""
...
async def get_active(self) -> list[Incident]:
async def get_active(
self,
*,
project_id: str | None = None,
) -> list[Incident]:
"""取得所有活躍的 Incident"""
...

View File

@@ -730,9 +730,17 @@ class IncidentService:
)
return "updated"
async def get_for_readback(self, incident_id: str) -> Incident | None:
async def get_for_readback(
self,
incident_id: str,
*,
project_id: str | None = None,
) -> Incident | None:
"""Return durable incident truth, with Redis as an availability fallback."""
incident = await self.get_from_episodic_memory(incident_id)
incident = await self.get_from_episodic_memory(
incident_id,
project_id=project_id,
)
if incident is not None:
return incident
@@ -742,7 +750,11 @@ class IncidentService:
)
return await self.get_from_working_memory(incident_id)
async def get_active_incidents(self) -> list[Incident]:
async def get_active_incidents(
self,
*,
project_id: str | None = None,
) -> list[Incident]:
"""
列出所有活躍的 Incidents。
@@ -755,7 +767,9 @@ class IncidentService:
try:
from src.repositories.incident_repository import get_incident_repository
incidents = await get_incident_repository().get_active()
incidents = await get_incident_repository().get_active(
project_id=project_id,
)
logger.info(
"get_active_incidents_from_episodic_memory",
count=len(incidents),

View File

@@ -37,11 +37,18 @@ async def test_readback_prefers_durable_resolved_incident_over_stale_cache(monke
working_read = AsyncMock(return_value=cached)
monkeypatch.setattr(service, "get_from_working_memory", working_read)
result = await service.get_for_readback(durable.incident_id)
result = await service.get_for_readback(
durable.incident_id,
project_id="awoooi",
)
assert result is durable
assert result.status is IncidentStatus.RESOLVED
working_read.assert_not_awaited()
service.get_from_episodic_memory.assert_awaited_once_with(
durable.incident_id,
project_id="awoooi",
)
@pytest.mark.asyncio
@@ -61,7 +68,10 @@ async def test_readback_falls_back_to_working_memory_when_durable_record_unavail
AsyncMock(return_value=cached),
)
result = await service.get_for_readback(cached.incident_id)
result = await service.get_for_readback(
cached.incident_id,
project_id="awoooi",
)
assert result is cached
@@ -76,11 +86,17 @@ async def test_incident_detail_endpoint_uses_durable_readback(monkeypatch):
service = _Service()
monkeypatch.setattr(incidents_api, "get_incident_service", lambda: service)
result = await incidents_api.get_incident(durable.incident_id)
result = await incidents_api.get_incident(
durable.incident_id,
project_id="awoooi",
)
assert result.incident_id == durable.incident_id
assert result.status == IncidentStatus.RESOLVED.value
service.get_for_readback.assert_awaited_once_with(durable.incident_id)
service.get_for_readback.assert_awaited_once_with(
durable.incident_id,
project_id="awoooi",
)
@pytest.mark.asyncio
@@ -98,7 +114,7 @@ async def test_active_incidents_come_from_durable_repository(monkeypatch):
lambda: repository,
)
incidents = await service.get_active_incidents()
incidents = await service.get_active_incidents(project_id="awoooi")
assert incidents == [durable_active]
repository.get_active.assert_awaited_once_with()
repository.get_active.assert_awaited_once_with(project_id="awoooi")

View File

@@ -15,7 +15,12 @@ from src.models.incident import Incident, Severity
class _IncidentService:
async def get_active_incidents(self) -> list[Incident]:
async def get_active_incidents(
self,
*,
project_id: str | None = None,
) -> list[Incident]:
assert project_id == "awoooi"
return [
Incident(
incident_id="INC-20260506-PURE01",
@@ -52,7 +57,10 @@ async def test_list_incidents_does_not_trigger_ai_decision_by_default(monkeypatc
monkeypatch.setattr(incidents_api, "get_incident_service", lambda: _IncidentService())
monkeypatch.setattr(incidents_api, "get_decision_manager", lambda: decision_manager)
result = await incidents_api.list_incidents(generate_missing_decisions=False)
result = await incidents_api.list_incidents(
project_id="awoooi",
generate_missing_decisions=False,
)
assert result.count == 1
assert result.incidents[0].incident_id == "INC-20260506-PURE01"