"""工单群成为一等对象:`assistant_ticket_chats` ## 为什么不是 `assistant_handoffs` 上的一列 `assistant_handoffs` 记的是**一次事件**:某条助手回复被转人工了,模型判成什么类、 拉没拉起群。那一行写完就不再变。 而群是一个**有生命周期的对象**:会加人、挂着一张要原地刷新的卡片、工单结案之后 会被解散。更要紧的是,群不再只从「转人工」来 —— 求助人可以在工单详情页开一个, 处理人可以在后台开一个。把这些塞进 handoff 行,得先造一个「没有 `message_id` 的 handoff」,而那是一句谎话。 所以这张表是「工单 ↔ 群」的**唯一注册表**;`assistant_handoffs.feishu_chat_id` 退化成那次转人工的历史记录,不再被任何链路当作查找依据。 ## 回填 已经存在的工单群(handoff 行上 `feishu_chat_id` 与 `ticket_id` 都有值)整批搬进 新表,`opened_reason='handoff'`、`opened_by_person_id` 取当时的提问者。不回填的话 那些群从这条迁移之后就**不再被认作工单群** —— 它们会掉进「任意群」那条默认关着的 闸,已经在里面的人第二天开始得不到任何回应。 一张工单在 handoff 表里理论上可能有两行(同一张工单被转过两次,第二次拉了新群)。 `ticket_id` 唯一,所以回填取**最新的那一行**:那才是现在还在用的房间。 """ from __future__ import annotations import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for revision = "633" down_revision = "632" branch_labels = None depends_on = None TABLE = "assistant_ticket_chats" def upgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) if TABLE in inspector.get_table_names(): return op.create_table( TABLE, sa.Column("id", sa.CHAR(36), primary_key=True), # 类型走 ``id_type_for(父表)``:裸 ``sa.CHAR(36)`` 会继承表默认排序规则, # 而 TiDB 上父表可能是另一种 —— 那是 3780 的形状(见 migration_helpers)。 # ``ticket_id`` 刻意**不加外键**:工单在另一个域,而这张表在助手域; # 域间只由 id 关联,不由数据库约束互相锁死(工单被硬删的清理路径不该 # 因为一个助手侧的行而失败)。 sa.Column( "ticket_id", id_type_for("support_tickets"), nullable=False, ), sa.Column("feishu_chat_id", sa.String(length=64), nullable=False), sa.Column("card_message_id", sa.String(length=64), nullable=True), sa.Column( "state", sa.String(length=16), nullable=False, server_default="open", ), sa.Column( "opened_by_person_id", id_type_for("identity_persons"), sa.ForeignKey("identity_persons.id"), nullable=True, ), sa.Column( "opened_reason", sa.String(length=16), nullable=False, server_default="handoff", ), sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("close_reason", sa.String(length=32), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), ) # 两个唯一约束各挡一个方向的重复:一张工单最多一个群(第二次点「开群」必须 # 拿到同一个群 —— 建群不可撤销,两个讲同一件事的房间会让人各说各话), # 一个房间也不可能同时是两张工单的群。 op.create_unique_constraint( "uq_assistant_ticket_chat_ticket", TABLE, ["ticket_id"], ) op.create_unique_constraint( "uq_assistant_ticket_chat_chat", TABLE, ["feishu_chat_id"], ) op.create_index( "idx_assistant_ticket_chat_state", TABLE, ["state", "last_activity_at"], ) # 回填:已有的转人工群必须继续被认作工单群,否则它们明天开始就掉进 # 「任意群」那条默认关着的闸,屋里的人得不到任何回应。 # # 同一张工单可能有两行 handoff(转过两次),取最新那一行 —— 那才是现在 # 还在用的房间。 bind.execute(sa.text( f""" INSERT INTO {TABLE} (id, ticket_id, feishu_chat_id, state, opened_by_person_id, opened_reason, created_at, updated_at) SELECT UUID(), h.ticket_id, h.feishu_chat_id, 'open', h.person_id, 'handoff', h.created_at, h.created_at FROM assistant_handoffs h WHERE h.feishu_chat_id IS NOT NULL AND h.ticket_id IS NOT NULL AND h.id = ( SELECT h2.id FROM assistant_handoffs h2 WHERE h2.ticket_id = h.ticket_id AND h2.feishu_chat_id IS NOT NULL ORDER BY h2.created_at DESC, h2.id DESC LIMIT 1 ) AND h.id = ( SELECT h3.id FROM assistant_handoffs h3 WHERE h3.feishu_chat_id = h.feishu_chat_id AND h3.ticket_id IS NOT NULL ORDER BY h3.created_at DESC, h3.id DESC LIMIT 1 ) """, )) # ⚠️ ``UUID()`` 而不是 ``REPLACE(UUID(),'-','')``:模型侧的 id 是 GUID # TypeDecorator(CHAR(36),带横线)。32 位无横线的 id 在这个仓库里已经让 # 四条链路静默失配过 —— ORM 查得出行,却永远匹配不上。 def downgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) if TABLE in inspector.get_table_names(): op.drop_table(TABLE)