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,462 @@
from __future__ import annotations
import json
import time
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
import pytest
from commercial_runtime_testkit import seed_meter
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
import app.models # noqa: F401 - 注册完整 metadata
from app.api.deps import CurrentUserContext
from app.db.base_class import Base
from app.models.commercial import UsageMeterEvent
from app.models.commercial_runtime import CommercialRuntimeReservation
from app.models.financial_connector import FinancialConnectorConfig, FinancialConnectorEvent
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.financial_connector import FinancialEventEnvelope
from app.services.expense_claim_attachment_commercial import (
ExpenseClaimAttachmentCommercialAccessDenied,
stage_attachment_deletion,
stage_attachment_replacement,
stage_claim_attachment_deletion,
)
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.financial_connector_auth import (
FinancialConnectorAuthError,
FinancialConnectorSecretResolver,
sign_financial_event,
)
from app.services.financial_connector_commercial import (
FinancialConnectorCommercialAccessDenied,
)
from app.services.financial_connector_ingestion import (
FinancialConnectorConflictError,
FinancialConnectorIngestionService,
)
@pytest.fixture()
def factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
result = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield result
finally:
Base.metadata.drop_all(engine)
engine.dispose()
def test_connector_only_meters_new_authenticated_committed_event(
factory: sessionmaker[Session],
) -> None:
now = datetime.now(UTC)
timestamp = int(now.timestamp())
with factory() as db:
claim = _seed_connector_boundary(db, now=now, hard_limit=Decimal("10"))
db.commit()
envelope = _envelope(claim, "SENSITIVE-EXTERNAL-EVENT-001", now)
first = _ingest(db, envelope, timestamp)
assert first.replayed is False
assert db.scalar(select(func.count(UsageMeterEvent.id))) == 0
db.commit()
replay = _ingest(db, envelope, timestamp)
db.commit()
assert replay.replayed is True
conflicting = envelope.model_copy(
update={"payload": {**envelope.payload, "amount": "66.01"}}
)
with pytest.raises(FinancialConnectorConflictError):
_ingest(db, conflicting, timestamp)
db.rollback()
with pytest.raises(FinancialConnectorAuthError):
_ingest(db, envelope, timestamp, signature="sha256=" + "0" * 64)
db.rollback()
usage = db.scalars(select(UsageMeterEvent)).one()
reservations = list(db.scalars(select(CommercialRuntimeReservation)).all())
assert Decimal(usage.quantity) == Decimal("1")
assert len(reservations) == 1 and reservations[0].status == "committed"
serialized = json.dumps(usage.metadata_json, ensure_ascii=False)
assert envelope.external_event_id not in serialized
assert envelope.correlation_id not in serialized
assert claim.claim_no not in serialized
def test_connector_rollback_releases_reservation_and_writes_no_usage(
factory: sessionmaker[Session],
) -> None:
now = datetime.now(UTC)
with factory() as db:
claim = _seed_connector_boundary(db, now=now, hard_limit=Decimal("10"))
db.commit()
envelope = _envelope(claim, "rollback-event", now)
_ingest(db, envelope, int(now.timestamp()))
db.rollback()
reservation = db.scalars(select(CommercialRuntimeReservation)).one()
assert reservation.status == "released"
assert db.scalar(select(func.count(UsageMeterEvent.id))) == 0
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 0
retry = _ingest(db, envelope, int(now.timestamp()))
db.commit()
assert retry.replayed is False
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 1
assert db.scalar(select(func.count(UsageMeterEvent.id))) == 1
db.refresh(reservation)
assert reservation.status == "committed"
def test_connector_quota_denial_happens_before_second_event_persistence(
factory: sessionmaker[Session],
) -> None:
now = datetime.now(UTC)
with factory() as db:
claim = _seed_connector_boundary(db, now=now, hard_limit=Decimal("1"))
db.commit()
_ingest(db, _envelope(claim, "quota-first", now), int(now.timestamp()))
db.commit()
with pytest.raises(FinancialConnectorCommercialAccessDenied):
_ingest(db, _envelope(claim, "quota-second", now), int(now.timestamp()))
db.rollback()
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 1
assert db.scalar(select(func.count(UsageMeterEvent.id))) == 1
def test_attachment_bytes_commit_and_rollback_follow_business_transaction(
factory: sessionmaker[Session],
tmp_path: Path,
) -> None:
now = datetime.now(UTC)
storage = _Storage(tmp_path / "expense-claims")
item = ExpenseClaimItem(id="item-a", claim_id="claim-a", invoice_id=None)
current_user = _user()
old_path = storage.build_item_dir("claim-a", "item-a") / "old.txt"
old_path.parent.mkdir(parents=True)
old_path.write_bytes(b"old")
item.invoice_id = storage.to_storage_key(old_path)
with factory() as db:
seed_meter(
db,
"tenant-a",
now,
basis="bytes",
tool_type="storage",
tool_name="attachment.upload",
hard_limit=Decimal("100"),
preflight_quantity=Decimal("100"),
)
db.commit()
stage_attachment_replacement(
db,
storage=storage,
item=item,
current_user=current_user,
claim_id="claim-a",
content=b"new-content",
request_id="upload-rollback",
)
new_path = storage.build_item_dir("claim-a", "item-a") / "new.txt"
new_path.parent.mkdir(parents=True)
new_path.write_bytes(b"new-content")
db.rollback()
assert old_path.read_bytes() == b"old"
assert not new_path.exists()
assert db.scalar(select(func.count(UsageMeterEvent.id))) == 0
assert db.scalars(select(CommercialRuntimeReservation)).one().status == "released"
stage_attachment_replacement(
db,
storage=storage,
item=item,
current_user=current_user,
claim_id="claim-a",
content=b"accepted",
request_id="upload-commit",
)
accepted_path = storage.build_item_dir("claim-a", "item-a") / "accepted.txt"
accepted_path.parent.mkdir(parents=True)
accepted_path.write_bytes(b"accepted")
db.commit()
assert accepted_path.read_bytes() == b"accepted"
assert not old_path.exists()
usage = db.scalars(select(UsageMeterEvent)).one()
assert Decimal(usage.quantity) == Decimal(len(b"accepted"))
assert usage.metadata_json["quantity_basis"] == "bytes"
@pytest.mark.parametrize(
("basis", "hard_limit"),
[("bytes", Decimal("2")), ("objects", Decimal("100"))],
)
def test_attachment_quota_or_wrong_basis_denies_before_existing_file_moves(
factory: sessionmaker[Session],
tmp_path: Path,
basis: str,
hard_limit: Decimal,
) -> None:
now = datetime.now(UTC)
storage = _Storage(tmp_path / "expense-claims")
item = ExpenseClaimItem(id="item-a", claim_id="claim-a", invoice_id=None)
old_path = storage.build_item_dir("claim-a", "item-a") / "old.txt"
old_path.parent.mkdir(parents=True)
old_path.write_bytes(b"keep-me")
item.invoice_id = storage.to_storage_key(old_path)
with factory() as db:
seed_meter(
db,
"tenant-a",
now,
basis=basis,
tool_type="storage",
tool_name="attachment.upload",
hard_limit=hard_limit,
preflight_quantity=Decimal("100"),
)
db.commit()
with pytest.raises(ExpenseClaimAttachmentCommercialAccessDenied):
stage_attachment_replacement(
db,
storage=storage,
item=item,
current_user=_user(),
claim_id="claim-a",
content=b"three",
request_id="quota-denied",
)
assert old_path.read_bytes() == b"keep-me"
assert db.scalar(select(func.count(CommercialRuntimeReservation.id))) == 0
assert db.scalar(select(func.count(UsageMeterEvent.id))) == 0
def test_attachment_delete_restores_on_rollback_and_finalizes_on_commit(
factory: sessionmaker[Session],
tmp_path: Path,
) -> None:
storage = _Storage(tmp_path / "expense-claims")
item = ExpenseClaimItem(id="item-a", claim_id="claim-a", invoice_id=None)
file_path = storage.build_item_dir("claim-a", "item-a") / "invoice.pdf"
file_path.parent.mkdir(parents=True)
file_path.write_bytes(b"invoice")
item.invoice_id = storage.to_storage_key(file_path)
with factory() as db:
stage_attachment_deletion(db, storage=storage, item=item)
assert not file_path.exists()
db.rollback()
assert file_path.read_bytes() == b"invoice"
stage_attachment_deletion(db, storage=storage, item=item)
db.commit()
assert not file_path.exists()
def test_claim_attachment_tree_delete_follows_business_transaction(
factory: sessionmaker[Session],
tmp_path: Path,
) -> None:
storage = _Storage(tmp_path / "expense-claims")
file_path = storage.build_item_dir("claim-a", "item-a") / "invoice.pdf"
file_path.parent.mkdir(parents=True)
file_path.write_bytes(b"invoice")
with factory() as db:
stage_claim_attachment_deletion(db, storage=storage, claim_id="claim-a")
assert not file_path.exists()
db.rollback()
assert file_path.read_bytes() == b"invoice"
stage_claim_attachment_deletion(db, storage=storage, claim_id="claim-a")
db.commit()
assert not file_path.exists()
def test_multiple_attachment_replacements_restore_in_reverse_order(
factory: sessionmaker[Session],
tmp_path: Path,
) -> None:
storage = _Storage(tmp_path / "expense-claims")
item = ExpenseClaimItem(id="item-a", claim_id="claim-a", invoice_id=None)
original_path = storage.build_item_dir("claim-a", "item-a") / "original.txt"
original_path.parent.mkdir(parents=True)
original_path.write_bytes(b"original")
item.invoice_id = storage.to_storage_key(original_path)
with factory() as db:
stage_attachment_replacement(
db,
storage=storage,
item=item,
current_user=_user(),
claim_id="claim-a",
content=b"middle",
request_id="replacement-one",
)
middle_path = storage.build_item_dir("claim-a", "item-a") / "middle.txt"
middle_path.parent.mkdir(parents=True)
middle_path.write_bytes(b"middle")
item.invoice_id = storage.to_storage_key(middle_path)
stage_attachment_replacement(
db,
storage=storage,
item=item,
current_user=_user(),
claim_id="claim-a",
content=b"latest",
request_id="replacement-two",
)
latest_path = storage.build_item_dir("claim-a", "item-a") / "latest.txt"
latest_path.parent.mkdir(parents=True)
latest_path.write_bytes(b"latest")
db.rollback()
assert original_path.read_bytes() == b"original"
assert not middle_path.exists()
assert not latest_path.exists()
class _Storage(ExpenseClaimAttachmentStorage):
def __init__(self, root: Path) -> None:
self._root = root
def root(self) -> Path:
return self._root.resolve()
def _seed_connector_boundary(
db: Session,
*,
now: datetime,
hard_limit: Decimal,
) -> ExpenseClaim:
seed_meter(
db,
"tenant-a",
now,
basis="events",
tool_type="connector",
tool_name="financial.ingest",
hard_limit=hard_limit,
preflight_quantity=Decimal("1"),
)
db.add(
FinancialConnectorConfig(
id=str(uuid.uuid4()),
tenant_id="tenant-a",
provider="metered-bank",
environment="mock",
key_version="v1",
secret_ref="connector/metered-test",
allowed_event_types_json=["payment_settled"],
clock_skew_seconds=300,
status="active",
created_by="platform-admin",
)
)
claim = ExpenseClaim(
id=str(uuid.uuid4()),
tenant_id="tenant-a",
claim_no=f"BX-METER-{uuid.uuid4().hex[:8]}",
employee_name="计量测试员工",
department_name="财务部",
expense_type="travel",
reason="连接器资源边界测试",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="pending_payment",
approval_stage="待付款",
risk_flags_json=[],
)
db.add(claim)
db.flush()
return claim
def _envelope(claim: ExpenseClaim, event_id: str, now: datetime) -> FinancialEventEnvelope:
return FinancialEventEnvelope(
tenant_id="tenant-a",
external_event_id=event_id,
event_type="payment_settled",
occurred_at=now,
correlation_id=f"sensitive-correlation-{event_id}"[:64],
payload={
"claim_id": claim.id,
"claim_reference": claim.claim_no,
"amount": str(claim.amount),
"currency": claim.currency,
"external_payment_reference": f"SENSITIVE-PAYMENT-{event_id}",
},
)
def _ingest(
db: Session,
envelope: FinancialEventEnvelope,
timestamp: int,
*,
signature: str = "",
):
resolved_signature = signature or sign_financial_event(
envelope,
timestamp=timestamp,
secret="metered-server-secret",
tenant_id="tenant-a",
provider="metered-bank",
key_version="v1",
)
return FinancialConnectorIngestionService(
db,
secrets=FinancialConnectorSecretResolver(
{"connector/metered-test": "metered-server-secret"}
),
now_epoch=timestamp,
).ingest(
envelope,
tenant_header="tenant-a",
provider_header="metered-bank",
key_version_header="v1",
timestamp_header=str(timestamp),
signature_header=resolved_signature,
)
def _user() -> CurrentUserContext:
return CurrentUserContext(
username="employee-a",
name="员工甲",
role_codes=["employee"],
is_admin=False,
tenant_id="tenant-a",
auth_session_id=f"session-{time.time_ns()}",
)