ForcePilot/backend/package/yuxi/storage/postgres/models_channels.py

868 lines
42 KiB
Python
Raw Normal View History

"""多渠道网关相关的 PostgreSQL 数据模型。
表名统一以 `channel_` 为前缀属于渠道限界上下文channel bounded context
`external_systems` / `scheduled_task` 等上下文隔离不跨上下文 import
实现类所有表统一包含审计字段created_by/updated_by/created_at/updated_at/
is_deleted/deleted_at支持软删除与操作追溯
本模块承载渠道网关域自身的持久化模型
- `channel_accounts`渠道账户配置与状态
- `channel_sessions`渠道会话映射
- `channel_user_identities`跨渠道统一身份
- `channel_pairings`DM 配对审批
- `channel_audit_logs`渠道审计日志append-only
- `channel_outbox_entries`持久化投递队列
- `channel_idempotency`写操作幂等记录
- `channel_reports`一次性报告元数据与内容
设计依据docs/vibe/V1.2/06-数据库设计方案文档.md
"""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSON, JSONB
from sqlalchemy.orm import declarative_base
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
Base = declarative_base()
JSON_VALUE = JSON().with_variant(JSONB, "postgresql")
# 幂等记录默认过期时间(小时)
IDEMPOTENCY_DEFAULT_TTL_HOURS = 24
class ChannelAccount(Base):
"""渠道账户配置与状态。
对应聚合根 ``ChannelAccount````yuxi.channels.core.model.channel_account``
状态机active disabled手动禁用active degraded active自动降级恢复
disabled active手动启用disabled 状态不得自动恢复必须手动启用
``enabled`` ``status`` 的语义关系
- ``enabled`` 为管理员意图admin intent``status`` 为运行时状态runtime state
- 约束``enabled=False`` ``status='disabled'````enabled=True`` ``status``
可为 ``active`` ``degraded``应用层必须保证两者一致性避免出现
``enabled=False, status='active'`` 等矛盾状态
"""
__tablename__ = "channel_accounts"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 业务标识
channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型feishu/dingtalk/wecom/webchat/...")
account_id = Column(String(128), nullable=False, comment="渠道账户 ID全局唯一业务标识")
display_name = Column(String(128), nullable=False, comment="展示名称")
# 配置与能力
config = Column(JSON_VALUE, nullable=False, default=dict, comment="渠道配置(敏感字段加密存储)")
capabilities = Column(JSON_VALUE, nullable=False, default=dict, comment="能力声明ChannelCapabilities 序列化)")
# 状态
enabled = Column(Boolean, nullable=False, default=True, comment="管理员启用意图;与 status='disabled' 互为充要,应用层须保证一致")
status = Column(String(32), nullable=False, default="active", comment="运行时状态机active/disabled/degraded")
plugin_status = Column(
String(32),
nullable=False,
default="stopped",
comment="插件生命周期状态FR-32discovered/parsed/loaded/initialized/starting/running/paused/resuming/stopping/stopped/unloaded/failed",
)
config_version = Column(Integer, nullable=False, default=1, comment="配置版本号FR-37 热更新冲突检测)")
last_error = Column(Text, nullable=True, comment="最近一次错误信息FR-35 健康检查详情)")
last_error_at = Column(DateTime, nullable=True, comment="最近一次错误时间")
last_health_check_at = Column(DateTime, nullable=True, comment="最近一次健康检查时间FR-35无论成功失败均更新用于诊断检查任务是否存活")
last_message_at = Column(DateTime, nullable=True, comment="最近一次消息时间(冗余字段)")
transport_cursor = Column(String(256), nullable=False, default="", server_default="", comment="传输游标Puller类型使用")
last_rotated_at = Column(DateTime, nullable=True, comment="凭据最近轮换时间")
version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号")
# 审计字段
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
UniqueConstraint("channel_type", "account_id", name="uq_channel_accounts_type_account"),
Index("ix_channel_accounts_status", "status"),
Index("ix_channel_accounts_plugin_status", "plugin_status"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典config 中的敏感字段由 persistence 层解密to_dict 不返回明文。"""
return {
"id": self.id,
"channel_type": self.channel_type,
"account_id": self.account_id,
"display_name": self.display_name,
"config": self.config or {},
"capabilities": self.capabilities or {},
"enabled": bool(self.enabled),
"status": self.status,
"plugin_status": self.plugin_status,
"config_version": self.config_version,
"last_error": self.last_error,
"last_error_at": format_utc_datetime(self.last_error_at),
"last_health_check_at": format_utc_datetime(self.last_health_check_at),
"last_message_at": format_utc_datetime(self.last_message_at),
"transport_cursor": self.transport_cursor or "",
"last_rotated_at": format_utc_datetime(self.last_rotated_at),
"version": self.version,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class ChannelSession(Base):
"""渠道会话映射,关联渠道账户与内部 Conversation。
对应聚合根 ``ChannelSession````yuxi.channels.core.model.channel_session``
conversation_id 不设唯一约束允许跨渠道关联FR-06
"""
__tablename__ = "channel_sessions"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 业务标识
session_id = Column(String(64), nullable=False, unique=True, comment="渠道会话 IDUUID业务标识")
account_id = Column(
Integer,
ForeignKey("channel_accounts.id"),
nullable=False,
index=True,
comment="关联渠道账户 IDORM 外键)",
)
channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型(冗余,便于查询)")
peer_id = Column(String(256), nullable=False, comment="对端 ID用户 ID 或群组 ID")
chat_type = Column(String(16), nullable=False, comment="会话类型p2p / group")
# 关联内部会话(可空,首次消息时创建;不设唯一约束,支持跨渠道关联 FR-06
conversation_id = Column(
Integer,
ForeignKey("conversations.id"),
nullable=True,
comment="关联内部会话 ID可空",
)
# 跨渠道身份与路由
unified_identity_id = Column(String(64), nullable=True, index=True, comment="统一身份 IDFR-06 跨渠道关联)")
owner_peer_id = Column(String(256), nullable=True, comment="主会话所有者对端 IDFR-26")
is_temporary = Column(Boolean, nullable=False, default=False, comment="临时会话标记FR-27")
last_route_at = Column(DateTime, nullable=True, comment="最近路由更新时间FR-04/FR-26")
route_match_source = Column(
String(32),
nullable=True,
comment="最近路由匹配来源direct/normalized_direct/parent/normalized_parent/wildcard/channel_binding",
)
last_message_at = Column(DateTime, nullable=True, comment="最近一次消息时间(冗余字段,用于会话列表排序与 inactive 临时会话清理)")
closed_at = Column(DateTime, nullable=True, comment="会话关闭时间")
version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号")
# 审计字段
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
# 部分唯一索引:同一账户下对端 ID 唯一(仅未软删除时),支持软删除后重建
Index(
"uq_channel_sessions_account_peer_active",
"account_id",
"peer_id",
unique=True,
postgresql_where=text("is_deleted = 0"),
),
Index("ix_channel_sessions_unified_identity", "unified_identity_id", "channel_type", "account_id"),
Index("ix_channel_sessions_conversation", "conversation_id"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"session_id": self.session_id,
"account_id": self.account_id,
"channel_type": self.channel_type,
"peer_id": self.peer_id,
"chat_type": self.chat_type,
"conversation_id": self.conversation_id,
"unified_identity_id": self.unified_identity_id,
"owner_peer_id": self.owner_peer_id,
"is_temporary": bool(self.is_temporary),
"last_route_at": format_utc_datetime(self.last_route_at),
"route_match_source": self.route_match_source,
"last_message_at": format_utc_datetime(self.last_message_at),
"closed_at": format_utc_datetime(self.closed_at),
"version": self.version,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
class ChannelUserIdentity(Base):
"""跨渠道统一身份映射。
对应聚合根 ``UserIdentity````yuxi.channels.core.model.user_identity``
user_id 为可空外键未关联真实用户时为 None走隔离策略
"""
__tablename__ = "channel_user_identities"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 业务标识
identity_id = Column(String(64), nullable=False, unique=True, comment="统一身份 IDUUID业务标识")
user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=True,
index=True,
comment="关联用户表 ID可空未关联真实用户时为 None",
)
# 身份信息
identity_type = Column(String(32), nullable=False, comment="身份类型email/phone/employee_id")
identity_value = Column(String(255), nullable=False, comment="身份值")
channel_type = Column(String(32), nullable=True, comment="首次解析到的渠道类型")
channel_sender_id = Column(String(255), nullable=True, comment="首次解析到的渠道发送者 ID")
source = Column(String(64), nullable=True, comment="身份来源explicit_link/phone/...")
# 绑定与合并
channel_bindings = Column(
JSON_VALUE,
nullable=False,
default=dict,
comment="渠道绑定映射:{channel_type: [peer_id, ...]}",
)
merged_from = Column(JSON_VALUE, nullable=False, default=list, comment="合并来源 identity_id 列表")
confidence = Column(String(16), nullable=False, default="low", comment="身份置信度low/medium/high")
version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号")
# 审计字段
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("ix_channel_user_identities_type_value", "identity_type", "identity_value"),
# 部分唯一索引:同一身份类型 + 身份值在未软删除时唯一,从源头杜绝重复身份
Index(
"uq_channel_user_identities_type_value_active",
"identity_type",
"identity_value",
unique=True,
postgresql_where=text("is_deleted = 0"),
),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"identity_id": self.identity_id,
"user_id": self.user_id,
"identity_type": self.identity_type,
"identity_value": self.identity_value,
"channel_type": self.channel_type,
"channel_sender_id": self.channel_sender_id,
"source": self.source,
"channel_bindings": self.channel_bindings or {},
"merged_from": self.merged_from or [],
"confidence": self.confidence,
"version": self.version,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
class ChannelPairing(Base):
"""DM 配对审批记录。
对应聚合根 ``PairingApproval````yuxi.channels.core.model.pairing_approval``
状态机不可逆pending approved | rejected | expired | revoked终态不可变更
配对审批表故障时必须拒绝所有 DMfail-closedFR-33
"""
__tablename__ = "channel_pairings"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 业务标识
pairing_id = Column(String(64), nullable=False, unique=True, comment="配对 IDUUID业务标识")
account_id = Column(
Integer,
ForeignKey("channel_accounts.id"),
nullable=False,
index=True,
comment="关联渠道账户 IDORM 外键)",
)
peer_id = Column(String(256), nullable=False, comment="渠道侧对端用户 ID")
peer_name = Column(String(128), nullable=True, comment="对端名称(冗余,便于展示)")
# 状态机
status = Column(
String(32),
nullable=False,
default="pending",
index=True,
comment="状态pending/approved/rejected/expired/revoked",
)
approver_id = Column(String(64), nullable=True, comment="审批人用户标识")
approved_at = Column(DateTime, nullable=True, comment="审批时间approved 状态时填充)")
rejected_at = Column(DateTime, nullable=True, comment="拒绝时间rejected 状态时填充)")
expired_at = Column(DateTime, nullable=True, comment="过期生效时间expired 状态时填充,由定时任务或审批时回填)")
revoked_at = Column(DateTime, nullable=True, comment="撤销时间revoked 状态时填充FR-33")
# 时间
requested_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="申请时间")
expires_at = Column(DateTime, nullable=False, index=True, comment="过期时间(默认创建时间 + 7 天)")
reason = Column(Text, nullable=True, comment="审批原因 / 拒绝原因 / 撤销原因")
version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号")
# 审计字段
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("ix_channel_pairings_account_peer_status", "account_id", "peer_id", "status"),
# 部分唯一索引:同一账户下对端 ID 同时只允许一条 pending防止重复申请
Index(
"uq_channel_pairings_account_peer_pending",
"account_id",
"peer_id",
unique=True,
postgresql_where=text("status = 'pending' AND is_deleted = 0"),
),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"pairing_id": self.pairing_id,
"account_id": self.account_id,
"peer_id": self.peer_id,
"peer_name": self.peer_name,
"status": self.status,
"approver_id": self.approver_id,
"approved_at": format_utc_datetime(self.approved_at),
"rejected_at": format_utc_datetime(self.rejected_at),
"expired_at": format_utc_datetime(self.expired_at),
"revoked_at": format_utc_datetime(self.revoked_at),
"requested_at": format_utc_datetime(self.requested_at),
"expires_at": format_utc_datetime(self.expires_at),
"reason": self.reason,
"version": self.version,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
class ChannelAuditLog(Base):
"""渠道审计日志append-only
对应聚合根 ``AuditLog````yuxi.channels.core.model.audit_log``
不复用现有 ``OperationLog`` 语义不同is_deleted 恒为 0不设 version 字段
审计日志写入必须在事务内完成fail-closedFR-34
"""
__tablename__ = "channel_audit_logs"
# 主键(大整数,高写入量)
id = Column(BigInteger, primary_key=True, autoincrement=True)
# 业务标识
audit_log_id = Column(String(64), nullable=False, unique=True, comment="审计日志 IDUUID业务标识")
# 操作信息
operation = Column(String(64), nullable=False, index=True, comment="操作类型(见 AuditOperationType 枚举)")
operator = Column(String(64), nullable=False, index=True, comment="操作人(用户 ID 或 system")
target_channel = Column(String(64), nullable=False, index=True, comment="目标渠道(渠道类型或 global")
target_account = Column(String(128), nullable=True, comment="目标账户 ID可选")
result = Column(String(32), nullable=False, comment="操作结果success / failed")
# 详情
params_summary = Column(
JSON_VALUE,
nullable=False,
default=dict,
comment="参数摘要(敏感字段已脱敏)",
)
trace_id = Column(String(64), nullable=True, index=True, comment="链路追踪 ID")
source_ip = Column(String(64), nullable=True, comment="来源 IP")
request_id = Column(String(64), nullable=True, comment="请求 ID链路追踪")
# FR-19 管理员消息审计关联字段
message_id = Column(String(64), nullable=True, index=True, comment="关联消息 IDFR-19")
content_summary = Column(String(256), nullable=True, comment="消息内容摘要FR-19")
# 时间戳(业务操作时间,与 created_at 同值但语义不同)
timestamp = Column(DateTime, nullable=False, default=utc_now_naive, index=True, comment="操作时间戳")
# 审计字段is_deleted 恒为 0append-only不设 version
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("ix_channel_audit_logs_operation_time", "operation", "timestamp"),
Index("ix_channel_audit_logs_operator_time", "operator", "timestamp"),
Index("ix_channel_audit_logs_target_time", "target_channel", "timestamp"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"audit_log_id": self.audit_log_id,
"operation": self.operation,
"operator": self.operator,
"target_channel": self.target_channel,
"target_account": self.target_account,
"result": self.result,
"params_summary": self.params_summary or {},
"trace_id": self.trace_id,
"source_ip": self.source_ip,
"request_id": self.request_id,
"timestamp": format_utc_datetime(self.timestamp),
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class AuditExportTask(Base):
"""审计日志导出任务。
对应聚合根 ``AuditExportTask````yuxi.channels.core.model.audit_export_task``
持久化异步导出任务的状态与结果配合 ARQ worker 实现审计日志的异步导出
任务状态机pending running completed | failed终态不可变更
"""
__tablename__ = "audit_export_tasks"
# 主键
id = Column(BigInteger, primary_key=True, autoincrement=True)
# 业务标识
task_id = Column(String(64), nullable=False, unique=True, index=True, comment="导出任务 IDUUID业务标识")
status = Column(String(32), nullable=False, index=True, comment="任务状态pending / running / completed / failed / cancelled")
# 导出参数与结果
query_snapshot = Column(JSON_VALUE, nullable=False, comment="查询条件快照AuditQuery 序列化)")
format = Column(String(16), nullable=False, default="json", comment="导出格式(当前仅支持 json")
total_records = Column(Integer, nullable=True, comment="导出记录总数completed 状态时填充)")
file_path = Column(String(512), nullable=True, comment="导出报告文件路径completed 状态时填充)")
partial_file_path = Column(String(512), nullable=True, comment="部分导出文件路径cancelled 状态时填充)")
error = Column(Text, nullable=True, comment="失败原因failed 状态时填充)")
# 时间与操作人
created_by = Column(String(64), nullable=False, index=True, comment="创建者用户 ID")
created_at = Column(DateTime(timezone=True), nullable=False, comment="任务创建时间UTC")
completed_at = Column(DateTime(timezone=True), nullable=True, comment="任务完成时间completed / failed 状态时填充)")
# 审计字段
is_deleted = Column(Integer, nullable=False, default=0)
deleted_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_audit_export_tasks_status_time", "status", "created_at"),
Index("ix_audit_export_tasks_created_by_time", "created_by", "created_at"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"task_id": self.task_id,
"status": self.status,
"query_snapshot": self.query_snapshot or {},
"format": self.format,
"total_records": self.total_records,
"file_path": self.file_path,
"partial_file_path": self.partial_file_path,
"error": self.error,
"created_by": self.created_by,
"created_at": format_utc_datetime(self.created_at),
"completed_at": format_utc_datetime(self.completed_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
class ChannelOutboxEntry(Base):
"""持久化投递队列,存储出站消息的持久化投递状态。
对应聚合根 ``OutboxEntry````yuxi.channels.core.model.outbox_entry``
仅存消息引用不存完整消息内容FR-22conversation_id / channel_session_id
persistence 适配器从管道上下文注入聚合根无这两个字段
并发投递锁 worker 拉取 pending 消息时通过 ``locked_by`` / ``locked_at``
实现显式租约worker 取走消息后写入 ``locked_by`` ``locked_at``处理完成
后清空并更新 ``status`` worker 崩溃``locked_at`` 超时后由回收任务重置
锁并重新投递保证 at-least-once 语义
"""
__tablename__ = "channel_outbox_entries"
# 主键(大整数,高写入量)
id = Column(BigInteger, primary_key=True, autoincrement=True)
# 业务标识
outbox_id = Column(String(64), nullable=False, unique=True, comment="发件箱 IDUUID业务标识")
# 关联message_id 复用现有 Message 表PRD §7.1.7
message_id = Column(Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="关联现有 Message 表 ID")
account_id = Column(Integer, ForeignKey("channel_accounts.id"), nullable=False, index=True, comment="目标渠道账户 ID")
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, comment="关联会话 ID由 persistence 适配器从管道上下文注入)")
channel_session_id = Column(Integer, ForeignKey("channel_sessions.id"), nullable=True, comment="关联渠道会话 ID由 persistence 适配器从管道上下文注入,可空)")
# 状态机
status = Column(String(32), nullable=False, default="pending", index=True, comment="状态机pending/sent/suppressed/failed/sent_unconfirmed/dead")
durability_policy = Column(String(32), nullable=False, default="required", comment="持久化策略required/best_effort/none")
delivery_mode = Column(String(32), nullable=False, default="persistent", comment="投递模式FR-13/FR-22streaming流式/ persistent持久化")
ack_policy = Column(String(32), nullable=False, default="after_agent_dispatch", comment="ACK 策略FR-24after_record/after_agent_dispatch/after_persist/manual")
# 并发投递锁at-least-once 投递保障)
locked_by = Column(String(64), nullable=True, comment="持有该条目的 worker ID租约标识")
locked_at = Column(DateTime, nullable=True, comment="锁定时间;超时后由回收任务重置,使条目可被重新拉取")
# 重试
retry_count = Column(Integer, nullable=False, default=0, comment="重试次数")
max_retry = Column(Integer, nullable=False, default=5, comment="最大重试次数")
next_retry_at = Column(DateTime, nullable=True, comment="下次重试时间(指数退避)")
last_retry_at = Column(DateTime, nullable=True, comment="上次重试时间(用于诊断退避进度与 SLA")
last_error = Column(Text, nullable=True, comment="最后一次失败的错误信息")
# 渠道回执
channel_msg_id = Column(String(128), nullable=True, comment="渠道返回的消息 ID")
sent_at = Column(DateTime, nullable=True, comment="首次成功投递时间(用于计算投递延迟 sent_at - created_at")
latency_ms = Column(Integer, nullable=True, comment="投递延迟毫秒markSent 首次调用时计算")
funnel_node = Column(String(32), nullable=True, comment="漏斗节点enter/sent/suppressed/failed/dead")
# 过期与追踪
expires_at = Column(DateTime, nullable=False, comment="过期时间(默认创建时间 + 24h")
trace_id = Column(String(64), nullable=True, comment="链路追踪 ID")
version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号")
# 审计字段
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("ix_channel_outbox_status_retry", "status", "next_retry_at"),
Index("ix_channel_outbox_expires_at", "expires_at"),
# 锁超时回收扫描:回收任务查询 locked_at 早于阈值的条目
Index("ix_channel_outbox_locked_at", "locked_at"),
Index("ix_channel_outbox_latency_ms", "latency_ms"),
Index("ix_channel_outbox_funnel_node", "funnel_node"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"outbox_id": self.outbox_id,
"message_id": self.message_id,
"account_id": self.account_id,
"conversation_id": self.conversation_id,
"channel_session_id": self.channel_session_id,
"status": self.status,
"durability_policy": self.durability_policy,
"delivery_mode": self.delivery_mode,
"ack_policy": self.ack_policy,
"locked_by": self.locked_by,
"locked_at": format_utc_datetime(self.locked_at),
"retry_count": self.retry_count,
"max_retry": self.max_retry,
"next_retry_at": format_utc_datetime(self.next_retry_at),
"last_retry_at": format_utc_datetime(self.last_retry_at),
"last_error": self.last_error,
"channel_msg_id": self.channel_msg_id,
"sent_at": format_utc_datetime(self.sent_at),
"expires_at": format_utc_datetime(self.expires_at),
"trace_id": self.trace_id,
"version": self.version,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
class ChannelIdempotency(Base):
"""写操作幂等记录,支持重复请求重放首次响应。
不复用现有 ``scheduled_task_idempotency`` 限界上下文隔离
is_deleted 恒为 0不设 version 字段过期后由定时任务硬删除
幂等处理流程
1. 请求到达 ``idempotency_key`` 查询记录
2. 不存在创建记录``status='in_progress'````in_progress_started_at=now``
持有处理权处理完成后更新为 ``status='completed'`` 并写入 ``response_body``
处理失败则更新为 ``status='failed'``可由后续请求覆盖重试
3. 存在且 ``status='in_progress'``重复请求应拒绝409 Conflict或阻塞等待
避免并发执行同一操作
4. 存在且 ``status='completed'``校验 ``request_hash``匹配则重放
``response_body``不匹配说明同 key 不同参数拒绝409 Conflict以防
掩盖客户端 bug
5. 已过期``expires_at < now``硬删除后按新请求处理
"""
__tablename__ = "channel_idempotency"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 幂等键
idempotency_key = Column(String(128), nullable=False, unique=True, comment="客户端传入的幂等键UUIDPRD §7.0.3")
operation = Column(String(32), nullable=False, index=True, comment="操作类型admin_message_send/pairing_approve/pairing_reject/config_update/whitelist_modify/session_merge")
account_id = Column(Integer, ForeignKey("channel_accounts.id"), nullable=True, index=True, comment="关联渠道账户(可选,部分操作如全局配置变更无账户)")
request_hash = Column(String(64), nullable=True, comment="请求参数 SHA256用于检测同 key 不同参数的冲突;无参数操作可留空)")
status = Column(String(16), nullable=False, default="in_progress", index=True, comment="处理状态in_progress/completed/failed")
in_progress_started_at = Column(DateTime, nullable=True, comment="开始处理时间;用于检测 in_progress 超时(疑似 worker 崩溃)并回收")
response_body = Column(JSON_VALUE, nullable=True, comment="首次请求的响应体status='completed' 时填充,用于重复请求重放)")
expires_at = Column(DateTime, nullable=False, default=lambda: utc_now_naive() + timedelta(hours=IDEMPOTENCY_DEFAULT_TTL_HOURS), comment="过期时间(默认创建时间 + 24h过期后由定时任务硬删除")
# 审计字段is_deleted 恒为 0过期后硬删除不设 version
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("ix_channel_idempotency_created_at", "created_at"),
Index("ix_channel_idempotency_expires_at", "expires_at"),
Index("ix_channel_idempotency_status_started", "status", "in_progress_started_at"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"idempotency_key": self.idempotency_key,
"operation": self.operation,
"account_id": self.account_id,
"request_hash": self.request_hash,
"status": self.status,
"in_progress_started_at": format_utc_datetime(self.in_progress_started_at),
"response_body": self.response_body,
"expires_at": format_utc_datetime(self.expires_at),
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class ChannelContentReviewRecord(Base):
"""内容审核历史记录 ORM 模型。
持久化 ContentReviewRecord DTO承载审核结果与命中片段
独立于 ChannelAuditLog操作记录 ChannelOutboxEntry投递记录
"""
__tablename__ = "channel_content_review_records"
# 主键(高写入量审核记录用 BigInteger
id = Column(BigInteger, primary_key=True, autoincrement=True)
# 业务标识
review_id = Column(String(64), nullable=False, unique=True, comment="审核记录 IDrev_YYYYMMDDhhmmssffffff_xxxxxxxx")
# 关联上下文
channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型feishu / wechat / ...")
account_id = Column(String(64), nullable=False, index=True, comment="渠道账户 ID")
# 审核入参
resource_type = Column(String(32), nullable=False, comment="资源类型message_text / message_attachment / user_profile")
content_preview = Column(String(200), nullable=False, comment="待审核内容预览(前 200 字符)")
# 审核结论
verdict = Column(String(16), nullable=False, index=True, comment="审核结论pass / review / block")
confidence = Column(Float, nullable=False, comment="置信度 [0.0, 1.0]")
categories = Column(JSON_VALUE, nullable=False, default=list, comment='命中分类数组(如 ["politics", "violence"]')
detail = Column(JSON_VALUE, nullable=False, default=list, comment="命中片段数组(含 snippet / position / category / severity")
# 时间与人
reviewed_at = Column(DateTime, nullable=False, index=True, comment="审核时间戳UTC")
reviewer = Column(String(64), nullable=False, comment="审核人operator.user_id")
# 来源与追踪
source = Column(String(32), nullable=False, index=True, comment="审核来源manual_preview / inbound_pipeline / outbound_pipeline")
trace_id = Column(String(64), nullable=True, comment="链路追踪 ID")
# 审计字段(与现有渠道模型统一)
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
# 复合索引(按渠道+账户查询、按时间范围查询)
__table_args__ = (
Index(
"ix_channel_content_review_channel_account",
"channel_type",
"account_id",
),
Index(
"ix_channel_content_review_source_reviewed",
"source",
"reviewed_at",
),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典datetime 统一用 format_utc_datetime"""
return {
"id": self.id,
"review_id": self.review_id,
"channel_type": self.channel_type,
"account_id": self.account_id,
"resource_type": self.resource_type,
"content_preview": self.content_preview,
"verdict": self.verdict,
"confidence": self.confidence,
"categories": self.categories,
"detail": self.detail,
"reviewed_at": format_utc_datetime(self.reviewed_at),
"reviewer": self.reviewer,
"source": self.source,
"trace_id": self.trace_id,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
class ChannelReport(Base):
"""渠道报告 ORM 模型。
存储一次性报告的元数据与内容报告内容由 scheduler handler
异步生成后写入 ``content`` 字段JSONB
设计依据对齐19-聚合视图域-reports-router-设计方案.md§8.4
"""
__tablename__ = "channel_reports"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 业务标识
report_id = Column(String(64), nullable=False, unique=True, index=True, comment="报告 IDUUID业务标识")
task_id = Column(String(64), nullable=False, unique=True, index=True, comment="关联调度任务 ID业务标识")
report_type = Column(String(32), nullable=False, comment="报告类型")
status = Column(String(16), nullable=False, default="pending", comment="状态pending/generating/ready/failed")
# 内容与参数
params = Column(JSON_VALUE, nullable=False, default=dict, comment="报告生成参数(序列化)")
content = Column(JSON_VALUE, nullable=True, comment="报告内容ready 状态时填充JSONB")
error_message = Column(String(512), nullable=True, comment="失败原因failed 状态时填充)")
# 时间与来源
created_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="创建时间")
ready_at = Column(DateTime, nullable=True, comment="报告就绪时间ready 状态时填充)")
created_by = Column(String(64), nullable=False, default="system", comment="创建者(用户 ID 或 system")
# 重试关联RPT-ONEOFF-RETRY
retried_from = Column(String(64), nullable=True, comment="重试来源报告的 task_id仅重试生成的新报告非空")
retried_at = Column(DateTime, nullable=True, comment="原报告被重试的时间(原报告标记字段)")
# 审计字段(与 channels 其他表一致)
updated_by = Column(String(64), nullable=True)
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
is_deleted = Column(Integer, nullable=False, default=0, index=True)
deleted_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("ix_channel_reports_status_created", "status", "created_at"),
Index("ix_channel_reports_type_created", "report_type", "created_at"),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"report_id": self.report_id,
"task_id": self.task_id,
"report_type": self.report_type,
"status": self.status,
"params": self.params or {},
"content": self.content,
"error_message": self.error_message,
"created_at": format_utc_datetime(self.created_at),
"ready_at": format_utc_datetime(self.ready_at),
"created_by": self.created_by,
"retried_from": self.retried_from,
"retried_at": format_utc_datetime(self.retried_at),
"updated_by": self.updated_by,
"updated_at": format_utc_datetime(self.updated_at),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}