zcbot/db/migrations/versions/20260624_1500_0015_channel_...

145 lines
7.0 KiB
Python

"""channel_bindings 统一表(微信渠道抽象,DESIGN §8.7).
Revision ID: 0015
Revises: 0014
Create Date: 2026-06-24
把 0012 wechat_bot_bindings(ClawBot)+ 0014 wecom_bindings(企业微信)合成一张
判别列 + JSONB 表 channel_bindings(user_id, channel, status, config),沿用本库
usage_events(kind+units)的多态范式 —— 加渠道不再各建表。
数据迁移:旧两表的行搬进 config JSONB(敏感 token 列本就是密文串,原样搬、不重新加密),
再 drop 旧表。DDL + DML 同一事务,失败整体回滚不丢数据。详 DESIGN §8.7。
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
revision: str = "0015"
down_revision: Union[str, None] = "0014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"channel_bindings",
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("channel", sa.Text(), primary_key=True), # clawbot | wecom | ...
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("config", JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 入站管理器/推送:按 (channel, status) 扫某渠道活跃绑定
op.create_index("ix_channel_bindings_channel", "channel_bindings", ["channel", "status"])
conn = op.get_bind()
insert = sa.text(
"INSERT INTO channel_bindings (user_id, channel, status, config, created_at, updated_at) "
"VALUES (:uid, :ch, :st, CAST(:cfg AS JSONB), :ca, :ua)"
)
# 0012 wechat_bot_bindings → channel='clawbot'(token 列已是密文串,原样搬)
insp = sa.inspect(conn)
if insp.has_table("wechat_bot_bindings"):
rows = conn.execute(sa.text(
"SELECT user_id, bot_token, bot_im_id, user_im_id, base_url, "
"latest_context_token, context_token_at, chat_task_id, status, created_at, updated_at "
"FROM wechat_bot_bindings"
)).mappings().all()
for r in rows:
cfg = {
"bot_token": r["bot_token"],
"bot_im_id": r["bot_im_id"],
"user_im_id": r["user_im_id"],
"base_url": r["base_url"],
"latest_context_token": r["latest_context_token"],
"context_token_at": r["context_token_at"].isoformat() if r["context_token_at"] else None,
"chat_task_id": str(r["chat_task_id"]) if r["chat_task_id"] else None,
}
conn.execute(insert, {
"uid": r["user_id"], "ch": "clawbot", "st": r["status"],
"cfg": json.dumps(cfg), "ca": r["created_at"], "ua": r["updated_at"],
})
op.drop_table("wechat_bot_bindings")
# 0014 wecom_bindings → channel='wecom'
if insp.has_table("wecom_bindings"):
rows = conn.execute(sa.text(
"SELECT user_id, wecom_userid, status, created_at, updated_at FROM wecom_bindings"
)).mappings().all()
for r in rows:
cfg = {"wecom_userid": r["wecom_userid"]}
conn.execute(insert, {
"uid": r["user_id"], "ch": "wecom", "st": r["status"],
"cfg": json.dumps(cfg), "ca": r["created_at"], "ua": r["updated_at"],
})
op.drop_table("wecom_bindings")
def downgrade() -> None:
# 回滚:重建旧两表 + 把 config 拆回列,再 drop channel_bindings。
op.create_table(
"wechat_bot_bindings",
sa.Column("user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True),
sa.Column("bot_token", sa.Text(), nullable=False),
sa.Column("bot_im_id", sa.Text(), nullable=True),
sa.Column("user_im_id", sa.Text(), nullable=True),
sa.Column("base_url", sa.Text(), nullable=False,
server_default="https://ilinkai.weixin.qq.com"),
sa.Column("latest_context_token", sa.Text(), nullable=True),
sa.Column("context_token_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("chat_task_id", PG_UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"wecom_bindings",
sa.Column("user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True),
sa.Column("wecom_userid", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
conn = op.get_bind()
rows = conn.execute(sa.text(
"SELECT user_id, channel, status, config, created_at, updated_at FROM channel_bindings"
)).mappings().all()
for r in rows:
cfg = r["config"] or {}
if r["channel"] == "clawbot":
conn.execute(sa.text(
"INSERT INTO wechat_bot_bindings (user_id, bot_token, bot_im_id, user_im_id, base_url, "
"latest_context_token, context_token_at, chat_task_id, status, created_at, updated_at) "
"VALUES (:uid, :bt, :bim, :uim, :bu, :lct, CAST(:cta AS timestamptz), "
"CAST(:cti AS uuid), :st, :ca, :ua)"
), {
"uid": r["user_id"], "bt": cfg.get("bot_token") or "", "bim": cfg.get("bot_im_id"),
"uim": cfg.get("user_im_id"), "bu": cfg.get("base_url") or "https://ilinkai.weixin.qq.com",
"lct": cfg.get("latest_context_token"), "cta": cfg.get("context_token_at"),
"cti": cfg.get("chat_task_id"), "st": r["status"],
"ca": r["created_at"], "ua": r["updated_at"],
})
elif r["channel"] == "wecom":
conn.execute(sa.text(
"INSERT INTO wecom_bindings (user_id, wecom_userid, status, created_at, updated_at) "
"VALUES (:uid, :wu, :st, :ca, :ua)"
), {
"uid": r["user_id"], "wu": cfg.get("wecom_userid") or "",
"st": r["status"], "ca": r["created_at"], "ua": r["updated_at"],
})
op.drop_index("ix_channel_bindings_channel", table_name="channel_bindings")
op.drop_table("channel_bindings")