Compare commits
No commits in common. "7b9f0c12ed82547d3008055c2a6ee2d0192044bc" and "6008e1b8a03a4376e79672782299f25c09607f6a" have entirely different histories.
7b9f0c12ed
...
6008e1b8a0
12
DESIGN.md
12
DESIGN.md
|
|
@ -694,14 +694,10 @@ 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=<id>`(长轮询,单连 hold ≤35s,循环续)→ 用户用**个人微信**扫码确认 → 返回 `{status:'confirmed', bot_token, baseurl}`。
|
||||
3. 把当前登录 zcbot user 与返回的 `bot_token/baseurl/user_im_id` upsert 进 `channel_bindings`(channel='clawbot')。前端轮询自己的绑定状态翻转。
|
||||
3. 把当前登录 zcbot user 与返回的 `bot_token/baseurl/user_im_id` upsert 进 `wechat_bot_bindings`。前端轮询自己的绑定状态翻转。
|
||||
|
||||
**数据模型(统一表 `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 同事务失败回滚不丢)。
|
||||
**数据模型(新表 `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。
|
||||
|
||||
**协议要点(自实现客户端,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),实现要**过期自动换码**。
|
||||
|
|
@ -726,7 +722,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=<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)。
|
||||
- **数据**:统一进 `channel_bindings`(channel='wecom',config=`{wecom_userid}`,明文非密钥);最初 0014 单建 `wecom_bindings`,0015 合进统一表(见上数据模型)。多企业留 `corpid/permanent_code` 进同一 config(additive,YAGNI)。
|
||||
- **数据**:独立表 `wecom_bindings(user_id PK, wecom_userid 明文非密钥, status)`,migration `0014`(0013 被 task_channel 占)。多企业留 nullable `corpid/permanent_code` 走服务商 ISV(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 统一名)。
|
||||
|
||||
|
|
|
|||
14
PROGRESS.md
14
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3)
|
||||
最后更新:2026-06-24(企业微信渠道 B:纯推送 + OAuth 扫码绑 userid,接入 send_to_user + bump 0.24.0)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,18 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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)
|
||||
|
||||
- 根因:生产 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` 绕过解析。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.24.3"
|
||||
__version__ = "0.24.1"
|
||||
|
|
|
|||
|
|
@ -228,30 +228,64 @@ class ScheduledJob(Base):
|
|||
)
|
||||
|
||||
|
||||
class ChannelBinding(Base):
|
||||
"""微信渠道绑定(0015,DESIGN §8.7 渠道抽象)。
|
||||
class WeChatBotBinding(Base):
|
||||
"""ClawBot 个人微信绑定(0012,DESIGN §8.7 渠道 A)。
|
||||
|
||||
一行 = 一个用户在某渠道(`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 同向取舍。)
|
||||
一行 = 一个 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)。
|
||||
"""
|
||||
|
||||
__tablename__ = "channel_bindings"
|
||||
__tablename__ = "wechat_bot_bindings"
|
||||
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True),
|
||||
ForeignKey("users.user_id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
channel: Mapped[str] = mapped_column(Text, primary_key=True) # clawbot | wecom | ...
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,21 +20,11 @@ from uuid import UUID
|
|||
from sqlalchemy import select
|
||||
|
||||
from core.storage import session_scope
|
||||
from core.storage.models import ChannelBinding
|
||||
from core.storage.models import WeChatBotBinding
|
||||
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:
|
||||
|
|
@ -58,38 +48,31 @@ class BindingSnapshot:
|
|||
status: str
|
||||
|
||||
|
||||
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")
|
||||
def _snap(row: WeChatBotBinding) -> BindingSnapshot:
|
||||
return BindingSnapshot(
|
||||
user_id=row.user_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,
|
||||
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,
|
||||
status=row.status,
|
||||
)
|
||||
|
||||
|
||||
def get_binding(user_id: UUID) -> Optional[BindingSnapshot]:
|
||||
with session_scope() as s:
|
||||
row = s.get(ChannelBinding, (user_id, _CLAWBOT))
|
||||
row = s.get(WeChatBotBinding, user_id)
|
||||
return _snap(row) if row else None
|
||||
|
||||
|
||||
def list_active_bindings() -> list[BindingSnapshot]:
|
||||
"""入站长轮询管理器用:所有 active 的 ClawBot 绑定(含明文 bot_token)。"""
|
||||
"""入站长轮询管理器用:所有 active 绑定(含明文 bot_token)。"""
|
||||
with session_scope() as s:
|
||||
rows = (
|
||||
s.execute(
|
||||
select(ChannelBinding).where(
|
||||
ChannelBinding.channel == _CLAWBOT,
|
||||
ChannelBinding.status == "active",
|
||||
)
|
||||
select(WeChatBotBinding).where(WeChatBotBinding.status == "active")
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
|
|
@ -100,16 +83,17 @@ 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 加密存进 config(保留已有 user_im_id 等)。"""
|
||||
"""扫码 confirmed 后写/更新绑定。bot_token 加密入库。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
with session_scope() as s:
|
||||
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
|
||||
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
|
||||
if bot_im_id:
|
||||
cfg["bot_im_id"] = bot_im_id
|
||||
row.config = cfg # 重新赋值 → ORM 追踪 JSONB 变更
|
||||
row.bot_im_id = bot_im_id
|
||||
row.status = "active"
|
||||
row.updated_at = now
|
||||
|
||||
|
|
@ -118,34 +102,30 @@ 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(ChannelBinding, (user_id, _CLAWBOT))
|
||||
row = s.get(WeChatBotBinding, user_id)
|
||||
if row is None:
|
||||
return
|
||||
cfg = dict(row.config or {})
|
||||
if user_im_id:
|
||||
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.user_im_id = user_im_id
|
||||
row.latest_context_token = crypto.enc(context_token)
|
||||
row.context_token_at = now
|
||||
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(ChannelBinding, (user_id, _CLAWBOT))
|
||||
row = s.get(WeChatBotBinding, user_id)
|
||||
if row is not None:
|
||||
cfg = dict(row.config or {})
|
||||
cfg["chat_task_id"] = str(task_id)
|
||||
row.config = cfg
|
||||
row.chat_task_id = task_id
|
||||
row.updated_at = now
|
||||
|
||||
|
||||
def unbind(user_id: UUID) -> bool:
|
||||
"""解绑 ClawBot(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。"""
|
||||
"""解绑(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
with session_scope() as s:
|
||||
row = s.get(ChannelBinding, (user_id, _CLAWBOT))
|
||||
row = s.get(WeChatBotBinding, user_id)
|
||||
if row is None:
|
||||
return False
|
||||
row.status = "revoked"
|
||||
|
|
@ -197,27 +177,33 @@ 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(ChannelBinding, (user_id, _WECOM))
|
||||
row = s.get(WeComBinding, user_id)
|
||||
if row is None or row.status != "active":
|
||||
return None
|
||||
return (row.config or {}).get("wecom_userid")
|
||||
return row.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 = _get_or_new(s, user_id, _WECOM)
|
||||
row.config = {"wecom_userid": wecom_userid}
|
||||
row.status = "active"
|
||||
row.updated_at = now
|
||||
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
|
||||
|
||||
|
||||
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(ChannelBinding, (user_id, _WECOM))
|
||||
row = s.get(WeComBinding, user_id)
|
||||
if row is None:
|
||||
return False
|
||||
row.status = "revoked"
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -295,17 +295,8 @@
|
|||
/* 定时任务 modal(只读 + 停用/删除,DESIGN §8.5)— 复用 .sk-item/.sk-badge/.sk-empty */
|
||||
#wechat-modal { z-index: 112; }
|
||||
#wechat-modal .card { width: 440px; max-width: 94vw; }
|
||||
#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 { display: flex; align-items: center; gap: 8px; }
|
||||
#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 .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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue