feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
476
server/src/app/models/financial_connector.py
Normal file
476
server/src/app/models/financial_connector.py
Normal file
@@ -0,0 +1,476 @@
|
||||
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()
|
||||
)
|
||||
Reference in New Issue
Block a user