feat: 迁移奶龙风控业务模块与演示文档
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.controllers import risk as risk_controller
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.core.contracts import RequestContext
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class StubRiskQueryService:
|
||||
def __init__(self, _session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def overview(self, _context: RequestContext) -> dict[str, Any]:
|
||||
return {"total": 2, "levels": {"高风险": 1}, "pending": 1, "overdue": 0}
|
||||
|
||||
async def list_alerts(self, _context: RequestContext, _query: Any) -> dict[str, Any]:
|
||||
return {"items": [{"alert_no": "ALERT-001"}], "next_cursor": None, "has_more": False}
|
||||
|
||||
async def get_alert_detail(self, _context: RequestContext, alert_no: str) -> dict[str, Any]:
|
||||
return {"alert": {"alert_no": alert_no}}
|
||||
|
||||
async def list_evidence(
|
||||
self,
|
||||
_context: RequestContext,
|
||||
source: str,
|
||||
_query: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {"items": [{"source": source}], "next_cursor": None, "has_more": False}
|
||||
|
||||
|
||||
class StubRiskScanService:
|
||||
def __init__(self, _session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def scan(self, _context: RequestContext) -> dict[str, Any]:
|
||||
return {"message": "规则扫描完成", "created_count": 2, "high_risk_count": 1}
|
||||
|
||||
|
||||
class StubRiskActionService:
|
||||
def __init__(self, _session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def acknowledge(self, alert_no: str, _context: RequestContext) -> dict[str, Any]:
|
||||
return {"alert_no": alert_no, "status": "待处理", "ack_status": "已确认"}
|
||||
|
||||
async def investigate(self, alert_no: str, _context: RequestContext) -> dict[str, Any]:
|
||||
return {"alert_no": alert_no, "status": "调查中"}
|
||||
|
||||
async def exclude(
|
||||
self, alert_no: str, reason: str, _context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
return {"alert_no": alert_no, "status": "已排除", "handle_result": reason}
|
||||
|
||||
async def resolve(
|
||||
self, alert_no: str, resolution: str, _context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
return {"alert_no": alert_no, "status": "已结案", "handle_result": resolution}
|
||||
|
||||
async def escalate(
|
||||
self, alert_no: str, reason: str, _context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
return {"alert_no": alert_no, "is_escalated": True, "escalation_reason": reason}
|
||||
|
||||
|
||||
class StubRiskEvidenceArchiveService:
|
||||
def __init__(self, _session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def archive(self, alert_no: str, upload: Any, _context: RequestContext) -> dict[str, Any]:
|
||||
return {
|
||||
"alert_no": alert_no,
|
||||
"evidence_archived": True,
|
||||
"stored_name": upload.filename,
|
||||
"file_size": 8,
|
||||
}
|
||||
|
||||
|
||||
class StubRiskNotificationService:
|
||||
def __init__(self, _session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def list_notifications(self, _context: RequestContext, _query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"items": [{"notification_id": "N-001", "alert_no": "ALERT-001"}],
|
||||
"next_cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
class StubRiskDailyReportService:
|
||||
def __init__(self, _session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def generate(self, _context: RequestContext, _report_time: Any) -> dict[str, Any]:
|
||||
return {"report_date": "2026-09-10", "content": "日报正文"}
|
||||
|
||||
async def stream(self, _context: RequestContext, _report_time: Any):
|
||||
yield {"type": "start", "generated_at": "2026-09-10T00:00:00"}
|
||||
yield {"type": "done", "report": {"report_date": "2026-09-10"}}
|
||||
|
||||
|
||||
class StubRiskDailyReportMailService:
|
||||
def send(self, recipients: list[str], _subject: str, _content: str) -> dict[str, Any]:
|
||||
return {"status": "dry_run", "recipient_count": len(recipients)}
|
||||
|
||||
|
||||
def authenticated_client(monkeypatch) -> TestClient:
|
||||
async def context() -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="990000002",
|
||||
trace_id="risk-trace",
|
||||
permissions=("risk:alert:read",),
|
||||
data_scope="all",
|
||||
)
|
||||
|
||||
async def session():
|
||||
yield None
|
||||
|
||||
monkeypatch.setattr(risk_controller, "RiskQueryService", StubRiskQueryService)
|
||||
monkeypatch.setattr(risk_controller, "RiskScanService", StubRiskScanService)
|
||||
monkeypatch.setattr(risk_controller, "RiskActionService", StubRiskActionService)
|
||||
monkeypatch.setattr(
|
||||
risk_controller,
|
||||
"RiskEvidenceArchiveService",
|
||||
StubRiskEvidenceArchiveService,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
risk_controller,
|
||||
"RiskNotificationService",
|
||||
StubRiskNotificationService,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
risk_controller,
|
||||
"RiskDailyReportService",
|
||||
StubRiskDailyReportService,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
risk_controller,
|
||||
"RiskDailyReportMailService",
|
||||
StubRiskDailyReportMailService,
|
||||
)
|
||||
application = create_app()
|
||||
application.dependency_overrides[build_request_context] = context
|
||||
application.dependency_overrides[get_session] = session
|
||||
return TestClient(application)
|
||||
|
||||
|
||||
def test_risk_routes_require_authentication() -> None:
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/api/v1/risk/overview")
|
||||
scan = client.post("/api/v1/risk/alerts/scan")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
|
||||
assert scan.status_code == 401
|
||||
|
||||
|
||||
def test_overview_uses_success_envelope(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
response = client.get("/api/v1/risk/overview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"data": {
|
||||
"total": 2,
|
||||
"levels": {"高风险": 1},
|
||||
"pending": 1,
|
||||
"overdue": 0,
|
||||
},
|
||||
"meta": {"trace_id": "risk-trace"},
|
||||
}
|
||||
|
||||
|
||||
def test_alert_and_detail_routes_bind_parameters(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
alerts = client.get("/api/v1/risk/alerts?limit=5&risk_level=高")
|
||||
detail = client.get("/api/v1/risk/alerts/ALERT-001")
|
||||
|
||||
assert alerts.status_code == 200
|
||||
assert alerts.json()["data"]["items"][0]["alert_no"] == "ALERT-001"
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["data"]["alert"]["alert_no"] == "ALERT-001"
|
||||
|
||||
|
||||
def test_evidence_route_and_page_limit_are_enforced(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
valid = client.get("/api/v1/risk/evidence/customers?limit=10")
|
||||
invalid = client.get("/api/v1/risk/evidence/customers?limit=11")
|
||||
|
||||
assert valid.status_code == 200
|
||||
assert valid.json()["data"]["items"] == [{"source": "customers"}]
|
||||
assert invalid.status_code == 422
|
||||
|
||||
|
||||
def test_scan_route_returns_success_envelope(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
response = client.post("/api/v1/risk/alerts/scan")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"data": {
|
||||
"message": "规则扫描完成",
|
||||
"created_count": 2,
|
||||
"high_risk_count": 1,
|
||||
},
|
||||
"meta": {"trace_id": "risk-trace"},
|
||||
}
|
||||
|
||||
|
||||
def test_action_routes_require_authentication() -> None:
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_action_routes_use_success_envelope_and_validate_body(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
acknowledged = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements")
|
||||
investigated = client.post("/api/v1/risk/alerts/ALERT-001/investigations")
|
||||
excluded = client.post(
|
||||
"/api/v1/risk/alerts/ALERT-001/exclusions",
|
||||
json={"reason": "客户本人确认"},
|
||||
)
|
||||
resolved = client.post(
|
||||
"/api/v1/risk/alerts/ALERT-001/resolutions",
|
||||
json={"resolution": "已核实并留痕"},
|
||||
)
|
||||
escalated = client.post(
|
||||
"/api/v1/risk/alerts/ALERT-001/escalations",
|
||||
json={"reason": "需要高级复核"},
|
||||
)
|
||||
invalid = client.post(
|
||||
"/api/v1/risk/alerts/ALERT-001/exclusions",
|
||||
json={"reason": " "},
|
||||
)
|
||||
|
||||
assert acknowledged.status_code == 200
|
||||
assert investigated.status_code == 200
|
||||
assert excluded.json()["data"]["handle_result"] == "客户本人确认"
|
||||
assert resolved.json()["data"]["handle_result"] == "已核实并留痕"
|
||||
assert escalated.json()["data"]["is_escalated"] is True
|
||||
assert invalid.status_code == 422
|
||||
|
||||
|
||||
def test_evidence_upload_uses_success_envelope(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
response = client.post(
|
||||
"/api/v1/risk/alerts/ALERT-001/evidence",
|
||||
files={"evidence_file": ("ALERT-001.png", b"png-data", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] == {
|
||||
"alert_no": "ALERT-001",
|
||||
"evidence_archived": True,
|
||||
"stored_name": "ALERT-001.png",
|
||||
"file_size": 8,
|
||||
}
|
||||
|
||||
|
||||
def test_notification_query_uses_notification_schema(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
response = client.get("/api/v1/risk/notifications?limit=10&send_status=已发送")
|
||||
invalid = client.get("/api/v1/risk/notifications?limit=11")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["items"] == [
|
||||
{"notification_id": "N-001", "alert_no": "ALERT-001"}
|
||||
]
|
||||
assert invalid.status_code == 422
|
||||
|
||||
|
||||
def test_daily_report_generate_stream_and_mail(monkeypatch) -> None:
|
||||
with authenticated_client(monkeypatch) as client:
|
||||
generated = client.post(
|
||||
"/api/v1/risk/daily-report",
|
||||
json={"report_date": "2026-09-10"},
|
||||
)
|
||||
streamed = client.post(
|
||||
"/api/v1/risk/daily-report/stream",
|
||||
json={"report_date": "2026-09-10"},
|
||||
)
|
||||
mailed = client.post(
|
||||
"/api/v1/risk/daily-report/mail",
|
||||
json={
|
||||
"recipients": ["risk@example.com", "RISK@example.com"],
|
||||
"subject": "风控日报",
|
||||
"content": "日报正文",
|
||||
},
|
||||
)
|
||||
invalid = client.post(
|
||||
"/api/v1/risk/daily-report/mail",
|
||||
json={
|
||||
"recipients": ["not-an-email"],
|
||||
"subject": "风控日报",
|
||||
"content": "日报正文",
|
||||
},
|
||||
)
|
||||
|
||||
assert generated.status_code == 200
|
||||
assert generated.json()["data"]["content"] == "日报正文"
|
||||
assert streamed.status_code == 200
|
||||
assert streamed.headers["content-type"].startswith("text/event-stream")
|
||||
assert "event: start" in streamed.text
|
||||
assert "event: done" in streamed.text
|
||||
assert mailed.status_code == 200
|
||||
assert mailed.json()["data"] == {"status": "dry_run", "recipient_count": 1}
|
||||
assert invalid.status_code == 422
|
||||
Reference in New Issue
Block a user