diff --git a/backend/app/main.py b/backend/app/main.py index 7dc299f..b737330 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.routers.auth import router as auth_router from app.routers.companies import router as companies_router from app.routers.dashboard import router as dashboard_router from app.routers.reports import router as reports_router +from app.routers.risks import router as risks_router from app.schemas.common import error @@ -73,3 +74,4 @@ app.include_router(auth_router, prefix="/api/v1") app.include_router(companies_router, prefix="/api/v1") app.include_router(reports_router, prefix="/api/v1") app.include_router(dashboard_router, prefix="/api/v1") +app.include_router(risks_router, prefix="/api/v1") diff --git a/backend/app/routers/risks.py b/backend/app/routers/risks.py new file mode 100644 index 0000000..48d7543 --- /dev/null +++ b/backend/app/routers/risks.py @@ -0,0 +1,160 @@ +"""风险事件路由:CRUD + 状态流转。""" + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.models.company import Company +from app.models.risk import RiskEvent +from app.models.user import User +from app.schemas.common import ApiResponse, success +from app.schemas.risk import ( + RiskEventCreate, + RiskEventListResponse, + RiskEventResponse, + RiskEventUpdate, +) + +router = APIRouter(prefix="/risks", tags=["risks"]) + + +@router.get("", response_model=ApiResponse[RiskEventListResponse]) +async def list_risks( + company_id: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + severity: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取风险事件列表。""" + query = ( + select(RiskEvent) + .join(Company, RiskEvent.company_id == Company.id) + .where(Company.tenant_id == user.tenant_id) + ) + + if company_id: + query = query.where(RiskEvent.company_id == company_id) + if status_filter: + query = query.where(RiskEvent.status == status_filter) + if severity: + query = query.where(RiskEvent.severity == severity) + + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar_one() + + offset = (page - 1) * page_size + query = query.order_by(RiskEvent.identified_at.desc()).offset(offset).limit(page_size) + result = await db.execute(query) + risks = result.scalars().all() + + return success( + data=RiskEventListResponse( + items=[RiskEventResponse.model_validate(r, from_attributes=True) for r in risks], + total=total, + page=page, + page_size=page_size, + ) + ) + + +@router.get("/{risk_id}", response_model=ApiResponse[RiskEventResponse]) +async def get_risk( + risk_id: str, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取风险事件详情。""" + result = await db.execute( + select(RiskEvent) + .join(Company, RiskEvent.company_id == Company.id) + .where(RiskEvent.id == risk_id, Company.tenant_id == user.tenant_id) + ) + risk = result.scalar_one_or_none() + if not risk: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="风险事件不存在") + return success(data=RiskEventResponse.model_validate(risk, from_attributes=True)) + + +@router.post("", response_model=ApiResponse[RiskEventResponse], status_code=status.HTTP_201_CREATED) +async def create_risk( + req: RiskEventCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建风险事件。""" + # 验证企业属于租户 + company_result = await db.execute( + select(Company).where(Company.id == req.company_id, Company.tenant_id == user.tenant_id) + ) + if not company_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在") + + risk = RiskEvent( + company_id=req.company_id, + type=req.type, + severity=req.severity, + title=req.title, + description=req.description, + suggested_action=req.suggested_action, + status="open", + ) + db.add(risk) + await db.flush() + return success( + data=RiskEventResponse.model_validate(risk, from_attributes=True), + message="创建成功", + ) + + +@router.put("/{risk_id}", response_model=ApiResponse[RiskEventResponse]) +async def update_risk( + risk_id: str, + req: RiskEventUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新风险事件(状态流转等)。""" + result = await db.execute( + select(RiskEvent) + .join(Company, RiskEvent.company_id == Company.id) + .where(RiskEvent.id == risk_id, Company.tenant_id == user.tenant_id) + ) + risk = result.scalar_one_or_none() + if not risk: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="风险事件不存在") + + update_data = req.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(risk, key, value) + + await db.flush() + return success( + data=RiskEventResponse.model_validate(risk, from_attributes=True), + message="更新成功", + ) + + +@router.delete("/{risk_id}", response_model=ApiResponse[None]) +async def delete_risk( + risk_id: str, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """删除风险事件。""" + result = await db.execute( + select(RiskEvent) + .join(Company, RiskEvent.company_id == Company.id) + .where(RiskEvent.id == risk_id, Company.tenant_id == user.tenant_id) + ) + risk = result.scalar_one_or_none() + if not risk: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="风险事件不存在") + + await db.delete(risk) + return success(message="删除成功") diff --git a/backend/app/schemas/risk.py b/backend/app/schemas/risk.py new file mode 100644 index 0000000..1cf03d6 --- /dev/null +++ b/backend/app/schemas/risk.py @@ -0,0 +1,54 @@ +"""风险事件相关 Pydantic schema。""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class RiskEventCreate(BaseModel): + """创建风险事件。""" + + company_id: str + type: str = Field(..., description="financial/operational/org/ai_specific") + severity: str = Field(default="medium", description="low/medium/high/critical") + title: str = Field(..., min_length=1, max_length=200) + description: str | None = None + suggested_action: str | None = None + + +class RiskEventUpdate(BaseModel): + """更新风险事件。""" + + status: str | None = Field(default=None, description="open/assigned/in_progress/resolved/closed") + severity: str | None = None + assigned_to: str | None = None + description: str | None = None + suggested_action: str | None = None + + +class RiskEventResponse(BaseModel): + """风险事件响应。""" + + id: str + company_id: str + type: str + severity: str + status: str + title: str + description: str | None = None + evidence_json: dict | None = None + suggested_action: str | None = None + assigned_to: str | None = None + due_at: datetime | None = None + identified_at: datetime + closed_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class RiskEventListResponse(BaseModel): + """风险列表响应。""" + + items: list[RiskEventResponse] + total: int + page: int + page_size: int diff --git a/backend/tests/test_risks.py b/backend/tests/test_risks.py new file mode 100644 index 0000000..23c7737 --- /dev/null +++ b/backend/tests/test_risks.py @@ -0,0 +1,172 @@ +"""风险事件 CRUD 测试。""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from app.core.database import Base, get_db +from app.main import app + +TEST_DATABASE_URL = "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true" + +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +test_session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + +async def override_get_db(): + async with test_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(scope="module", autouse=True) +async def setup_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def auth_headers(client: TestClient): + """注册并登录。""" + client.post( + "/api/v1/auth/register", + json={ + "email": "risk_test@example.com", + "password": "password123", + "name": "测试投资经理", + "tenant_name": "测试机构", + "role": "investor", + }, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "risk_test@example.com", "password": "password123"}, + ) + token = resp.json()["data"]["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def company_id(client: TestClient, auth_headers: dict): + """创建测试企业。""" + resp = client.post( + "/api/v1/companies", + json={"name": "风险测试公司", "industry": "AI"}, + headers=auth_headers, + ) + return resp.json()["data"]["id"] + + +class TestCreateRisk: + """创建风险事件。""" + + def test_create_success(self, client: TestClient, auth_headers: dict, company_id: str): + response = client.post( + "/api/v1/risks", + json={ + "company_id": company_id, + "type": "financial", + "severity": "high", + "title": "现金流预警", + "description": "月度烧钱率超过预期", + "suggested_action": "建议与创始人沟通融资计划", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["code"] == 0 + assert data["data"]["title"] == "现金流预警" + assert data["data"]["status"] == "open" + assert data["data"]["severity"] == "high" + + def test_create_without_auth(self, client: TestClient): + response = client.post( + "/api/v1/risks", + json={"company_id": "x", "type": "financial", "title": "测试"}, + ) + assert response.status_code == 401 + + +class TestListRisks: + """风险列表。""" + + def test_list_success(self, client: TestClient, auth_headers: dict, company_id: str): + # 先创建一条 + client.post( + "/api/v1/risks", + json={"company_id": company_id, "type": "operational", "title": "人员流失"}, + headers=auth_headers, + ) + response = client.get("/api/v1/risks", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["data"]["total"] >= 1 + + def test_list_by_status(self, client: TestClient, auth_headers: dict, company_id: str): + response = client.get( + "/api/v1/risks?status=open", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert all(item["status"] == "open" for item in data["data"]["items"]) + + +class TestUpdateRisk: + """更新风险事件。""" + + def test_update_status(self, client: TestClient, auth_headers: dict, company_id: str): + create_resp = client.post( + "/api/v1/risks", + json={"company_id": company_id, "type": "ai_specific", "title": "AI 依赖风险"}, + headers=auth_headers, + ) + risk_id = create_resp.json()["data"]["id"] + + response = client.put( + f"/api/v1/risks/{risk_id}", + json={"status": "in_progress", "assigned_to": "someone"}, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["status"] == "in_progress" + + +class TestDeleteRisk: + """删除风险事件。""" + + def test_delete_success(self, client: TestClient, auth_headers: dict, company_id: str): + create_resp = client.post( + "/api/v1/risks", + json={"company_id": company_id, "type": "org", "title": "组织架构风险"}, + headers=auth_headers, + ) + risk_id = create_resp.json()["data"]["id"] + + response = client.delete( + f"/api/v1/risks/{risk_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + + get_resp = client.get( + f"/api/v1/risks/{risk_id}", + headers=auth_headers, + ) + assert get_resp.status_code == 404 diff --git a/frontend/src/app/(investor)/risks/page.tsx b/frontend/src/app/(investor)/risks/page.tsx new file mode 100644 index 0000000..4f93fa7 --- /dev/null +++ b/frontend/src/app/(investor)/risks/page.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { AlertTriangle, Trash2, Filter } from "lucide-react"; +import { + listRisks, + updateRisk, + deleteRisk, + RISK_STATUS_LABELS, + RISK_STATUS_COLORS, + SEVERITY_LABELS, + SEVERITY_COLORS, + RISK_TYPE_LABELS, + type RiskEvent, +} from "@/lib/risks"; +import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; +import { EmptyState } from "@/components/shared/EmptyState"; + +/** + * 投资人端 — 风险工作台。 + */ +export default function RisksPage() { + const [risks, setRisks] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(""); + const [page, setPage] = useState(1); + const pageSize = 20; + + const loadRisks = useCallback(async () => { + setIsLoading(true); + try { + const resp = await listRisks({ + page, + page_size: pageSize, + status: statusFilter || undefined, + }); + if (resp.data) { + setRisks(resp.data.items); + setTotal(resp.data.total); + } + } catch { + setRisks([]); + } finally { + setIsLoading(false); + } + }, [page, statusFilter]); + + useEffect(() => { + loadRisks(); + }, [loadRisks]); + + async function handleStatusChange(id: string, newStatus: string) { + try { + await updateRisk(id, { status: newStatus }); + loadRisks(); + } catch (err) { + alert(err instanceof Error ? err.message : "更新失败"); + } + } + + async function handleDelete(id: string) { + if (!confirm("确认删除该风险事件?")) return; + try { + await deleteRisk(id); + loadRisks(); + } catch (err) { + alert(err instanceof Error ? err.message : "删除失败"); + } + } + + return ( +
+
+

风险工作台

+

共 {total} 条风险事件

+
+ + {/* 状态筛选 */} +
+
+ + {isLoading ? ( +
+ +
+ ) : risks.length === 0 ? ( + + ) : ( +
+ {risks.map((risk) => ( +
+
+
+
+
+
+ {RISK_TYPE_LABELS[risk.type] || risk.type} + + 严重度: {SEVERITY_LABELS[risk.severity] || risk.severity} + + {new Date(risk.identified_at).toLocaleDateString("zh-CN")} +
+ {risk.description && ( +

{risk.description}

+ )} + {risk.suggested_action && ( +

+ 建议:{risk.suggested_action} +

+ )} +
+
+ + +
+
+
+ ))} +
+ )} + + {total > pageSize && ( +
+ + + 第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页 + + +
+ )} +
+ ); +} diff --git a/frontend/src/lib/risks.ts b/frontend/src/lib/risks.ts new file mode 100644 index 0000000..3a51077 --- /dev/null +++ b/frontend/src/lib/risks.ts @@ -0,0 +1,112 @@ +/** 风险事件相关类型和 API 函数。 */ + +import { apiFetch, type ApiResponse } from "./api"; + +/** 风险事件。 */ +export interface RiskEvent { + id: string; + company_id: string; + type: string; + severity: string; + status: string; + title: string; + description: string | null; + evidence_json: Record | null; + suggested_action: string | null; + assigned_to: string | null; + due_at: string | null; + identified_at: string; + closed_at: string | null; + created_at: string; + updated_at: string; +} + +/** 风险列表响应。 */ +export interface RiskListResponse { + items: RiskEvent[]; + total: number; + page: number; + page_size: number; +} + +/** 状态标签。 */ +export const RISK_STATUS_LABELS: Record = { + open: "待处理", + assigned: "已分配", + in_progress: "处理中", + resolved: "已解决", + closed: "已关闭", +}; + +/** 状态颜色。 */ +export const RISK_STATUS_COLORS: Record = { + open: "bg-rose-100 text-rose-700", + assigned: "bg-amber-100 text-amber-700", + in_progress: "bg-blue-100 text-blue-700", + resolved: "bg-emerald-100 text-emerald-700", + closed: "bg-muted text-muted-foreground", +}; + +/** 严重程度标签。 */ +export const SEVERITY_LABELS: Record = { + low: "低", + medium: "中", + high: "高", + critical: "严重", +}; + +/** 严重程度颜色。 */ +export const SEVERITY_COLORS: Record = { + low: "text-emerald-600", + medium: "text-amber-600", + high: "text-orange-600", + critical: "text-rose-600", +}; + +/** 风险类型标签。 */ +export const RISK_TYPE_LABELS: Record = { + financial: "财务", + operational: "经营", + org: "组织", + ai_specific: "AI 相关", +}; + +/** + * 获取风险列表。 + */ +export async function listRisks(params?: { + company_id?: string; + status?: string; + severity?: string; + page?: number; + page_size?: number; +}): Promise> { + const query = new URLSearchParams(); + if (params?.company_id) query.set("company_id", params.company_id); + if (params?.status) query.set("status", params.status); + if (params?.severity) query.set("severity", params.severity); + if (params?.page) query.set("page", String(params.page)); + if (params?.page_size) query.set("page_size", String(params.page_size)); + return apiFetch(`/risks?${query.toString()}`); +} + +/** + * 更新风险事件。 + */ +export async function updateRisk(id: string, data: { + status?: string; + severity?: string; + assigned_to?: string; +}): Promise> { + return apiFetch(`/risks/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +/** + * 删除风险事件。 + */ +export async function deleteRisk(id: string): Promise> { + return apiFetch(`/risks/${id}`, { method: "DELETE" }); +}