feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -17,6 +17,7 @@ from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.user_session_metric import UserSessionMetric
|
||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_scheduler import HermesScheduler
|
||||
@@ -167,6 +168,36 @@ def _build_claim(
|
||||
)
|
||||
|
||||
|
||||
def add_closed_user_session(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: str,
|
||||
username: str,
|
||||
display_name: str = "",
|
||||
employee_no: str = "",
|
||||
duration_ms: int = 30 * 60 * 1000,
|
||||
) -> None:
|
||||
logout_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
login_at = logout_at - timedelta(milliseconds=duration_ms)
|
||||
db.add(
|
||||
UserSessionMetric(
|
||||
session_id=session_id,
|
||||
username=username,
|
||||
display_name=display_name or username,
|
||||
employee_no=employee_no,
|
||||
email=username if "@" in username else "",
|
||||
login_at=login_at,
|
||||
logout_at=logout_at,
|
||||
last_activity_at=logout_at,
|
||||
duration_ms=duration_ms,
|
||||
activity_event_count=8,
|
||||
logout_reason="manual",
|
||||
status="closed",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_service_scans_snapshots_and_filters_approval_scene() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -238,6 +269,18 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
EmployeeBehaviorProfileService(db).refresh_employee_profiles(
|
||||
employee_id="emp-main",
|
||||
window_days=(90,),
|
||||
expense_type_scope="overall",
|
||||
)
|
||||
add_closed_user_session(
|
||||
db,
|
||||
session_id="session-employee-current",
|
||||
username="zhangsan@example.com",
|
||||
display_name="张三",
|
||||
employee_no="E1001",
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -266,6 +309,9 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
|
||||
assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"}
|
||||
ai_profile = next(item for item in payload["profiles"] if item["profile_type"] == "ai_usage")
|
||||
assert ai_profile["metrics"]["ai_run_duration_ms"] == 120
|
||||
assert ai_profile["metrics"]["online_duration_ms"] == 30 * 60 * 1000
|
||||
assert ai_profile["metrics"]["usage_duration_ms"] == 30 * 60 * 1000
|
||||
assert ai_profile["metrics"]["usage_duration_mode"] == "online_session"
|
||||
assert payload["profile_tags"]
|
||||
assert payload["radar"]["dimensions"]
|
||||
|
||||
@@ -336,6 +382,98 @@ def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None:
|
||||
assert payload["radar"]["dimensions"]
|
||||
|
||||
|
||||
def test_current_admin_profile_endpoint_uses_online_session_without_agent_runs() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
add_closed_user_session(
|
||||
db,
|
||||
session_id="session-admin-online",
|
||||
username="admin",
|
||||
display_name="admin",
|
||||
duration_ms=12 * 60 * 1000,
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/employee-profiles/me/latest",
|
||||
params={
|
||||
"scene": "operations",
|
||||
"window_days": 90,
|
||||
"expense_type_scope": "overall",
|
||||
},
|
||||
headers={"x-auth-username": "admin", "x-auth-name": "admin", "x-auth-is-admin": "true"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["employee_id"] == "admin"
|
||||
assert payload["empty_reason"] == ""
|
||||
metrics = payload["profiles"][0]["metrics"]
|
||||
assert metrics["ai_run_count"] == 0
|
||||
assert metrics["online_duration_ms"] == 12 * 60 * 1000
|
||||
assert metrics["usage_duration_ms"] == 12 * 60 * 1000
|
||||
assert metrics["usage_duration_mode"] == "online_session"
|
||||
|
||||
|
||||
def test_finish_session_endpoint_closes_active_session() -> None:
|
||||
session_factory = build_session_factory()
|
||||
login_at = datetime.now(UTC) - timedelta(minutes=9)
|
||||
with session_factory() as db:
|
||||
db.add(
|
||||
UserSessionMetric(
|
||||
session_id="session-active-finish",
|
||||
username="zhangsan@example.com",
|
||||
display_name="张三",
|
||||
employee_no="E1001",
|
||||
email="zhangsan@example.com",
|
||||
login_at=login_at,
|
||||
last_activity_at=login_at,
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/auth/sessions/session-active-finish/finish",
|
||||
json={
|
||||
"reason": "manual",
|
||||
"lastActivityAt": datetime.now(UTC).isoformat(),
|
||||
"activityEventCount": 5,
|
||||
"pagePath": "/workbench",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["sessionId"] == "session-active-finish"
|
||||
assert payload["durationMs"] > 0
|
||||
with session_factory() as db:
|
||||
session = db.query(UserSessionMetric).filter_by(session_id="session-active-finish").one()
|
||||
assert session.status == "closed"
|
||||
assert session.activity_event_count == 5
|
||||
|
||||
|
||||
def test_hermes_scheduler_parses_weekly_profile_cron() -> None:
|
||||
scheduler = HermesScheduler()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user