feat(wecom): 企业微信加「手填 userid」绑定(无 HTTPS 域名也能推)+ bump 0.26.3
企业微信推送是出站调用(gettoken/message_send 直连 qyapi),不需要域名;只有 OAuth 扫码拿 userid 那步要 HTTPS 可信域名。用户暂无域名 → 加第二条绑定路: 手填成员 userid(管理后台→通讯录→成员→「账号」)即可推送。 - web/app.py:`PUT /v1/wecom/bind/userid`(写绑定,wecom_configured 才允许) - 前端 rail「微信」modal 企业微信段加输入框 + 保存(与扫码并列,已绑回填); refreshWecom 提示两路并存 - service/推送/send_to_user 不动(userid 来源换了,绑定数据结构一样) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9381655210
commit
c79fc8ef0c
|
|
@ -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=<HMAC签+短TTL>` → 桌面出二维码扫 / 企业微信内静默 → 回调 `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=<HMAC签+短TTL>` → 扫码/静默 → 回调 `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 统一名)。
|
||||
|
||||
**取舍(不选)**:
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
5
RUN.md
5
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(见故障兜底)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.26.2"
|
||||
__version__ = "0.26.3"
|
||||
|
|
|
|||
14
web/app.py
14
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
|
||||
|
|
|
|||
|
|
@ -1344,10 +1344,16 @@
|
|||
<div class="muted" style="font-size:12px;margin-bottom:10px;">无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。</div>
|
||||
<div id="wc-state" class="wx-status wait">加载中…</div>
|
||||
<div class="wx-acts">
|
||||
<button id="wc-bind" class="small">绑定企业微信</button>
|
||||
<button id="wc-bind" class="small">扫码绑定</button>
|
||||
<button id="wc-test" class="small" disabled>发送测试</button>
|
||||
<button id="wc-unbind" class="small danger" disabled>解绑</button>
|
||||
</div>
|
||||
<div class="wx-acts" style="margin-top:8px;">
|
||||
<input id="wc-userid" class="small" placeholder="或手填成员 userid(无域名时)"
|
||||
style="flex:1;min-width:150px;padding:6px 8px;border:1px solid var(--line,#d0d7de);border-radius:6px;">
|
||||
<button id="wc-bind-id" class="small">保存</button>
|
||||
</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:6px;">userid 见管理后台→通讯录→点成员→「账号」。没有 HTTPS 域名用手填;有域名可用上方扫码。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue