Files
X-Financial/server/src/app/models/financial_connector.py

477 lines
20 KiB
Python
Raw Normal View History

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()
)