Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
477 lines
20 KiB
Python
477 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import (
|
|
CheckConstraint,
|
|
DateTime,
|
|
ForeignKeyConstraint,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from sqlalchemy.types import JSON
|
|
|
|
from app.db.base_class import Base
|
|
|
|
|
|
def _new_id() -> str:
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
class FinancialConnectorConfig(Base):
|
|
"""租户绑定的连接器契约;只保存服务端密钥引用,不保存密钥。"""
|
|
|
|
__tablename__ = "financial_connector_configs"
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "id", name="uq_financial_connector_configs_tenant_id"),
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"provider",
|
|
"key_version",
|
|
name="uq_financial_connector_configs_tenant_provider_key",
|
|
),
|
|
CheckConstraint(
|
|
"environment IN ('test', 'mock', 'staging', 'production')",
|
|
name="ck_financial_connector_configs_environment",
|
|
),
|
|
CheckConstraint(
|
|
"status IN ('active', 'disabled', 'rotating')",
|
|
name="ck_financial_connector_configs_status",
|
|
),
|
|
CheckConstraint(
|
|
"clock_skew_seconds BETWEEN 30 AND 900",
|
|
name="ck_financial_connector_configs_clock_skew",
|
|
),
|
|
CheckConstraint(
|
|
"version >= 1",
|
|
name="ck_financial_connector_configs_version",
|
|
),
|
|
CheckConstraint(
|
|
"length(trim(provider)) > 0 AND length(trim(key_version)) > 0 "
|
|
"AND length(trim(secret_ref)) > 0",
|
|
name="ck_financial_connector_configs_keys",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_configs_tenant_status",
|
|
"tenant_id",
|
|
"status",
|
|
"provider",
|
|
),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
environment: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
key_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
secret_ref: Mapped[str] = mapped_column(String(180), nullable=False)
|
|
allowed_event_types_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
clock_skew_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="disabled")
|
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
last_error_code: Mapped[str | None] = mapped_column(String(80))
|
|
created_by: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class FinancialConnectorConfigEvent(Base):
|
|
"""连接器配置生命周期审计事实;不得保存密钥引用或密钥明文。"""
|
|
|
|
__tablename__ = "financial_connector_config_events"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"id",
|
|
name="uq_financial_connector_config_events_tenant_id",
|
|
),
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"request_id",
|
|
"action",
|
|
name="uq_financial_connector_config_events_tenant_request_action",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "config_id"],
|
|
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
|
name="fk_financial_connector_config_events_tenant_config",
|
|
ondelete="RESTRICT",
|
|
),
|
|
CheckConstraint(
|
|
"action IN ('created', 'activated', 'disabled', "
|
|
"'rotation_started', 'rotation_replacement_created')",
|
|
name="ck_financial_connector_config_events_action",
|
|
),
|
|
CheckConstraint(
|
|
"expected_version IS NULL OR expected_version >= 1",
|
|
name="ck_financial_connector_config_events_expected_version",
|
|
),
|
|
CheckConstraint(
|
|
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
|
|
"AND length(trim(reason)) > 0",
|
|
name="ck_financial_connector_config_events_required_text",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_config_events_tenant_config_time",
|
|
"tenant_id",
|
|
"config_id",
|
|
"occurred_at",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_config_events_tenant_request",
|
|
"tenant_id",
|
|
"request_id",
|
|
),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
config_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
action: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
reason: Mapped[str] = mapped_column(Text(), nullable=False)
|
|
expected_version: Mapped[int | None] = mapped_column(Integer)
|
|
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
occurred_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
|
|
class FinancialConnectorOperationalEvent(Base):
|
|
"""连接器重放、认证失败和载荷冲突的最小化追加式运营事实。"""
|
|
|
|
__tablename__ = "financial_connector_operational_events"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"id",
|
|
name="uq_financial_connector_operational_events_tenant_id",
|
|
),
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"idempotency_key",
|
|
name="uq_financial_connector_operational_events_tenant_request",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "config_id"],
|
|
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
|
name="fk_financial_connector_operational_events_tenant_config",
|
|
ondelete="RESTRICT",
|
|
),
|
|
CheckConstraint(
|
|
"event_type IN ('replay', 'auth_failure', 'payload_conflict')",
|
|
name="ck_financial_connector_operational_events_type",
|
|
),
|
|
CheckConstraint(
|
|
"environment IN ('test', 'mock', 'staging', 'production')",
|
|
name="ck_financial_connector_operational_events_environment",
|
|
),
|
|
CheckConstraint(
|
|
"length(trim(provider)) > 0 AND length(trim(reason_code)) > 0",
|
|
name="ck_financial_connector_operational_events_required_text",
|
|
),
|
|
CheckConstraint(
|
|
"length(request_fingerprint) = 76 "
|
|
"AND request_fingerprint LIKE 'hmac-sha256:%' "
|
|
"AND length(external_event_fingerprint) = 76 "
|
|
"AND external_event_fingerprint LIKE 'hmac-sha256:%' "
|
|
"AND length(idempotency_key) = 71 "
|
|
"AND idempotency_key LIKE 'sha256:%'",
|
|
name="ck_financial_connector_operational_events_fingerprints",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_operational_events_tenant_config_time",
|
|
"tenant_id",
|
|
"config_id",
|
|
"occurred_at",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_operational_events_tenant_type_time",
|
|
"tenant_id",
|
|
"event_type",
|
|
"occurred_at",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_operational_events_tenant_provider_time",
|
|
"tenant_id",
|
|
"provider",
|
|
"occurred_at",
|
|
),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
config_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
environment: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
reason_code: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
request_fingerprint: Mapped[str] = mapped_column(String(76), nullable=False)
|
|
external_event_fingerprint: Mapped[str] = mapped_column(String(76), nullable=False)
|
|
idempotency_key: Mapped[str] = mapped_column(String(71), nullable=False)
|
|
occurred_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
|
|
class FinancialConnectorEvent(Base):
|
|
"""经签名验证的最小化外部事实。表由 PostgreSQL 触发器强制只追加。"""
|
|
|
|
__tablename__ = "financial_connector_events"
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "id", name="uq_financial_connector_events_tenant_id"),
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"provider",
|
|
"external_event_id",
|
|
name="uq_financial_connector_events_external_id",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "config_id"],
|
|
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
|
name="fk_financial_connector_events_tenant_config",
|
|
ondelete="RESTRICT",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "origin_event_id"],
|
|
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
|
name="fk_financial_connector_events_tenant_origin",
|
|
ondelete="RESTRICT",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "expense_case_id"],
|
|
["expense_cases.tenant_id", "expense_cases.id"],
|
|
name="fk_financial_connector_events_tenant_expense_case",
|
|
ondelete="RESTRICT",
|
|
),
|
|
CheckConstraint(
|
|
"direction = 'inbound'",
|
|
name="ck_financial_connector_events_direction",
|
|
),
|
|
CheckConstraint(
|
|
"event_type IN ('payment_settled', 'payment_failed', 'erp_posted', "
|
|
"'erp_posting_failed', 'payment_refunded', 'payment_reversed')",
|
|
name="ck_financial_connector_events_type",
|
|
),
|
|
CheckConstraint(
|
|
"environment IN ('test', 'mock', 'staging', 'production')",
|
|
name="ck_financial_connector_events_environment",
|
|
),
|
|
CheckConstraint(
|
|
"verification_level IN ('simulated', 'staging_verified', 'production_verified')",
|
|
name="ck_financial_connector_events_verification",
|
|
),
|
|
CheckConstraint(
|
|
"processing_status IN ('processed', 'exception', 'pending')",
|
|
name="ck_financial_connector_events_processing_status",
|
|
),
|
|
CheckConstraint(
|
|
"length(trim(external_event_id)) > 0 "
|
|
"AND length(trim(request_fingerprint)) >= 16 "
|
|
"AND length(trim(content_hash)) >= 16",
|
|
name="ck_financial_connector_events_fingerprints",
|
|
),
|
|
CheckConstraint(
|
|
"(event_type IN ('payment_refunded', 'payment_reversed', "
|
|
"'erp_posted', 'erp_posting_failed') "
|
|
"AND (origin_event_id IS NOT NULL OR processing_status = 'exception')) "
|
|
"OR (event_type IN ('payment_settled', 'payment_failed') "
|
|
"AND origin_event_id IS NULL)",
|
|
name="ck_financial_connector_events_origin",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_events_tenant_received",
|
|
"tenant_id",
|
|
"received_at",
|
|
),
|
|
Index(
|
|
"ix_financial_connector_events_tenant_claim",
|
|
"tenant_id",
|
|
"claim_id",
|
|
"occurred_at",
|
|
),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
config_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
environment: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
direction: Mapped[str] = mapped_column(String(12), nullable=False, default="inbound")
|
|
external_event_id: Mapped[str] = mapped_column(String(160), nullable=False)
|
|
event_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
received_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
key_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
|
verification_level: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
content_hash: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
processing_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
error_code: Mapped[str | None] = mapped_column(String(80))
|
|
# expense_claims 由 legacy bootstrap 创建,迁移表只保存经过租户 Case 校验的软引用。
|
|
claim_id: Mapped[str | None] = mapped_column(String(36))
|
|
expense_case_id: Mapped[str | None] = mapped_column(String(36))
|
|
origin_event_id: Mapped[str | None] = mapped_column(String(36))
|
|
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
external_reference_tail: Mapped[str | None] = mapped_column(String(8))
|
|
normalized_payload_json: Mapped[dict[str, Any]] = mapped_column(
|
|
JSON, nullable=False, default=dict
|
|
)
|
|
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
|
|
|
|
class PaymentReconciliationCase(Base):
|
|
"""对账当前投影;所有历史变化由 PaymentReconciliationEvent 保存。"""
|
|
|
|
__tablename__ = "payment_reconciliation_cases"
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_cases_tenant_id"),
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"provider",
|
|
"claim_id",
|
|
name="uq_payment_reconciliation_cases_tenant_provider_claim",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "last_connector_event_id"],
|
|
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
|
name="fk_payment_reconciliation_cases_tenant_last_event",
|
|
ondelete="RESTRICT",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "expense_case_id"],
|
|
["expense_cases.tenant_id", "expense_cases.id"],
|
|
name="fk_payment_reconciliation_cases_tenant_expense_case",
|
|
ondelete="RESTRICT",
|
|
),
|
|
CheckConstraint(
|
|
"status IN ('pending', 'matched', 'exception', 'confirmed', "
|
|
"'rejected', 'reopened', 'closed')",
|
|
name="ck_payment_reconciliation_cases_status",
|
|
),
|
|
CheckConstraint(
|
|
"erp_status IN ('pending_posting', 'posted', 'posting_failed')",
|
|
name="ck_payment_reconciliation_cases_erp_status",
|
|
),
|
|
CheckConstraint(
|
|
"expected_amount >= 0 AND actual_amount >= 0",
|
|
name="ck_payment_reconciliation_cases_amounts",
|
|
),
|
|
CheckConstraint(
|
|
"length(trim(expected_currency)) = 3 AND length(trim(actual_currency)) = 3",
|
|
name="ck_payment_reconciliation_cases_currencies",
|
|
),
|
|
Index(
|
|
"ix_payment_reconciliation_cases_tenant_status",
|
|
"tenant_id",
|
|
"status",
|
|
"updated_at",
|
|
),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
# 租户边界由 expense_case_id 复合外键与服务查询共同保证。
|
|
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
expense_case_id: Mapped[str | None] = mapped_column(String(36))
|
|
expected_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
|
|
actual_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
|
|
amount_difference: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
|
|
expected_currency: Mapped[str] = mapped_column(String(3), nullable=False)
|
|
actual_currency: Mapped[str] = mapped_column(String(3), nullable=False)
|
|
expected_reference: Mapped[str] = mapped_column(String(160), nullable=False)
|
|
external_reference_tail: Mapped[str | None] = mapped_column(String(8))
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
exception_code: Mapped[str | None] = mapped_column(String(80))
|
|
erp_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending_posting")
|
|
erp_document_tail: Mapped[str | None] = mapped_column(String(8))
|
|
erp_document_hash: Mapped[str | None] = mapped_column(String(80))
|
|
assigned_to: Mapped[str | None] = mapped_column(String(120))
|
|
last_connector_event_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class PaymentReconciliationEvent(Base):
|
|
"""对账动作审计事实;数据库级禁止更新和删除。"""
|
|
|
|
__tablename__ = "payment_reconciliation_events"
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_events_tenant_id"),
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"connector_event_id",
|
|
"action",
|
|
name="uq_payment_reconciliation_events_connector_action",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "reconciliation_case_id"],
|
|
["payment_reconciliation_cases.tenant_id", "payment_reconciliation_cases.id"],
|
|
name="fk_payment_reconciliation_events_tenant_case",
|
|
ondelete="RESTRICT",
|
|
),
|
|
ForeignKeyConstraint(
|
|
["tenant_id", "connector_event_id"],
|
|
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
|
name="fk_payment_reconciliation_events_tenant_connector_event",
|
|
ondelete="RESTRICT",
|
|
),
|
|
CheckConstraint(
|
|
"action IN ('auto_matched', 'exception_created', 'erp_posted', "
|
|
"'erp_posting_failed', 'reopened', 'confirmed', 'rejected', 'closed')",
|
|
name="ck_payment_reconciliation_events_action",
|
|
),
|
|
CheckConstraint(
|
|
"length(trim(request_fingerprint)) >= 16",
|
|
name="ck_payment_reconciliation_events_fingerprint",
|
|
),
|
|
Index(
|
|
"ix_payment_reconciliation_events_tenant_case_time",
|
|
"tenant_id",
|
|
"reconciliation_case_id",
|
|
"occurred_at",
|
|
),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
reconciliation_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
connector_event_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
|
|
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
|
reason: Mapped[str | None] = mapped_column(Text())
|
|
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
occurred_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|