diff --git a/DESIGN.md b/DESIGN.md index 71a52ec..1c77afe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -694,10 +694,14 @@ create index on usage_events (model_profile, created_at); **扫码绑定流程(iLink)**: 1. zcbot 网页"绑定微信" → 后端 `GET get_bot_qrcode?bot_type=3` → `{qrcode, qrcode_img_content}`,前端展示二维码。 2. 后端 `GET get_qrcode_status?qrcode=`(长轮询,单连 hold ≤35s,循环续)→ 用户用**个人微信**扫码确认 → 返回 `{status:'confirmed', bot_token, baseurl}`。 -3. 把当前登录 zcbot user 与返回的 `bot_token/baseurl/user_im_id` upsert 进 `wechat_bot_bindings`。前端轮询自己的绑定状态翻转。 +3. 把当前登录 zcbot user 与返回的 `bot_token/baseurl/user_im_id` upsert 进 `channel_bindings`(channel='clawbot')。前端轮询自己的绑定状态翻转。 -**数据模型(新表 `wechat_bot_bindings`,独立加表 → 公测兼容)**: -`user_id(PK/FK→users), bot_token(敏感,长期), bot_im_id, user_im_id(xxx@im.wechat), base_url, latest_context_token(敏感,入站时刷新), context_token_at(时间戳,判 24h 推送窗口), status(active|revoked), created_at, updated_at`。migration `0012_wechat_bot_bindings`。`bot_token` / `latest_context_token` 是**敏感凭据**:加密列存或至少**绝不进沙箱 / 不落日志**(沿用 §3.4 密钥隔离);不进 run_python。 +**数据模型(统一表 `channel_bindings`,判别列 + JSONB 多态;0015 由旧 `wechat_bot_bindings`/`wecom_bindings` 合并而来)**: +`user_id, channel, status, config(JSONB), created_at, updated_at`,PK=(user_id, channel)。沿用本库 `usage_events`(kind+units)范式 —— 各渠道字段装 `config`,加渠道不动 schema。 +- channel='clawbot' 的 config:`{bot_token*, bot_im_id, user_im_id, base_url, latest_context_token*, context_token_at(iso), chat_task_id}`(`*`=经 crypto 加密入 JSONB;`latest_context_token`+`context_token_at` 判 24h 推送窗口)。 +- channel='wecom' 的 config:`{wecom_userid}`(企业成员 id,非密钥、明文)。 +- 敏感字段加密 + **绝不进沙箱 / 不落日志 / API**(§3.4);`chat_task_id` FK 与 per-字段 NOT NULL 退应用层校验(与 usage_events JSONB 同向取舍)。 +> **为何统一表(2026-06-24 重构,§设计取舍)**:渠道绑定 = "用户在某渠道的一份配置",各渠道字段形态不同 → 用判别列 + JSONB(同 usage_events)最契合本库,且渠道增长(飞书/TG…)零 migration。分表(每渠道一表)对 2 渠道够用但不扛增长、与库内多态范式不一致;单宽表(NULL 列并列)2 列 vs 8 列硬并、稀疏 + 破坏 NOT NULL,最差。趁绑定数据极少时合表(migration 0015 搬数据,DDL 同事务失败回滚不丢)。 **协议要点(自实现客户端,2026-06-23 实测验证)**:base = 绑定返回的 `base_url`(实测 `https://ilinkai.weixin.qq.com`)。所有请求 header:`Content-Type: application/json` + `AuthorizationType: ilink_bot_token` + **`X-WECHAT-UIN` 每请求变**(`base64(随机uint32)`,反重放);除取码/查状态外加 `Authorization: Bearer `。 - **取码/绑定**:`GET /ilink/bot/get_bot_qrcode?bot_type=3`(无需任何预置凭据)→ `{qrcode, qrcode_img_content}`,`qrcode_img_content` 是**微信深链**(`liteapp.weixin.qq.com/q/...`),需**自渲成二维码**(非图片直链);`GET /ilink/bot/get_qrcode_status?qrcode=`(长轮询)→ `{status: wait|confirmed|expired, bot_token, baseurl}`。二维码 TTL 短(~1min),实现要**过期自动换码**。 @@ -722,7 +726,7 @@ create index on usage_events (model_profile, created_at); - **应用凭据(全局 env,需管理员建应用)**:`WECOM_CORPID / WECOM_AGENTID / WECOM_SECRET`;secret 仅 host 进程读、不进沙箱(同 ClawBot / `send_email`)。host 直连 `qyapi.weixin.qq.com`(`core/wechat/wecom.py`)。 - **扫码绑定(OAuth 网页授权)**:网页 rail「微信」modal「绑定企业微信」→ `oauth2/authorize?...scope=snsapi_base&agentid=&state=` → 桌面出二维码扫 / 企业微信内静默 → 回调 `GET /v1/wecom/oauth/callback`(公开端点,身份从 state 验,非 JWT)→ `cgi-bin/auth/getuserinfo?code=` 拿 `wecom_userid` → 写绑定。**需管理员另配「网页授权可信域名」指向 zcbot 域名**;redirect 主机取 `ZCBOT_PUBLIC_BASE_URL` 或请求 base。 - **推送**:`gettoken` → `access_token`(2h 缓存 + 提前刷新 + 线程安全锁 + 40014/42001 失效重取)→ `message/send` text/file(file 先 `media/upload?type=file` 换 `media_id`,≤20MB)。 -- **数据**:独立表 `wecom_bindings(user_id PK, wecom_userid 明文非密钥, status)`,migration `0014`(0013 被 task_channel 占)。多企业留 nullable `corpid/permanent_code` 走服务商 ISV(additive,YAGNI)。 +- **数据**:统一进 `channel_bindings`(channel='wecom',config=`{wecom_userid}`,明文非密钥);最初 0014 单建 `wecom_bindings`,0015 合进统一表(见上数据模型)。多企业留 `corpid/permanent_code` 进同一 config(additive,YAGNI)。 - **接入**:`service.push_wecom` + `send_to_user` 加 wecom 一路(已绑则推);scheduler `deliver_notify` 的 `wechat` 通道经 `send_to_user` 自动带上企业微信。端点 `/v1/wecom/oauth/url|callback`、`/v1/wecom/bind` GET/DELETE、`/v1/wecom/test`;前端 rail modal 企业微信段。 - **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。 diff --git a/PROGRESS.md b/PROGRESS.md index 43cc207..74772bc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-24(修复微信绑定弹框标题样式错乱,与其他弹框对齐 + bump 0.24.2) +最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3) --- @@ -21,6 +21,13 @@ ## 已完成关键能力 +### 2026-06-24 / 微信绑定表重构:两表合一 channel_bindings(判别列+JSONB,bump 0.24.3) + +- 起因:ClawBot(0012 `wechat_bot_bindings`,8 列)+ 企微(0014 `wecom_bindings`,1 列)各一表。从架构角度复盘:渠道绑定本质="用户在某渠道的一份配置",各渠道字段形态不同 → 最优是**判别列 + JSONB 多态**(与本库 `usage_events` kind+units / `scheduled_jobs.notify` 同范式),加渠道(飞书/TG…)零 migration。分表不扛增长、与库内范式不一致;单宽表(NULL 列并列)最差。 +- 重构:`ChannelBinding(user_id, channel, status, config JSONB)` PK=(user_id,channel);clawbot config 装 `{bot_token*, user_im_id, base_url, latest_context_token*, context_token_at, chat_task_id}`(`*` crypto 加密入 JSONB),wecom 装 `{wecom_userid}`。migration `0015` 建表 + 把旧两表数据搬进 config(token 本就是密文串、原样搬)+ drop 旧表;DDL+DML 同事务,失败回滚不丢。 +- **关键:只动 models + service 内部 + migration**,`service` 公共 API 与 `BindingSnapshot` 形状不变 → inbound/web/tool/scheduler **零改动**(纯内部数据层重构,对外行为不变)。趁绑定数据极少时合表最省。 +- 文件:`core/storage/models.py`(`ChannelBinding` 替 `WeChatBotBinding`/`WeComBinding`)、`core/wechat/service.py`(存取改读写 config)、migration `0015_channel_bindings`(含 down 拆回)。import/编译 + `_snap` 反序列化单测过;DB 往返 + migration 待部署联调。 + ### 2026-06-24 / 修复微信绑定弹框标题样式错乱(bump 0.24.2) - 根因:`#wechat-modal h3` 只设了 flex 布局,漏了其他弹框(crons/memory)都有的 `margin:0; padding:12px 16px; font-size:16px; border-bottom` → 标题吃浏览器默认 h3 样式(大字号 + ~21px 上下默认 margin + 无分隔线),看着比别的弹框又大又飘。 diff --git a/core/__init__.py b/core/__init__.py index 4b9400c..5575ef5 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.24.2" +__version__ = "0.24.3" diff --git a/core/storage/models.py b/core/storage/models.py index 93f470b..0d9731b 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -228,64 +228,30 @@ class ScheduledJob(Base): ) -class WeChatBotBinding(Base): - """ClawBot 个人微信绑定(0012,DESIGN §8.7 渠道 A)。 +class ChannelBinding(Base): + """微信渠道绑定(0015,DESIGN §8.7 渠道抽象)。 - 一行 = 一个 zcbot 用户绑定其个人微信「微信 ClawBot」。PK=user_id → 1 用户 1 绑定。 - - `bot_token`:扫码下发的长期 per-user 凭据。 - - `latest_context_token` + `context_token_at`:每条入站消息刷新;主动推送靠它, - 仅在 ~24h 有效期内可用(冷启动 / 超期则推不出,退邮件兜底,§8.5)。 - 两个 token 列存**密文**(core/wechat/crypto.py;无 ZCBOT_WECHAT_SECRET_KEY 时退明文标记)。 - 绝不进沙箱 / 日志 / API 响应(§3.4)。 + 一行 = 一个用户在某渠道(`channel`)的一份绑定配置;PK=(user_id, channel) → 1 用户每渠道 1 行。 + 沿用本库「判别列 + JSONB 多态」范式(同 usage_events.kind+units / scheduled_jobs.notify): + 各渠道配置字段不同,全装进 `config` JSONB,加渠道不动 schema、不再各建一表。 + + config 形态(敏感字段经 core/wechat/crypto.py 加密入 JSONB,绝不进沙箱/日志/API): + - channel='clawbot':{bot_token*, bot_im_id, user_im_id, base_url, latest_context_token*, + context_token_at(iso), chat_task_id(str)} —— *=密文;context_token 24h 窗口主动推靠它。 + - channel='wecom':{wecom_userid} —— 企业成员 id,非密钥、明文;无条件推。 + (chat_task_id/FK、per-字段 NOT NULL 退到应用层校验,与 usage_events JSONB 同向取舍。) """ - __tablename__ = "wechat_bot_bindings" + __tablename__ = "channel_bindings" user_id: Mapped[UUID] = mapped_column( PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True, ) - bot_token: Mapped[str] = mapped_column(Text, nullable=False) # 密文 - bot_im_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # xxx@im.bot - user_im_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # xxx@im.wechat - base_url: Mapped[str] = mapped_column( - Text, nullable=False, server_default="https://ilinkai.weixin.qq.com" - ) - latest_context_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 密文,入站刷新 - context_token_at: Mapped[Optional[datetime]] = mapped_column( - DateTime(timezone=True), nullable=True - ) - # 该用户的微信对话常驻 task(persistent,§8.7 决策);两渠道入站都落这条 - chat_task_id: Mapped[Optional[UUID]] = mapped_column( - PG_UUID(as_uuid=True), ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True - ) - status: Mapped[str] = mapped_column(Text, nullable=False, server_default="active") # active|revoked - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - -class WeComBinding(Base): - """企业微信绑定(0014,DESIGN §8.7 渠道 B,纯推送)。 - - 一行 = 一个 zcbot 用户的企业微信成员 `userid`(OAuth 扫码拿,见 core/wechat/wecom.py)。 - 应用凭据(corpid/agentid/secret)走全局 env,不入库。`wecom_userid` 是企业内成员 id、 - 非密钥 → 明文存。推送**无条件**(不挑活跃度、无 24h 窗口),正补 ClawBot 短板。 - """ - - __tablename__ = "wecom_bindings" - - user_id: Mapped[UUID] = mapped_column( - PG_UUID(as_uuid=True), - ForeignKey("users.user_id", ondelete="CASCADE"), - primary_key=True, - ) - wecom_userid: Mapped[str] = mapped_column(Text, nullable=False) + channel: Mapped[str] = mapped_column(Text, primary_key=True) # clawbot | wecom | ... status: Mapped[str] = mapped_column(Text, nullable=False, server_default="active") # active|revoked + config: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/core/wechat/service.py b/core/wechat/service.py index c50c322..feb1f63 100644 --- a/core/wechat/service.py +++ b/core/wechat/service.py @@ -20,11 +20,21 @@ from uuid import UUID from sqlalchemy import select from core.storage import session_scope -from core.storage.models import WeChatBotBinding +from core.storage.models import ChannelBinding from core.wechat import crypto from core.wechat.ilink import DEFAULT_BASE, ILinkClient CONTEXT_TOKEN_TTL = timedelta(hours=24) +_CLAWBOT = "clawbot" +_WECOM = "wecom" + + +def _get_or_new(s, user_id: UUID, channel: str) -> ChannelBinding: + row = s.get(ChannelBinding, (user_id, channel)) + if row is None: + row = ChannelBinding(user_id=user_id, channel=channel, config={}) + s.add(row) + return row def clawbot_enabled() -> bool: @@ -48,31 +58,38 @@ class BindingSnapshot: status: str -def _snap(row: WeChatBotBinding) -> BindingSnapshot: +def _snap(row: ChannelBinding) -> BindingSnapshot: + """channel='clawbot' 行 → 快照(解密 token,反序列化 config)。""" + cfg = row.config or {} + cta = cfg.get("context_token_at") + cti = cfg.get("chat_task_id") return BindingSnapshot( user_id=row.user_id, - bot_token=crypto.dec(row.bot_token) or "", - base_url=row.base_url or DEFAULT_BASE, - user_im_id=row.user_im_id, - context_token=crypto.dec(row.latest_context_token), - context_token_at=row.context_token_at, - chat_task_id=row.chat_task_id, + bot_token=crypto.dec(cfg.get("bot_token")) or "", + base_url=cfg.get("base_url") or DEFAULT_BASE, + user_im_id=cfg.get("user_im_id"), + context_token=crypto.dec(cfg.get("latest_context_token")), + context_token_at=datetime.fromisoformat(cta) if cta else None, + chat_task_id=UUID(cti) if cti else None, status=row.status, ) def get_binding(user_id: UUID) -> Optional[BindingSnapshot]: with session_scope() as s: - row = s.get(WeChatBotBinding, user_id) + row = s.get(ChannelBinding, (user_id, _CLAWBOT)) return _snap(row) if row else None def list_active_bindings() -> list[BindingSnapshot]: - """入站长轮询管理器用:所有 active 绑定(含明文 bot_token)。""" + """入站长轮询管理器用:所有 active 的 ClawBot 绑定(含明文 bot_token)。""" with session_scope() as s: rows = ( s.execute( - select(WeChatBotBinding).where(WeChatBotBinding.status == "active") + select(ChannelBinding).where( + ChannelBinding.channel == _CLAWBOT, + ChannelBinding.status == "active", + ) ) .scalars() .all() @@ -83,17 +100,16 @@ def list_active_bindings() -> list[BindingSnapshot]: def upsert_clawbot_binding( user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None ) -> None: - """扫码 confirmed 后写/更新绑定。bot_token 加密入库。""" + """扫码 confirmed 后写/更新绑定。bot_token 加密存进 config(保留已有 user_im_id 等)。""" now = datetime.now(timezone.utc) with session_scope() as s: - row = s.get(WeChatBotBinding, user_id) - if row is None: - row = WeChatBotBinding(user_id=user_id) - s.add(row) - row.bot_token = crypto.enc(bot_token) - row.base_url = base_url or DEFAULT_BASE + row = _get_or_new(s, user_id, _CLAWBOT) + cfg = dict(row.config or {}) + cfg["bot_token"] = crypto.enc(bot_token) + cfg["base_url"] = base_url or DEFAULT_BASE if bot_im_id: - row.bot_im_id = bot_im_id + cfg["bot_im_id"] = bot_im_id + row.config = cfg # 重新赋值 → ORM 追踪 JSONB 变更 row.status = "active" row.updated_at = now @@ -102,30 +118,34 @@ def refresh_context_token(user_id: UUID, user_im_id: str, context_token: str) -> """每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。""" now = datetime.now(timezone.utc) with session_scope() as s: - row = s.get(WeChatBotBinding, user_id) + row = s.get(ChannelBinding, (user_id, _CLAWBOT)) if row is None: return + cfg = dict(row.config or {}) if user_im_id: - row.user_im_id = user_im_id - row.latest_context_token = crypto.enc(context_token) - row.context_token_at = now + cfg["user_im_id"] = user_im_id + cfg["latest_context_token"] = crypto.enc(context_token) + cfg["context_token_at"] = now.isoformat() + row.config = cfg row.updated_at = now def set_chat_task(user_id: UUID, task_id: UUID) -> None: now = datetime.now(timezone.utc) with session_scope() as s: - row = s.get(WeChatBotBinding, user_id) + row = s.get(ChannelBinding, (user_id, _CLAWBOT)) if row is not None: - row.chat_task_id = task_id + cfg = dict(row.config or {}) + cfg["chat_task_id"] = str(task_id) + row.config = cfg row.updated_at = now def unbind(user_id: UUID) -> bool: - """解绑(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。""" + """解绑 ClawBot(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。""" now = datetime.now(timezone.utc) with session_scope() as s: - row = s.get(WeChatBotBinding, user_id) + row = s.get(ChannelBinding, (user_id, _CLAWBOT)) if row is None: return False row.status = "revoked" @@ -177,33 +197,27 @@ def push_clawbot( # ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)─────────────── def get_wecom_userid(user_id: UUID) -> Optional[str]: - from core.storage.models import WeComBinding with session_scope() as s: - row = s.get(WeComBinding, user_id) + row = s.get(ChannelBinding, (user_id, _WECOM)) if row is None or row.status != "active": return None - return row.wecom_userid + return (row.config or {}).get("wecom_userid") def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None: """OAuth 拿到 userid 后写/更新绑定。""" - from core.storage.models import WeComBinding now = datetime.now(timezone.utc) with session_scope() as s: - row = s.get(WeComBinding, user_id) - if row is None: - s.add(WeComBinding(user_id=user_id, wecom_userid=wecom_userid)) - else: - row.wecom_userid = wecom_userid - row.status = "active" - row.updated_at = now + row = _get_or_new(s, user_id, _WECOM) + row.config = {"wecom_userid": wecom_userid} + row.status = "active" + row.updated_at = now def unbind_wecom(user_id: UUID) -> bool: - from core.storage.models import WeComBinding now = datetime.now(timezone.utc) with session_scope() as s: - row = s.get(WeComBinding, user_id) + row = s.get(ChannelBinding, (user_id, _WECOM)) if row is None: return False row.status = "revoked" diff --git a/db/migrations/versions/20260624_1500_0015_channel_bindings.py b/db/migrations/versions/20260624_1500_0015_channel_bindings.py new file mode 100644 index 0000000..f0aaea6 --- /dev/null +++ b/db/migrations/versions/20260624_1500_0015_channel_bindings.py @@ -0,0 +1,144 @@ +"""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")