Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming 7b9f0c12ed refactor(wechat): 绑定表合一 channel_bindings(判别列+JSONB),取代 ClawBot/企微两表 + bump 0.24.3
架构复盘:渠道绑定 = "用户在某渠道的一份配置",各渠道字段形态不同 → 判别列 + JSONB 多态
(同本库 usage_events kind+units)最契合,加渠道(飞书/TG…)零 migration。原分表
(0012/0014)对 2 渠道够用但不扛增长、与库内多态范式不一致;单宽表(NULL 列并列)最差。

- models:`ChannelBinding(user_id, channel, status, config JSONB)` PK=(user_id,channel)
  取代 WeChatBotBinding/WeComBinding;clawbot 敏感字段 crypto 加密入 config,wecom 明文 userid。
- migration 0015:建表 + 旧两表数据搬进 config(token 密文串原样搬)+ drop 旧表;
  DDL+DML 同事务失败回滚不丢;含 down 拆回。
- service 存取改读写 config —— **公共 API + BindingSnapshot 形状不变** → inbound/web/tool/
  scheduler 零改动(纯内部数据层重构,对外行为不变)。趁绑定数据极少时合表最省。

import/编译 + _snap 反序列化单测过;DB 往返 + migration 待部署联调。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:55:39 +08:00
caoqianming 2dd1b49725 fix(web): 微信绑定弹框标题样式对齐其他弹框 + bump 0.24.2
#wechat-modal h3 只设了 flex,漏了 margin/padding/font-size/border-bottom,
吃浏览器默认 h3 样式导致标题又大又飘、无分隔线。补齐标题样式 +
h3 svg opacity + .sk-x 关闭按钮样式,与 crons/memory 弹框一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:42:57 +08:00
7 changed files with 244 additions and 95 deletions

View File

