ForcePilot/backend/package/yuxi/storage/postgres/models_channels.py
Kris 111f510cf5 refactor(postgres models): 调整多个数据库字段长度和注释
1. 将ChannelContentReviewRecord的account_id从String(64)扩容至String(128),补充注释说明对齐channel_accounts.account_id
2. 将Conversation的uid从String(64)扩容至String(225),补充注释说明包含服务账号uid
3. 将Conversation的channel_account_id从String(64)扩容至String(128),补充对齐注释
4. 将MessageFeedback和AgentRun的uid从String(64)扩容至String(225),补充注释说明包含服务账号uid
2026-07-09 13:52:45 +08:00

956 lines
43 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 yuxi.storage.postgres.models_business import Base
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
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="渠道配置(敏感字段加密存储)")
# 状态
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",
)
# 接入态状态机(与运行态 status 解耦pending/configured/verified/online/offline/failed
onboarding_status = Column(
String(32),
nullable=False,
default="pending",
server_default="pending",
comment="接入态状态机pending/configured/verified/online/offline/failed",
)
# 凭证引用与版本version 化凭证存储)
credential_ref = Column(String(128), nullable=True, comment="凭证引用,指向凭证存储中的版本化凭证记录")
credential_version = Column(Integer, nullable=False, default=0, server_default="0", comment="凭证版本号")
# 关联服务账号user_type='service'
service_user_uid = Column(String(225), nullable=True, comment="关联服务账号 User.uid")
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无论成功失败均更新用于诊断检查任务是否存活"
)
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 {},
"enabled": bool(self.enabled),
"status": self.status,
"plugin_status": self.plugin_status,
"onboarding_status": self.onboarding_status,
"credential_ref": self.credential_ref,
"credential_version": self.credential_version,
"service_user_uid": self.service_user_uid,
"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),
"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="合并来源结构化证据列表")
confidence = Column(String(16), nullable=False, default="low", comment="身份置信度low/medium/high")
version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号")
pending_review = Column(Boolean, nullable=False, default=False, comment="低置信度合并待审批标记H-21")
# 审计字段
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"),
),
# GIN 索引:加速 channel_bindings / merged_from 的 @> 包含查询
Index(
"ix_channel_user_identities_channel_bindings_gin",
"channel_bindings",
postgresql_using="gin",
postgresql_ops={"channel_bindings": "jsonb_path_ops"},
),
Index(
"ix_channel_user_identities_merged_from_gin",
"merged_from",
postgresql_using="gin",
postgresql_ops={"merged_from": "jsonb_path_ops"},
),
)
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,
"pending_review": self.pending_review,
"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"),
),
)
# 启用 SQLAlchemy 乐观锁ORM 实例 flush 时自动追加
# ``WHERE id = ? AND version = ?`` 并自增 version并发状态转换冲突时
# 抛 ``StaleDataError``,由适配器层翻译为 ``ConflictError``。
__mapper_args__ = {"version_id_col": version}
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"),
# 高频过滤列索引H-8target_account 为审计日志查询的高频过滤条件,
# 无索引会触发全表扫描。
Index("ix_channel_audit_logs_target_account", "target_account"),
)
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 ChannelOutboxEntry(Base):
"""持久化投递队列,存储出站消息的持久化投递状态。
对应聚合根 ``OutboxEntry````yuxi.channels.core.model.outbox_entry``)。
仅存消息引用不存完整消息内容FR-22。conversation_id / channel_session_id
由 persistence 适配器从管道上下文注入(聚合根无这两个字段)。
并发投递锁(已废弃):``locked_by`` / ``locked_at`` 列保留兼容历史数据,
但并发控制已由 Redis advisory lock``OutboxRetryWorker.acquireAdvisoryLock``
实现DB 级租约机制不再使用。``delivery_mode`` / ``ack_policy`` 列保留默认值,
投递模式为运行时管道决策不持久化,``ack_policy`` 为入站概念不在出站流程读取。
"""
__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")
# 投递原子语义与降级追踪O-02/O-09/O-10/O-11/O-16
idempotency_key = Column(
String(128),
nullable=True,
comment="渠道侧幂等键markSentUnconfirmed 时写入,供恢复扫描器 queryMessageByRequestId 查询",
)
channel_request_id = Column(
String(128),
nullable=True,
comment="渠道侧请求 IDmarkSentUnconfirmed 时写入,用于恢复扫描器查询投递结果",
)
partial_failure = Column(
Boolean,
nullable=False,
default=False,
comment="多分片投递部分失败标记markPartialFailure 时置 True",
)
stream_aborted_at_chunk = Column(
Integer,
nullable=True,
comment="流式投递 TTL 超时时已发送的分片数deliver 据此调用 sendMessageContinuation 续发",
)
degraded_reason = Column(
Text,
nullable=True,
comment="降级原因摘要BEST_EFFORT 持久化失败、markDeliveryUnconfirmedFailed 等)",
)
delivered_parts = Column(
JSONB,
nullable=False,
default=list,
server_default="[]",
comment="多分片投递已成功投递的分片序号列表重试时仅发送未投递分片H-15",
)
# 过期与追踪
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"),
# 外键列索引H-8_applyOutboxQueryFilter 按 channel_session_id 过滤,
# conversation_id 为高频 JOIN/级联列,无索引会触发全表扫描。
Index("ix_channel_outbox_channel_session", "channel_session_id"),
Index("ix_channel_outbox_conversation", "conversation_id"),
)
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),
"latency_ms": self.latency_ms,
"funnel_node": self.funnel_node,
"idempotency_key": self.idempotency_key,
"channel_request_id": self.channel_request_id,
"partial_failure": self.partial_failure,
"stream_aborted_at_chunk": self.stream_aborted_at_chunk,
"degraded_reason": self.degraded_reason,
"delivered_parts": self.delivered_parts,
"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 ChannelRouteBinding(Base):
"""渠道路由绑定规则,描述某渠道账户在指定匹配层级下的 Agent 绑定。
对应 DTO ``RouteBindingRule````yuxi.channels.contract.dtos.route``)。
``match_source`` 为 tier 名字符串session_key/identity_id/peer_id/chat_type/
channel_session/channel_type/account/default``match_value`` 为该层级下的
匹配值(可空表示该层级无条件命中)。同一 (channel_type, account_id,
match_source, match_value) 在未软删除时唯一。
"""
__tablename__ = "channel_route_bindings"
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 业务标识
binding_id = Column(String(64), nullable=False, index=True, comment="绑定 IDUUID业务标识")
# 关联
channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型")
account_id = Column(String(128), nullable=False, index=True, comment="渠道账户 ID业务标识")
# 匹配规则
match_source = Column(String(32), nullable=False, comment="匹配层级名称tier name")
match_value = Column(String(512), nullable=True, comment="匹配值;为空表示该层级无条件命中")
# 绑定目标
agent_binding = Column(String(255), nullable=False, comment="Agent 绑定agent slug")
# 状态
enabled = Column(Boolean, nullable=False, default=True, comment="是否启用")
description = Column(String(512), 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(
"uq_channel_route_bindings_match",
"channel_type",
"account_id",
"match_source",
"match_value",
unique=True,
postgresql_where=text("is_deleted = 0"),
# PG15+NULL 视为相等,使 account/default 等 match_value 为 NULL 的
# tier 也能在 (channel_type, account_id, match_source) 维度上唯一。
postgresql_nulls_not_distinct=True,
),
Index(
"ix_channel_route_bindings_list",
"channel_type",
"account_id",
"enabled",
),
)
class ChannelIdempotency(Base):
"""写操作幂等记录,支持重复请求重放首次响应。
不复用现有 ``scheduled_task_idempotency`` 表(限界上下文隔离)。
is_deleted 恒为 0不设 version 字段;过期后由定时任务
``ChannelIdempotencyCleanupHandler`` 硬删除。
幂等处理流程:
1. 请求到达,按 ``idempotency_key`` 查询记录。
2. 不存在:创建记录,``status='in_progress'````in_progress_started_at=now``
持有处理权;处理完成后更新为 ``status='completed'`` 并写入 ``response_body``。
处理失败则更新为 ``status='failed'``(可由后续请求覆盖重试)。
3. 存在且 ``status='in_progress'``重复请求应拒绝409 Conflict或阻塞等待
避免并发执行同一操作。超时的 in_progress超过 ``in_progress_started_at``
阈值)视为崩溃遗留,删除重建。
4. 存在且 ``status='completed'``:重放 ``response_body``(管理员消息路径),
或拒绝重复创建 AgentRun入站消息路径
5. 过期记录由定时任务 ``ChannelIdempotencyCleanupHandler`` 按 ``expires_at``
硬删除,不阻塞请求处理。
"""
__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,
comment="操作类型inbound_message入站消息/ message/send管理员消息发送",
)
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_expires_at", "expires_at"),)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典。"""
return {
"id": self.id,
"idempotency_key": self.idempotency_key,
"operation": self.operation,
"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(128), nullable=False, index=True, comment="渠道账户 ID对齐 channel_accounts.account_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,
comment="审核来源manual_preview / inbound_pipeline / outbound_pipeline",
)
trace_id = Column(String(64), nullable=True, comment="链路追踪 ID")
# 决策时长统计CR-STATS-01 avg_decision_seconds 真实化)
# decision_started_at审核请求开始时间首次写入时与 reviewed_at 同值)
# decision_completed_at审核结论最终确定时间人工 update_verdict 时刷新)
# 仅对有 completed_at 的记录参与 avg 计算(首次审核即终态的不计入)
decision_started_at = Column(DateTime, nullable=True, comment="审核决策开始时间UTC")
decision_completed_at = Column(DateTime, nullable=True, comment="审核决策完成时间UTC人工覆盖时刷新")
# 审计字段(与现有渠道模型统一)
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)
# 复合索引(按渠道+账户查询、按时间范围查询)
# 注source 不作为查询过滤条件(仅聚合 case when 使用),不建索引;
# reviewed_at 是高频时间范围扫描字段,单列索引直接支持 stats/analytics 趋势分桶。
__table_args__ = (
Index(
"ix_channel_content_review_channel_account",
"channel_type",
"account_id",
),
Index(
"ix_channel_content_review_reviewed_at",
"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,
"decision_started_at": format_utc_datetime(self.decision_started_at),
"decision_completed_at": format_utc_datetime(self.decision_completed_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),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}