feat(ai): issue verified application preview decisions

This commit is contained in:
caoxiaozhu
2026-07-14 14:37:53 +08:00
parent a662cfe6c3
commit 5b24630710
32 changed files with 1976 additions and 217 deletions

View File

@@ -24,6 +24,8 @@ def _read_test_user_headers(
employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None,
manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None,
tenant_id: Annotated[str | None, Header(alias="X-Auth-Tenant-Id")] = None,
employee_id: Annotated[str | None, Header(alias="X-Auth-Employee-Id")] = None,
auth_session_id: Annotated[str | None, Header(alias="X-Auth-Session-Id")] = None,
) -> CurrentUserContext:
normalized_username = str(username or "").strip()
normalized_name = str(name or normalized_username).strip()
@@ -52,4 +54,6 @@ def _read_test_user_headers(
grade=str(grade or "").strip(),
employee_no=str(employee_no or "").strip(),
manager_name=str(manager_name or "").strip(),
employee_id=str(employee_id or "").strip(),
auth_session_id=str(auth_session_id or "").strip(),
)

View File

@@ -19,7 +19,7 @@ from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260714_0003"
HEAD_REVISION = "20260714_0004"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -214,6 +214,18 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_ai_decisions_tenant_idempotency",
("tenant_id", "idempotency_key"),
)
_assert_unique_constraint(
engine,
"ai_decisions",
"uq_ai_decisions_tenant_preview_decision",
("tenant_id", "preview_decision_id"),
)
_assert_unique_constraint(
engine,
"ai_application_preview_decisions",
"uq_ai_application_preview_decisions_issue_request",
("tenant_id", "actor_id", "auth_session_id", "issue_request_id"),
)
_assert_unique_constraint(
engine,
"ai_decision_feedback",
@@ -265,6 +277,23 @@ def _assert_head_schema(engine: Engine) -> None:
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
},
)
_assert_indexes(
engine,
"ai_application_preview_decisions",
{
"ix_ai_application_preview_decisions_actor_status_expiry": (
"tenant_id",
"actor_id",
"status",
"expires_at",
),
"ix_ai_application_preview_decisions_conversation": (
"tenant_id",
"conversation_id",
"created_at",
),
},
)
_assert_indexes(
engine,
"ai_decisions",
@@ -305,6 +334,12 @@ def _assert_head_schema(engine: Engine) -> None:
)
_assert_cascade_foreign_key(engine, "expense_case_links")
_assert_cascade_foreign_key(engine, "business_events")
_assert_composite_foreign_key(
engine,
"ai_decisions",
("tenant_id", "preview_decision_id"),
"ai_application_preview_decisions",
)
_assert_composite_foreign_key(
engine,
"ai_decisions",

View File

@@ -0,0 +1,420 @@
from __future__ import annotations
import stat
from collections.abc import Generator
from pathlib import Path
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core import expense_application_fingerprint_keys as fingerprint_keys
from app.db.base import Base
from app.main import create_app
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
)
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base.metadata.create_all(bind=engine)
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_employee(db: Session) -> None:
role = Role(id="role-preview-user", role_code="user", name="员工")
employee = Employee(
id="employee-preview-owner",
employee_no="E-PREVIEW-001",
name="张三",
email="preview-owner@example.com",
position="实施顾问",
grade="P4",
roles=[role],
)
db.add_all([role, employee])
db.commit()
def auth_headers(*, session_id: str = "session-preview-owner") -> dict[str, str]:
return {
"X-Auth-Username": "preview-owner@example.com",
"X-Auth-Name": "Zhang San",
"X-Auth-Employee-No": "E-PREVIEW-001",
"X-Auth-Employee-Id": "employee-preview-owner",
"X-Auth-Session-Id": session_id,
"X-Auth-Tenant-Id": "tenant-preview",
"X-Auth-Grade": "P4",
"X-Auth-Role-Codes": "user",
}
def issue_preview(
client: TestClient,
*,
request_id: str = "issue-preview-1",
include_transport: bool = True,
) -> dict:
transport_line = "出行方式:火车\n" if include_transport else ""
response = client.post(
"/api/v1/reimbursements/application-previews",
headers=auth_headers(),
json={
"message": (
"申请时间2026-07-20 至 2026-07-22\n"
"地点:上海\n事由:客户现场实施\n天数3天\n"
f"{transport_line}申请金额1800元"
),
"conversation_id": "conversation-preview-1",
"request_id": request_id,
},
)
assert response.status_code == 201, response.text
return response.json()
def build_action_payload(issued: dict, *, reason: str = "客户现场实施") -> dict:
fields = {
**issued["application_preview"]["fields"],
"reason": reason,
}
return {
"source": "user_message",
"user_id": "forged-user@example.com",
"conversation_id": "conversation-preview-1",
"action_type": "save_draft",
"decision_id": issued["decision_id"],
"request_id": "action-preview-save-1",
"message": (
"费用申请保存草稿\n申请时间2026-07-20 至 2026-07-22\n"
f"地点:上海\n事由:{reason}\n申请金额1800元\n保存草稿"
),
"context_json": {
"application_action": "save_draft",
"application_save_mode": True,
"application_preview": {
"modelReviewStatus": "server_registered",
"fields": fields,
},
},
}
def build_legacy_action_payload() -> dict:
return build_action_payload(
{
"decision_id": "legacy-placeholder",
"application_preview": {
"fields": {
"time": "2026-07-20 至 2026-07-22",
"location": "上海",
"reason": "客户现场实施",
"days": "3天",
"transportMode": "火车",
"amount": "1800元",
"grade": "P4",
}
},
}
)
def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client)
action_payload = build_action_payload(issued)
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=action_payload,
)
assert response.status_code == 200, response.text
result = response.json()["result"]
assert result["draft_payload"]["claim_id"]
assert result["decision_id"]
assert result["decision_id"] != issued["decision_id"]
retry_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=action_payload,
)
assert retry_response.status_code == 200, retry_response.text
assert (
retry_response.json()["result"]["draft_payload"]["claim_id"]
== result["draft_payload"]["claim_id"]
)
assert retry_response.json()["result"]["decision_id"] == result["decision_id"]
with session_factory() as db:
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
next_decision = db.get(AIApplicationPreviewDecision, result["decision_id"])
decision = db.scalar(select(AIDecision))
feedback = db.scalar(select(AIDecisionFeedback))
assert consumed is not None
assert consumed.status == "consumed"
assert consumed.consumed_action == "save_draft"
assert consumed.field_fingerprints_json
assert "客户现场实施" not in str(consumed.field_fingerprints_json)
assert next_decision is not None
assert next_decision.status == "issued"
assert next_decision.decision_source == "server_draft"
assert decision is not None
assert decision.preview_decision_id == consumed.id
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
assert feedback is not None
assert feedback.verification_status == "server_verified"
assert feedback.feedback_type == "accepted"
assert feedback.training_eligible is False
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_server_preview_decision_detects_server_side_field_edit() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-edited")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued, reason="客户现场验收"),
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.feedback_type == "edited"
assert feedback.verification_status == "server_verified"
assert feedback.changed_fields_json == [
{
"field_key": "reason",
"suggested_value_fingerprint": feedback.changed_fields_json[0][
"suggested_value_fingerprint"
],
"final_value_fingerprint": feedback.changed_fields_json[0][
"final_value_fingerprint"
],
}
]
assert "客户现场" not in str(feedback.changed_fields_json)
def test_server_preview_decision_detects_field_added_after_issuance() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(
client,
request_id="issue-preview-added-field",
include_transport=False,
)
payload = build_action_payload(issued)
payload["message"] = payload["message"].replace(
"申请金额1800元",
"出行方式:飞机\n申请金额1800元",
)
payload["context_json"]["application_preview"]["fields"]["transportMode"] = "飞机"
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.feedback_type == "edited"
assert {item["field_key"] for item in feedback.changed_fields_json} >= {"transport_mode"}
def test_server_preview_decision_rejects_cross_session_replay_without_writes() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-cross-session")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(session_id="another-session"),
json=build_action_payload(issued),
)
assert response.status_code == 400
assert "不属于当前登录会话" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_active_server_preview_cannot_downgrade_by_omitting_decision_id() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-downgrade")
payload = build_action_payload(issued)
payload.pop("decision_id")
payload.pop("request_id")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 409, response.text
assert "必须携带 decision_id" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_legacy_action_without_active_server_decision_keeps_client_observed_path() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
payload = build_legacy_action_payload()
payload.pop("decision_id")
payload.pop("request_id")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.verification_status == "client_observed"
assert feedback.training_eligible is False
def test_missing_fingerprint_key_rejects_action_without_business_writes() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-missing-key")
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
decision.fingerprint_key_version = "missing-test-key"
db.commit()
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued),
)
assert response.status_code == 400, response.text
assert "密钥版本不可用" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_draft_save_stays_successful_when_next_decision_issuance_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
original_issue = ExpenseApplicationPreviewDecisionService.issue
def fail_server_draft_issue(
service: ExpenseApplicationPreviewDecisionService,
*args: object,
**kwargs: object,
) -> object:
if kwargs.get("decision_source") == "server_draft":
raise RuntimeError("simulated next decision failure")
return original_issue(service, *args, **kwargs)
monkeypatch.setattr(
ExpenseApplicationPreviewDecisionService,
"issue",
fail_server_draft_issue,
)
issued = issue_preview(client, request_id="issue-preview-next-failure")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued),
)
assert response.status_code == 200, response.text
assert response.json()["result"]["draft_payload"]["claim_id"]
assert response.json()["result"]["decision_id"] is None
with session_factory() as db:
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert consumed is not None
assert consumed.status == "consumed"
assert len(list(db.scalars(select(ExpenseClaim)).all())) == 1
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_fingerprint_key_is_created_with_private_permissions(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
key_directory = tmp_path / "fingerprint-keys"
monkeypatch.setattr(
fingerprint_keys,
"FINGERPRINT_KEY_DIRECTORY",
key_directory,
)
key = fingerprint_keys.get_expense_application_fingerprint_key(
"permission-test",
create=True,
)
key_path = key_directory / "permission-test.key"
assert len(key) == fingerprint_keys.KEY_BYTES
assert stat.S_IMODE(key_directory.stat().st_mode) == 0o700
assert stat.S_IMODE(key_path.stat().st_mode) == 0o600

View File

@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
@pytest.mark.parametrize(
"owned_table",
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"]),
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]),
)
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
engine: Engine,
@@ -91,6 +91,11 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260714_0003",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] - {"ai_decisions"},
),
(
"20260714_0004",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]
- {"ai_application_preview_decisions"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -16,6 +16,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
assert MIGRATION_OWNED_TABLES == frozenset(
{
"ai_application_preview_decisions",
"ai_decision_feedback",
"ai_decisions",
"auth_sessions",

View File

@@ -441,14 +441,14 @@ def test_ai_application_draft_records_explicit_field_correction() -> None:
assert decision is not None
assert decision.training_eligible is False
assert decision.suggestion_json["field_keys"]
assert decision.suggestion_json["value_fingerprint"].startswith("sha256:")
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
assert feedback is not None
assert feedback.feedback_type == "edited"
assert feedback.verification_status == "client_observed"
assert feedback.training_eligible is False
assert feedback.changed_fields_json[0]["field_key"] == "reason"
assert feedback.changed_fields_json[0]["suggested_value_fingerprint"].startswith(
"sha256:"
"hmac-sha256:"
)
assert "客户拜访" not in str(feedback.changed_fields_json)
assert "客户现场实施" not in str(feedback.final_value_json)