diff --git a/DESIGN.md b/DESIGN.md index fb8e3f2..c3cc615 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -729,10 +729,12 @@ create index on usage_events (model_profile, created_at); **渠道 B:企业微信自建应用(✅ 2026-06-24 实现,纯推送 / 不做对话,共用渠道抽象)** - **决策:只做推送、不做入站对话**(刻意简化,省回调 + AES `WXBizMsgCrypt` + 5s ACK + agent 回推那一整套;要对话用 ClawBot)。企业微信因此≈"和邮件一个量级"的纯出站通道,其**无条件主动推**正补 ClawBot 的 24h 窗口短板,定时简报必达首选。 - **应用凭据(全局 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。 +- **绑定两路(touser=wecom_userid)**: + - **手填 userid(无 HTTPS 域名时,默认)**:`PUT /v1/wecom/bind/userid` 直接写绑定;userid 见管理后台→通讯录→成员→「账号」。**推送是出站调用、不需域名**,故没域名也能用企业微信推送 —— 仅 OAuth 那路要域名。 + - **扫码绑定(OAuth,需 HTTPS 可信域名)**:rail modal「扫码绑定」→ `oauth2/authorize?...scope=snsapi_base&state=` → 扫码/静默 → 回调 `GET /v1/wecom/oauth/callback`(公开端点,身份从 state 验,非 JWT)→ `cgi-bin/auth/getuserinfo?code=` 拿 `wecom_userid`。**需管理员配「网页授权可信域名」** + `ZCBOT_PUBLIC_BASE_URL`。 - **推送**:`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)。 -- **接入**:`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/bind/userid` PUT(手填)、`/v1/wecom/test`;前端 rail modal 企业微信段(扫码 + 手填两路)。 - **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。 **取舍(不选)**: diff --git a/PROGRESS.md b/PROGRESS.md index c73efdf..f418515 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-25(监控页近 7 天用量按日期倒序,最新一天在最上 + bump 0.26.2) +最后更新:2026-06-25(企业微信加「手填 userid」绑定:无 HTTPS 域名也能用企业微信推送 + bump 0.26.3) --- @@ -21,6 +21,12 @@ ## 已完成关键能力 +### 2026-06-25 / 企业微信加「手填 userid」绑定(无域名也能推,bump 0.26.3) + +- 痛点:企业微信只有 OAuth 扫码绑定那一路,而 OAuth 回调要落在 HTTPS 可信域名;用户暂无域名 → 卡住。关键认知:**企业微信推送是出站调用(gettoken/message_send 直连 qyapi),根本不需要域名**——只有"扫码拿 userid"那步要域名。 +- 加第二条绑定路:`PUT /v1/wecom/bind/userid` 手填成员 userid(管理后台→通讯录→成员→「账号」)→ `upsert_wecom_binding`;前端 rail「微信」modal 企业微信段加输入框 + 保存(与「扫码绑定」并列,已绑回填 userid)。`service`/推送/`send_to_user` 全不动(userid 来源换了,绑定数据结构一样)。 +- 文件:`web/app.py`(+1 端点)、`web/static/dev.html`(输入框)、`web/static/js/wechat.js`(保存处理 + 回填)。py 编译 + node --check 过。 + ### 2026-06-25 / 监控页近 7 天用量按日期倒序(bump 0.26.2) - `admin.py` `_usage_section` 的 `by_day_7d` 排序由 `order_by(day)` 改 `order_by(day.desc())`,最新一天在最上(overview 趋势表 + PDF 报告共用此数据,两处都生效)。前端纯按行渲染、不依赖升序,无需改 JS。 diff --git a/RUN.md b/RUN.md index e4d043c..e196920 100644 --- a/RUN.md +++ b/RUN.md @@ -69,7 +69,10 @@ > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。 - **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env` 设 `ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。 -- **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET` + 在应用配「网页授权可信域名」指向 zcbot 域名;② `main.py db upgrade head` 带 migration `0014`;③ 用户在 rail「微信」modal 点「绑定企业微信」扫码授权(OAuth 拿 userid)。之后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达;不做对话(要对话用 ClawBot)。回调端点 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。 +- **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET`(+ 可见范围含目标用户);② `main.py db upgrade head`。**绑定两条路,任选**: + - **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。 + - **扫码授权(OAuth,要 HTTPS 域名)**:管理员另配「网页授权可信域名」指向 zcbot 域名 + `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。 + - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达;不做对话(要对话用 ClawBot)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 - **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 diff --git a/core/__init__.py b/core/__init__.py index 038d545..b197b56 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.26.2" +__version__ = "0.26.3" diff --git a/web/app.py b/web/app.py index 3bbb9db..a75d899 100644 --- a/web/app.py +++ b/web/app.py @@ -1245,6 +1245,20 @@ def create_app() -> FastAPI: wuid = _wx.get_wecom_userid(user_id) return {"configured": wecom.wecom_configured(), "bound": bool(wuid), "wecom_userid": wuid} + @app.put("/v1/wecom/bind/userid", tags=["wecom"]) + def wecom_bind_userid(payload: dict, user_id: UUID = Depends(require_user)): + """手填企业微信成员 userid 绑定(无 HTTPS 域名 / 不走 OAuth 时用)。 + userid 见管理后台 → 通讯录 → 点成员 → 「账号」。""" + from core.wechat import service as _wx + from core.wechat import wecom + if not wecom.wecom_configured(): + raise HTTPException(400, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)") + wuid = (payload.get("wecom_userid") or "").strip() + if not wuid: + raise HTTPException(400, "wecom_userid 不能为空") + _wx.upsert_wecom_binding(user_id, wuid) + return {"bound": True, "wecom_userid": wuid} + @app.delete("/v1/wecom/bind", status_code=204, tags=["wecom"]) def wecom_unbind(user_id: UUID = Depends(require_user)): from core.wechat import service as _wx diff --git a/web/static/dev.html b/web/static/dev.html index 1428bd6..cd3fd6d 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1344,10 +1344,16 @@
无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。
加载中…
- +
+
+ + +
+
userid 见管理后台→通讯录→点成员→「账号」。没有 HTTPS 域名用手填;有域名可用上方扫码。
diff --git a/web/static/js/wechat.js b/web/static/js/wechat.js index 2cc9a95..bedac30 100644 --- a/web/static/js/wechat.js +++ b/web/static/js/wechat.js @@ -54,14 +54,16 @@ async function refreshWecom() { if (!b.configured) { setWc("wait", "未配置:管理员需建自建应用 + 配 WECOM_CORPID/AGENTID/SECRET。"); $("wc-bind").disabled = true; $("wc-test").disabled = true; $("wc-unbind").disabled = true; + $("wc-bind-id").disabled = true; return; } - $("wc-bind").disabled = false; + $("wc-bind").disabled = false; $("wc-bind-id").disabled = false; if (b.bound) { setWc("ok", "已绑定 · 成员 " + (b.wecom_userid || "")); + $("wc-userid").value = b.wecom_userid || ""; $("wc-test").disabled = false; $("wc-unbind").disabled = false; } else { - setWc("wait", "未绑定。点「绑定企业微信」扫码授权。"); + setWc("wait", "未绑定。扫码授权(需 HTTPS 域名),或下方手填 userid 保存。"); $("wc-test").disabled = true; $("wc-unbind").disabled = true; } } catch (e) { @@ -159,6 +161,16 @@ $("wx-test").onclick = async () => { }; $("wc-bind").onclick = wecomBind; +$("wc-bind-id").onclick = async () => { + const uid = ($("wc-userid").value || "").trim(); + if (!uid) { setWc("err", "请先填入 userid。"); return; } + $("wc-bind-id").disabled = true; + try { + await api("PUT", "/v1/wecom/bind/userid", { wecom_userid: uid }); + await refreshWecom(); + } catch (e) { setWc("err", "保存失败: " + e.message); } + finally { $("wc-bind-id").disabled = false; } +}; $("wc-unbind").onclick = async () => { if (!confirm("确定解绑企业微信?")) return; try { await api("DELETE", "/v1/wecom/bind"); await refreshWecom(); }