@ -694,10 +694,14 @@ create index on usage_events (model_profile, created_at);
**扫码绑定流程(iLink)**: **扫码绑定流程(iLink)**:
1. zcbot 网页"绑定微信" → 后端 `GET get_bot_qrcode?bot_type=3``{qrcode, qrcode_img_content}`,前端展示二维码。 1. zcbot 网页"绑定微信" → 后端 `GET get_bot_qrcode?bot_type=3``{qrcode, qrcode_img_content}`,前端展示二维码。
2. 后端 `GET get_qrcode_status?qrcode=<id>`(长轮询,单连 hold ≤35s,循环续)→ 用户用**个人微信**扫码确认 → 返回 `{status:'confirmed', bot_token, baseurl}` 2. 后端 `GET get_qrcode_status?qrcode=<id>`(长轮询,单连 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`,独立加表 → 公测兼容)**: **数据模型(统一表 `channel_bindings`,判别列 + JSONB 多态;0015 由旧 `wechat_bot_bindings`/`wecom_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。 `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 <bot_token>` **协议要点(自实现客户端,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 <bot_token>`
- **取码/绑定**:`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),实现要**过期自动换码**。 - **取码/绑定**:`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`)。 - **应用凭据(全局 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=<HMAC签+短TTL>` → 桌面出二维码扫 / 企业微信内静默 → 回调 `GET /v1/wecom/oauth/callback`(公开端点,身份从 state 验,非 JWT)→ `cgi-bin/auth/getuserinfo?code=``wecom_userid` → 写绑定。**需管理员另配「网页授权可信域名」指向 zcbot 域名**;redirect 主机取 `ZCBOT_PUBLIC_BASE_URL` 或请求 base。 - **扫码绑定(OAuth 网页授权)**:网页 rail「微信」modal「绑定企业微信」→ `oauth2/authorize?...scope=snsapi_base&agentid=&state=<HMAC签+短TTL>` → 桌面出二维码扫 / 企业微信内静默 → 回调 `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)。 - **推送**:`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 企业微信段。 - **接入**:`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 统一名)。 - **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-24(企业微信渠道 B:纯推送 + OAuth 扫码绑 userid,接入 send_to_user + bump 0.24.0) 最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3)
--- ---
@ -21,6 +21,18 @@
## 已完成关键能力 ## 已完成关键能力
### 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 + 无分隔线),看着比别的弹框又大又飘。
- 修复:`web/static/dev.html` 给 `#wechat-modal h3` 补齐标题样式,并加 `h3 svg{opacity:.85}``.sk-x` 关闭按钮样式,与 crons/memory 弹框对齐。
### 2026-06-24 / 修复 host-side 文件工具发不出附件(docker 容器路径未翻译,bump 0.24.1) ### 2026-06-24 / 修复 host-side 文件工具发不出附件(docker 容器路径未翻译,bump 0.24.1)
- 根因:生产 docker 模式下,fs 工具在容器里跑(文件落容器卷=宿主 `users/<uid>/<wd>/`),但 `send_email` / `wechat_push` 是**宿主进程**工具;它们 `base_dir=Path.cwd()`(部署根)且不识别容器↔宿主路径映射 → agent 给的相对路径拼到 cwd、容器绝对路径 `/workspace/...` 宿主上瞎解析,`relative_to(user_root)` 必越界 → 附件永远发不出(微信 DB 实锤 `#7` 相对 + `#15` 容器绝对两条都「文件路径越界」)。probe 脚本能发是因直接调 `send_file` 绕过解析。 - 根因:生产 docker 模式下,fs 工具在容器里跑(文件落容器卷=宿主 `users/<uid>/<wd>/`),但 `send_email` / `wechat_push` 是**宿主进程**工具;它们 `base_dir=Path.cwd()`(部署根)且不识别容器↔宿主路径映射 → agent 给的相对路径拼到 cwd、容器绝对路径 `/workspace/...` 宿主上瞎解析,`relative_to(user_root)` 必越界 → 附件永远发不出(微信 DB 实锤 `#7` 相对 + `#15` 容器绝对两条都「文件路径越界」)。probe 脚本能发是因直接调 `send_file` 绕过解析。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.24.1" __version__ = "0.24.3"

View File

@ -228,64 +228,30 @@ class ScheduledJob(Base):
) )
class WeChatBotBinding(Base): class ChannelBinding(Base):
"""ClawBot 个人微信绑定(0012,DESIGN §8.7 渠道 A)。 """微信渠道绑定(0015,DESIGN §8.7 渠道抽象)。
一行 = 一个 zcbot 用户绑定其个人微信微信 ClawBotPK=user_id 1 用户 1 绑定 一行 = 一个用户在某渠道(`channel`)的一份绑定配置;PK=(user_id, channel) 1 用户每渠道 1
- `bot_token`:扫码下发的长期 per-user 凭据 沿用本库判别列 + JSONB 多态范式( usage_events.kind+units / scheduled_jobs.notify):
- `latest_context_token` + `context_token_at`:每条入站消息刷新;主动推送靠它, 各渠道配置字段不同,全装进 `config` JSONB,加渠道不动 schema不再各建一表
仅在 ~24h 有效期内可用(冷启动 / 超期则推不出,退邮件兜底,§8.5)
两个 token 列存**密文**(core/wechat/crypto.py; ZCBOT_WECHAT_SECRET_KEY 时退明文标记) config 形态(敏感字段经 core/wechat/crypto.py 加密入 JSONB,绝不进沙箱/日志/API):
绝不进沙箱 / 日志 / API 响应(§3.4) - 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/FKper-字段 NOT NULL 退到应用层校验, usage_events JSONB 同向取舍
""" """
__tablename__ = "wechat_bot_bindings" __tablename__ = "channel_bindings"
user_id: Mapped[UUID] = mapped_column( user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), PG_UUID(as_uuid=True),
ForeignKey("users.user_id", ondelete="CASCADE"), ForeignKey("users.user_id", ondelete="CASCADE"),
primary_key=True, primary_key=True,
) )
bot_token: Mapped[str] = mapped_column(Text, nullable=False) # 密文 channel: Mapped[str] = mapped_column(Text, primary_key=True) # clawbot | wecom | ...
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)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default="active") # active|revoked 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@ -20,11 +20,21 @@ from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from core.storage import session_scope 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 import crypto
from core.wechat.ilink import DEFAULT_BASE, ILinkClient from core.wechat.ilink import DEFAULT_BASE, ILinkClient
CONTEXT_TOKEN_TTL = timedelta(hours=24) 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: def clawbot_enabled() -> bool:
@ -48,31 +58,38 @@ class BindingSnapshot:
status: str 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( return BindingSnapshot(
user_id=row.user_id, user_id=row.user_id,
bot_token=crypto.dec(row.bot_token) or "", bot_token=crypto.dec(cfg.get("bot_token")) or "",
base_url=row.base_url or DEFAULT_BASE, base_url=cfg.get("base_url") or DEFAULT_BASE,
user_im_id=row.user_im_id, user_im_id=cfg.get("user_im_id"),
context_token=crypto.dec(row.latest_context_token), context_token=crypto.dec(cfg.get("latest_context_token")),
context_token_at=row.context_token_at, context_token_at=datetime.fromisoformat(cta) if cta else None,
chat_task_id=row.chat_task_id, chat_task_id=UUID(cti) if cti else None,
status=row.status, status=row.status,
) )
def get_binding(user_id: UUID) -> Optional[BindingSnapshot]: def get_binding(user_id: UUID) -> Optional[BindingSnapshot]:
with session_scope() as s: 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 return _snap(row) if row else None
def list_active_bindings() -> list[BindingSnapshot]: def list_active_bindings() -> list[BindingSnapshot]:
"""入站长轮询管理器用:所有 active 绑定(含明文 bot_token)。""" """入站长轮询管理器用:所有 active 的 ClawBot 绑定(含明文 bot_token)。"""
with session_scope() as s: with session_scope() as s:
rows = ( rows = (
s.execute( s.execute(
select(WeChatBotBinding).where(WeChatBotBinding.status == "active") select(ChannelBinding).where(
ChannelBinding.channel == _CLAWBOT,
ChannelBinding.status == "active",
)
) )
.scalars() .scalars()
.all() .all()
@ -83,17 +100,16 @@ def list_active_bindings() -> list[BindingSnapshot]:
def upsert_clawbot_binding( def upsert_clawbot_binding(
user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None
) -> None: ) -> None:
"""扫码 confirmed 后写/更新绑定。bot_token 加密入库""" """扫码 confirmed 后写/更新绑定。bot_token 加密存进 config(保留已有 user_im_id 等)"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
row = s.get(WeChatBotBinding, user_id) row = _get_or_new(s, user_id, _CLAWBOT)
if row is None: cfg = dict(row.config or {})
row = WeChatBotBinding(user_id=user_id) cfg["bot_token"] = crypto.enc(bot_token)
s.add(row) cfg["base_url"] = base_url or DEFAULT_BASE
row.bot_token = crypto.enc(bot_token)
row.base_url = base_url or DEFAULT_BASE
if bot_im_id: 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.status = "active"
row.updated_at = now row.updated_at = now
@ -102,30 +118,34 @@ def refresh_context_token(user_id: UUID, user_im_id: str, context_token: str) ->
"""每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。""" """每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
row = s.get(WeChatBotBinding, user_id) row = s.get(ChannelBinding, (user_id, _CLAWBOT))
if row is None: if row is None:
return return
cfg = dict(row.config or {})
if user_im_id: if user_im_id:
row.user_im_id = user_im_id cfg["user_im_id"] = user_im_id
row.latest_context_token = crypto.enc(context_token) cfg["latest_context_token"] = crypto.enc(context_token)
row.context_token_at = now cfg["context_token_at"] = now.isoformat()
row.config = cfg
row.updated_at = now row.updated_at = now
def set_chat_task(user_id: UUID, task_id: UUID) -> None: def set_chat_task(user_id: UUID, task_id: UUID) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
row = s.get(WeChatBotBinding, user_id) row = s.get(ChannelBinding, (user_id, _CLAWBOT))
if row is not None: 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 row.updated_at = now
def unbind(user_id: UUID) -> bool: def unbind(user_id: UUID) -> bool:
"""解绑(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。""" """解绑 ClawBot(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
row = s.get(WeChatBotBinding, user_id) row = s.get(ChannelBinding, (user_id, _CLAWBOT))
if row is None: if row is None:
return False return False
row.status = "revoked" row.status = "revoked"
@ -177,33 +197,27 @@ def push_clawbot(
# ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)─────────────── # ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)───────────────
def get_wecom_userid(user_id: UUID) -> Optional[str]: def get_wecom_userid(user_id: UUID) -> Optional[str]:
from core.storage.models import WeComBinding
with session_scope() as s: 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": if row is None or row.status != "active":
return None return None
return row.wecom_userid return (row.config or {}).get("wecom_userid")
def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None: def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None:
"""OAuth 拿到 userid 后写/更新绑定。""" """OAuth 拿到 userid 后写/更新绑定。"""
from core.storage.models import WeComBinding
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
row = s.get(WeComBinding, user_id) row = _get_or_new(s, user_id, _WECOM)
if row is None: row.config = {"wecom_userid": wecom_userid}
s.add(WeComBinding(user_id=user_id, wecom_userid=wecom_userid)) row.status = "active"
else: row.updated_at = now
row.wecom_userid = wecom_userid
row.status = "active"
row.updated_at = now
def unbind_wecom(user_id: UUID) -> bool: def unbind_wecom(user_id: UUID) -> bool:
from core.storage.models import WeComBinding
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as s: with session_scope() as s:
row = s.get(WeComBinding, user_id) row = s.get(ChannelBinding, (user_id, _WECOM))
if row is None: if row is None:
return False return False
row.status = "revoked" row.status = "revoked"

View File

@ -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")

View File

@ -295,8 +295,17 @@
/* 定时任务 modal(只读 + 停用/删除,DESIGN §8.5)— 复用 .sk-item/.sk-badge/.sk-empty */ /* 定时任务 modal(只读 + 停用/删除,DESIGN §8.5)— 复用 .sk-item/.sk-badge/.sk-empty */
#wechat-modal { z-index: 112; } #wechat-modal { z-index: 112; }
#wechat-modal .card { width: 440px; max-width: 94vw; } #wechat-modal .card { width: 440px; max-width: 94vw; }
#wechat-modal h3 { display: flex; align-items: center; gap: 8px; } #wechat-modal h3 {
margin: 0; padding: 12px 16px; font-size: 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
#wechat-modal h3 .spacer { flex: 1; } #wechat-modal h3 .spacer { flex: 1; }
#wechat-modal h3 svg { opacity: .85; }
#wechat-modal .sk-x {
border: none; background: transparent; font-size: 16px;
cursor: pointer; color: var(--muted); padding: 2px 6px;
}
#wx-body { padding: 16px; overflow: auto; } #wx-body { padding: 16px; overflow: auto; }
#wx-body .wx-status { padding: 10px 12px; border-radius: 8px; font-size: 14px; margin-bottom: 14px; background: var(--code-bg, #f6f8fa); } #wx-body .wx-status { padding: 10px 12px; border-radius: 8px; font-size: 14px; margin-bottom: 14px; background: var(--code-bg, #f6f8fa); }
#wx-body .wx-status.ok { background: #e6f4ea; color: #1a7f37; } #wx-body .wx-status.ok { background: #e6f4ea; color: #1a7f37; }