feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -0,0 +1,367 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.v1.endpoints import steward as steward_endpoint
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.models.financial_record import ExpenseClaim
from app.schemas.steward import (
StewardPlanResponse,
StewardRuntimeDecisionResponse,
StewardSlotDecisionResponse,
)
from app.services.expense_cases import ExpenseCaseService
from app.test_helpers.db import build_in_memory_session_factory
def _current_user(*, tenant_id: str = "tenant-a") -> CurrentUserContext:
return CurrentUserContext(
username="trusted.user@example.com",
name="可信用户",
role_codes=["user"],
is_admin=False,
tenant_id=tenant_id,
department_name="交付部",
department_id="dept-delivery",
cost_center="CC-TRUSTED",
position="实施顾问",
grade="P6",
employee_no="E-TRUSTED",
employee_id="employee-trusted",
manager_name="可信经理",
auth_session_id="session-secret",
)
def _build_client(
*,
current_user: CurrentUserContext | None,
) -> tuple[TestClient, object, object]:
session_factory = build_in_memory_session_factory()
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
if current_user is not None:
app.dependency_overrides[get_current_user] = lambda: current_user
return TestClient(app), session_factory, app
@pytest.mark.parametrize(
("path", "payload"),
[
("/api/v1/steward/plans", {"message": "报销交通费"}),
("/api/v1/steward/plans/stream", {"message": "报销交通费"}),
(
"/api/v1/steward/slot-decisions",
{"task_type": "reimbursement", "user_message": "报销交通费"},
),
(
"/api/v1/steward/runtime-decisions",
{"user_message": "继续处理"},
),
],
)
def test_steward_ai_endpoints_require_authenticated_user(
path: str,
payload: dict[str, object],
) -> None:
client, _session_factory, app = _build_client(current_user=None)
try:
response = client.post(path, json=payload)
assert response.status_code == 401
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize(
("path", "payload"),
[
("/api/v1/steward/plans", {"message": "报销交通费"}),
("/api/v1/steward/plans/stream", {"message": "报销交通费"}),
(
"/api/v1/steward/slot-decisions",
{"task_type": "reimbursement", "user_message": "报销交通费"},
),
(
"/api/v1/steward/runtime-decisions",
{"user_message": "继续处理"},
),
],
)
def test_steward_ai_endpoints_fail_closed_without_tenant(
path: str,
payload: dict[str, object],
) -> None:
client, _session_factory, app = _build_client(
current_user=_current_user(tenant_id=""),
)
try:
response = client.post(path, json=payload)
assert response.status_code == 403
assert "缺少租户归属" in response.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_steward_plan_and_stream_use_authenticated_identity(monkeypatch) -> None:
captured_payloads = []
class CapturingPlanner:
@staticmethod
def _clean_text(value):
return str(value or "").strip()
@staticmethod
def _resolve_base_date(_client_now_iso, _context_json):
return datetime(2026, 7, 16, tzinfo=UTC).date()
@staticmethod
def _looks_like_ambiguous_travel_flow(_message, _base_date, _payload):
return False
@staticmethod
def build_plan(payload):
captured_payloads.append(payload)
return StewardPlanResponse(
plan_id=f"plan-auth-{len(captured_payloads)}",
summary="已按可信登录身份生成计划。",
)
monkeypatch.setattr(
steward_endpoint,
"_build_steward_planner",
lambda _db: CapturingPlanner(),
)
current_user = _current_user()
client, session_factory, app = _build_client(current_user=current_user)
forged_context = {
"tenant_id": "tenant-forged",
"tenantId": "tenant-forged-camel",
"user_id": "forged-user",
"userId": "forged-user-camel",
"username": "forged@example.com",
"role_codes": ["admin"],
"is_admin": True,
"auth_session_id": "forged-session",
"session_type": "steward",
}
try:
plan_response = client.post(
"/api/v1/steward/plans",
json={
"message": "报销交通费",
"user_id": "forged-user",
"context_json": forged_context,
},
)
assert plan_response.status_code == 200
with client.stream(
"POST",
"/api/v1/steward/plans/stream",
json={
"message": "继续报销交通费",
"user_id": "forged-user",
"context_json": forged_context,
},
) as stream_response:
assert stream_response.status_code == 200
assert any(stream_response.iter_lines())
assert len(captured_payloads) == 2
for captured in captured_payloads:
assert captured.user_id == current_user.username
assert captured.context_json["tenant_id"] == "tenant-a"
assert captured.context_json["user_id"] == current_user.username
assert captured.context_json["username"] == current_user.username
assert captured.context_json["role_codes"] == ["user"]
assert captured.context_json["is_admin"] is False
assert "tenantId" not in captured.context_json
assert "userId" not in captured.context_json
assert "auth_session_id" not in captured.context_json
with session_factory() as db:
conversations = list(db.scalars(select(AgentConversation)).all())
assert len(conversations) == 2
assert all(item.user_id == current_user.username for item in conversations)
assert all(
item.state_json.get("tenant_id") == "tenant-a"
for item in conversations
)
finally:
app.dependency_overrides.clear()
def test_steward_slot_and_runtime_ignore_forged_identity(monkeypatch) -> None:
captured_slot_payloads = []
captured_runtime_payloads = []
def fake_slot_decision(payload, _runtime_chat):
captured_slot_payloads.append(payload)
return StewardSlotDecisionResponse(
next_action="ask_user",
question="请补充金额。",
)
def fake_runtime_decision(payload, _runtime_chat):
captured_runtime_payloads.append(payload)
return StewardRuntimeDecisionResponse(next_action="no_op")
monkeypatch.setattr(steward_endpoint, "_decide_steward_slot", fake_slot_decision)
monkeypatch.setattr(
steward_endpoint,
"_decide_steward_runtime",
fake_runtime_decision,
)
client, _session_factory, app = _build_client(current_user=_current_user())
try:
slot_response = client.post(
"/api/v1/steward/slot-decisions",
json={
"task_type": "reimbursement",
"user_message": "报销交通费",
"task_context": {
"tenant_id": "tenant-forged",
"tenantId": "tenant-forged-camel",
"username": "forged@example.com",
"is_admin": True,
},
},
)
runtime_response = client.post(
"/api/v1/steward/runtime-decisions",
json={
"user_message": "继续处理",
"context_json": {
"tenant_id": "tenant-forged",
"tenantId": "tenant-forged-camel",
"username": "forged@example.com",
},
"runtime_state": {
"tenant_id": "tenant-forged-runtime",
"user_id": "forged-runtime-user",
},
},
)
assert slot_response.status_code == 200
assert runtime_response.status_code == 200
assert len(captured_slot_payloads) == 1
assert len(captured_runtime_payloads) == 1
slot_context = captured_slot_payloads[0].task_context
assert slot_context["tenant_id"] == "tenant-a"
assert slot_context["username"] == "trusted.user@example.com"
assert slot_context["is_admin"] is False
assert "tenantId" not in slot_context
runtime_payload = captured_runtime_payloads[0]
assert runtime_payload.context_json["tenant_id"] == "tenant-a"
assert runtime_payload.runtime_state["tenant_id"] == "tenant-a"
assert runtime_payload.context_json["user_id"] == "trusted.user@example.com"
assert runtime_payload.runtime_state["user_id"] == "trusted.user@example.com"
assert "tenantId" not in runtime_payload.context_json
finally:
app.dependency_overrides.clear()
def test_steward_runtime_rejects_cross_tenant_conversation(monkeypatch) -> None:
client, session_factory, app = _build_client(current_user=_current_user())
with session_factory() as db:
db.add(
AgentConversation(
conversation_id="conv-tenant-b",
user_id="trusted.user@example.com",
source="user_message",
state_json={
"tenant_id": "tenant-b",
"session_type": "steward",
"steward_state": {"active_flow": "travel_reimbursement"},
},
)
)
db.commit()
monkeypatch.setattr(
steward_endpoint,
"_decide_steward_runtime",
lambda *_args, **_kwargs: pytest.fail("越权会话不应进入决策服务"),
)
try:
response = client.post(
"/api/v1/steward/runtime-decisions",
json={
"user_message": "继续处理",
"context_json": {"conversation_id": "conv-tenant-b"},
},
)
assert response.status_code == 403
assert "不属于登录用户或租户" in response.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_steward_application_candidates_are_tenant_scoped() -> None:
_client, session_factory, app = _build_client(current_user=_current_user())
try:
with session_factory() as db:
for tenant_id, suffix in (("tenant-a", "A"), ("tenant-b", "B")):
claim = ExpenseClaim(
id=f"application-tenant-{suffix.lower()}",
tenant_id=tenant_id,
claim_no=f"AP-TENANT-{suffix}",
employee_id="employee-trusted",
employee_name="可信用户",
department_name="交付部",
expense_type="travel_application",
reason="上海客户现场部署",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 7, 20, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add(claim)
db.flush()
ExpenseCaseService(db).ensure_case_for_claim(
claim,
tenant_id=tenant_id,
)
db.commit()
payload = steward_endpoint._bind_authenticated_plan_request(
steward_endpoint.StewardPlanRequest(
message="7月20日去上海出差继续发起报销",
context_json={},
),
_current_user(),
)
candidates = steward_endpoint._query_required_application_gate_candidates(
db,
payload,
payload.context_json,
tenant_id="tenant-a",
)
assert [item["claim_no"] for item in candidates] == ["AP-TENANT-A"]
finally:
app.dependency_overrides.clear()