ForcePilot/backend/package/yuxi/storage/postgres/models_channels.py
Kris b88c0ae29e feat(channels): 批量新增多渠道网关限界上下文基础代码与契约
新增完整的 channels 限界上下文模块,包含契约层、领域核心层、应用服务、管道编排、插件体系、基础设施组合根等全层级代码,新增飞书与微信 iLink 渠道插件基础结构,补充各类 DTO、端口协议与领域服务实现。
2026-07-02 03:22:12 +08:00

868 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""多渠道网关相关的 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-22。conversation_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),
}