feat(wecom): 企业微信应用主页免登进 embed 控制台(bump 0.44.0)

- 新端点 GET /v1/wecom/entry:企微客户端内走网页授权 snsapi_base 静默
  无感,外部浏览器退 wwlogin 扫码,同一 URL 两端可用;state 签 login
  哨兵防 CSRF(绑定回调显式拒绝该哨兵,两条流程不串)
- 新端点 GET /v1/wecom/entry/callback:code→userid→反查绑定→签 JWT→
  302 dev.html?embed=1&wecom=1,token 走 URL fragment 不进日志;
  未绑定成员提示先在控制台绑定,不自动建号(避免重复账号)
- 前端 wecom 变体:fragment 吃 token 即清 hash,不需要 parent_origin;
  401/logout 回 entry 静默重签;iframe embed 与 postMessage 协议零改动
- wecom.py 新增 oauth_inclient_url();提示页 helper 提出共用
- EMBED.md §9 + RUN.md 企微段(应用主页 + 网页授权可信域名两项设置)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-07 10:09:52 +08:00
parent 50e1d57ed5
commit b0a8802d35
9 changed files with 167 additions and 21 deletions

View File

@ -25,6 +25,7 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示
| `embed` | 是 | 固定 `1`,触发 embed 模式 | | `embed` | 是 | 固定 `1`,触发 embed 模式 |
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** | | `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task | | `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task |
| `wecom` | 否 | 固定 `1`,企业微信免登变体(见 §9):token 来自 `/v1/wecom/entry` 回调的 URL fragment,**不走 postMessage、不需要 `parent_origin`**。platform iframe 对接用不到这个参数 |
示例: 示例:
``` ```
@ -228,3 +229,31 @@ curl -X POST http://127.0.0.1:8765/v1/auth/login \
| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 | | 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 |
| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height``100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` | | 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height``100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` |
| 带 `task_id` 进来后没自动定位 / 报"加载失败" | task_id 不属于当前签发 token 的 user_id,或 UUID 拼写错 | 校验 platform 传的 task_id 归属对的 user;chat 区错误信息会显具体 message | | 带 `task_id` 进来后没自动定位 / 报"加载失败" | task_id 不属于当前签发 token 的 user_id,或 UUID 拼写错 | 校验 platform 传的 task_id 归属对的 user;chat 区错误信息会显具体 message |
---
## 9. 企业微信免登变体(`?embed=1&wecom=1`)
> 这一节是 embed 模式的**另一条 token 来路**,面向 zcbot 运维 / 企微管理员;platform iframe 对接(§1§4)不涉及。
用途:把 zcbot 配成**企业微信自建应用的「应用主页」**,成员在企微客户端里点开应用即自动登录,无需邮箱密码。
流程:
```
企微客户端点开应用
→ GET /v1/wecom/entry (302 到企微网页授权,snsapi_base 静默无感)
→ GET /v1/wecom/entry/callback (code→userid→反查绑定→签 JWT)
→ 302 /static/dev.html?embed=1&wecom=1#token=...&user_id=...
```
与 iframe embed 的差异:
| | iframe embed | wecom 免登 |
|---|---|---|
| token 来路 | 父页面 postMessage | URL fragment(前端读完立即 `history.replaceState` 清掉) |
| `parent_origin` | 必填 | 不需要 |
| 401 处理 | 发 `zcbot-401` 等父页面重签 | `location.replace("/v1/wecom/entry")` 静默重签 |
| 身份映射 | platform 维护 user_id | `channel_bindings` 里的企微绑定(**未绑定不自动建号**,提示先在控制台绑定) |
企微后台配置 + 可信域名要求见 `RUN.md` 企业微信段「应用主页免登」。外部浏览器(非企微客户端)打开 `/v1/wecom/entry` 会自动退到 wwlogin 扫码,同一 URL 两端可用。

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-07 / 0.44.0**:企业微信应用主页免登——企微后台「应用主页」填 `/v1/wecom/entry`,成员点开应用即静默授权(snsapi_base)登进 embed 控制台。后端两端点(entry 302 授权、callback code→userid→反查绑定→签 JWT→fragment 带 token 302 进 `dev.html?embed=1&wecom=1`),UA 分支让外部浏览器退 wwlogin 扫码;前端 wecom 变体(fragment 吃 token 即清 hash,401 回 entry 静默重签,不需要 parent_origin);未绑定成员提示先在控制台绑定(不自动建号,避免重复账号)。EMBED.md §9 + RUN.md 企微段同步。
- **07-07 / 0.43.1**:跳秒指示补两处空窗——① 刷新/重连接管后 phaseSince 随页面内存丢失,退回静态"思考中"不跳秒(恰是用户因疑似卡死而刷新的场景),重建直播卡时立即按"思考中"起跳,下一个阶段事件校正;② 发消息 POST 返回即起跳,覆盖 build_agent + 首轮 TTFT(原先要等 llm_start 才开始计时)。重连后秒数从重连时刻起算(非真实累计),可接受。 - **07-07 / 0.43.1**:跳秒指示补两处空窗——① 刷新/重连接管后 phaseSince 随页面内存丢失,退回静态"思考中"不跳秒(恰是用户因疑似卡死而刷新的场景),重建直播卡时立即按"思考中"起跳,下一个阶段事件校正;② 发消息 POST 返回即起跳,覆盖 build_agent + 首轮 TTFT(原先要等 llm_start 才开始计时)。重连后秒数从重连时刻起算(非真实累计),可接受。
- **07-07 / 0.43.0**:run 长耗时可视化——修"前端一直静止显示思考中被当成卡死":① 新增 SSE `reasoning{delta}` 事件(loop 提取 `delta.reasoning_content`,thinking 模型分钟级推理原先整段丢弃、前端零反馈),前端渲染灰色折叠"思考过程"卡,历史消息里持久化的 reasoning_content 同款渲染;② 前端活跃状态指示:TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"(CSS attr(data-status)),工具卡运行中加 spinner + 跳秒、结果到达定格为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗。 - **07-07 / 0.43.0**:run 长耗时可视化——修"前端一直静止显示思考中被当成卡死":① 新增 SSE `reasoning{delta}` 事件(loop 提取 `delta.reasoning_content`,thinking 模型分钟级推理原先整段丢弃、前端零反馈),前端渲染灰色折叠"思考过程"卡,历史消息里持久化的 reasoning_content 同款渲染;② 前端活跃状态指示:TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"(CSS attr(data-status)),工具卡运行中加 spinner + 跳秒、结果到达定格为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗。
- **07-06 / 0.42.5**:497 跳转示例 301→308——平台走 http 入口 POST login 被客户端按 301 语义降级成 GET 报 405;308 保留方法/body。治本仍是调用方直配 https(明文首跳暴露 platform_key)。 - **07-06 / 0.42.5**:497 跳转示例 301→308——平台走 http 入口 POST login 被客户端按 301 语义降级成 GET 报 405;308 保留方法/body。治本仍是调用方直配 https(明文首跳暴露 platform_key)。

3
RUN.md
View File

@ -80,7 +80,7 @@
# WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息) # WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息)
# WECOM_AGENTID=1000002 # 自建应用 AgentId # WECOM_AGENTID=1000002 # 自建应用 AgentId
# WECOM_SECRET=... # 自建应用 Secret # WECOM_SECRET=... # 自建应用 Secret
# ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(须在应用「企业微信授权登录」可信域名内;缺则取请求 base) # ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(扫码绑定→「企业微信授权登录」可信域名;应用主页免登→「网页授权及JS-SDK可信域名」;缺则取请求 base)
# 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项, # 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项,
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。 # 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成) # WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
@ -94,6 +94,7 @@
- **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 - **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。 - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。 - **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
- **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019` - **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **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))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。

View File

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

View File

@ -32,6 +32,9 @@ QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
# 不能用 open.weixin.qq.com/connect/oauth2/authorize —— 那条是「网页授权」,只能在 # 不能用 open.weixin.qq.com/connect/oauth2/authorize —— 那条是「网页授权」,只能在
# 企业微信客户端内打开,桌面浏览器会报「请在企业微信客户端打开链接」。 # 企业微信客户端内打开,桌面浏览器会报「请在企业微信客户端打开链接」。
WWLOGIN_SSO = "https://login.work.weixin.qq.com/wwlogin/sso/login" WWLOGIN_SSO = "https://login.work.weixin.qq.com/wwlogin/sso/login"
# 网页授权(snsapi_base 静默):只能在企业微信客户端内打开(应用主页 / 会话内链接),
# 用户无感直接回跳带 code。与上面 WWLOGIN_SSO 互补:客户端内用这条,外部浏览器用扫码。
OPEN_OAUTH = "https://open.weixin.qq.com/connect/oauth2/authorize"
MAX_FILE_BYTES = 20 * 1024 * 1024 MAX_FILE_BYTES = 20 * 1024 * 1024
# access_token 进程内缓存 # access_token 进程内缓存
@ -158,6 +161,23 @@ def oauth_authorize_url(redirect_uri: str, state: str) -> str:
) )
def oauth_inclient_url(redirect_uri: str, state: str) -> str:
"""造**客户端内网页授权**链接(snsapi_base 静默授权):企业微信客户端里打开(应用主页
入口)无感回跳带 code,后续 auth/getuserinfo userid 与扫码路径一致
注意:redirect_uri 域名须登记在应用网页授权及 JS-SDK 可信域名,与扫码登录的
企业微信授权登录可信域名是两项不同设置;`#wechat_redirect` 锚点是协议要求,必须带。"""
from urllib.parse import quote
return (
f"{OPEN_OAUTH}?appid={_corpid()}"
f"&redirect_uri={quote(redirect_uri, safe='')}"
f"&response_type=code&scope=snsapi_base"
f"&agentid={_agentid()}"
f"&state={quote(state, safe='')}"
f"#wechat_redirect"
)
def get_user_id(code: str) -> Optional[str]: def get_user_id(code: str) -> Optional[str]:
"""OAuth 回调用 code 换企业成员 userid(非成员返回 None)。""" """OAuth 回调用 code 换企业成员 userid(非成员返回 None)。"""
d = _api_get("auth/getuserinfo", {"code": code}) d = _api_get("auth/getuserinfo", {"code": code})

View File

@ -1397,6 +1397,16 @@ def create_app() -> FastAPI:
# ───────────── 企业微信接入(渠道 B,纯推送,§8.7)───────────── # ───────────── 企业微信接入(渠道 B,纯推送,§8.7)─────────────
def _wecom_page(msg: str, ok: bool):
"""OAuth 回跳落地提示页(无 JWT 场景:绑定 / 免登失败都用)。"""
from fastapi.responses import HTMLResponse
color = "#1a7f37" if ok else "#cf222e"
return HTMLResponse(
f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回 zcbot</div>"
)
@app.get("/v1/wecom/oauth/url", tags=["wecom"]) @app.get("/v1/wecom/oauth/url", tags=["wecom"])
def wecom_oauth_url(request: Request, user_id: UUID = Depends(require_user)): def wecom_oauth_url(request: Request, user_id: UUID = Depends(require_user)):
"""生成企业微信 OAuth 网页授权链接(前端打开 → 扫码授权)。回调用 state 带回身份。 """生成企业微信 OAuth 网页授权链接(前端打开 → 扫码授权)。回调用 state 带回身份。
@ -1413,31 +1423,78 @@ def create_app() -> FastAPI:
@app.get("/v1/wecom/oauth/callback", include_in_schema=False) @app.get("/v1/wecom/oauth/callback", include_in_schema=False)
async def wecom_oauth_callback(code: str = "", state: str = ""): async def wecom_oauth_callback(code: str = "", state: str = ""):
"""企业微信授权后浏览器回跳到这(无 JWT;身份从 state 验)。换 userid → 写绑定 → 回提示页。""" """企业微信授权后浏览器回跳到这(无 JWT;身份从 state 验)。换 userid → 写绑定 → 回提示页。"""
from fastapi.responses import HTMLResponse
from core.wechat import service as _wx from core.wechat import service as _wx
from core.wechat import wecom from core.wechat import wecom
def _page(msg: str, ok: bool) -> HTMLResponse:
color = "#1a7f37" if ok else "#cf222e"
return HTMLResponse(
f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回 zcbot</div>"
)
uid = wecom.verify_state(state) uid = wecom.verify_state(state)
if not uid: if not uid or uid == _WECOM_LOGIN_STATE:
return _page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False) return _wecom_page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False)
if not code: if not code:
return _page("绑定失败:未拿到授权码。", False) return _wecom_page("绑定失败:未拿到授权码。", False)
try: try:
wecom_userid = await asyncio.to_thread(wecom.get_user_id, code) wecom_userid = await asyncio.to_thread(wecom.get_user_id, code)
except Exception as e: except Exception as e:
return _page(f"绑定失败:{type(e).__name__}", False) return _wecom_page(f"绑定失败:{type(e).__name__}", False)
if not wecom_userid: if not wecom_userid:
return _page("绑定失败:你不是该企业成员(只支持企业内成员)。", False) return _wecom_page("绑定失败:你不是该企业成员(只支持企业内成员)。", False)
await asyncio.to_thread(_wx.upsert_wecom_binding, UUID(uid), wecom_userid) await asyncio.to_thread(_wx.upsert_wecom_binding, UUID(uid), wecom_userid)
return _page("✅ 企业微信绑定成功!以后简报 / 结果会推到你的企业微信。", True) return _wecom_page("✅ 企业微信绑定成功!以后简报 / 结果会推到你的企业微信。", True)
# 免登入口 state 里的哨兵值:登录流程没有 user_id,用它占位 + 防 CSRF。
# 真实 user_id 都是 UUID 串,不会与之撞;绑定回调那侧也显式拒绝这个值。
_WECOM_LOGIN_STATE = "login"
@app.get("/v1/wecom/entry", include_in_schema=False)
def wecom_entry(request: Request):
"""企业微信应用主页免登入口(无 JWT):302 到 OAuth 授权,回调换 JWT 直进 embed 控制台。
企微后台应用主页 `<公网 base>/v1/wecom/entry`企微客户端内(UA wxwork)
走网页授权 snsapi_base 静默无感;外部浏览器退到 wwlogin 扫码,同一 URL 两端可用
网页授权要求域名登记在应用网页授权及 JS-SDK 可信域名(与扫码的授权登录域名是两项)"""
from fastapi.responses import RedirectResponse
from core.wechat import wecom
if not wecom.wecom_configured():
raise HTTPException(404, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)")
base = (os.getenv("ZCBOT_PUBLIC_BASE_URL", "").strip()
or str(request.base_url).rstrip("/"))
redirect_uri = f"{base}/v1/wecom/entry/callback"
state = wecom.sign_state(_WECOM_LOGIN_STATE)
ua = (request.headers.get("user-agent") or "").lower()
url = (wecom.oauth_inclient_url(redirect_uri, state) if "wxwork" in ua
else wecom.oauth_authorize_url(redirect_uri, state))
return RedirectResponse(url, status_code=302)
@app.get("/v1/wecom/entry/callback", include_in_schema=False)
async def wecom_entry_callback(code: str = "", state: str = ""):
"""免登回跳:code → wecom_userid → 反查绑定 → 签 JWT → 302 进 embed 控制台。
token URL fragment(不进服务端日志不随后续请求发出,前端读完立即清 hash)
未绑定成员不自动建号 提示先在控制台完成绑定,避免同一人产生重复账号"""
from fastapi.responses import RedirectResponse
from core.wechat import service as _wx
from core.wechat import wecom
if wecom.verify_state(state) != _WECOM_LOGIN_STATE:
return _wecom_page("登录失败:授权已过期或无效,请重新打开应用。", False)
if not code:
return _wecom_page("登录失败:未拿到授权码。", False)
try:
wecom_userid = await asyncio.to_thread(wecom.get_user_id, code)
except Exception as e:
return _wecom_page(f"登录失败:{type(e).__name__}", False)
if not wecom_userid:
return _wecom_page("登录失败:你不是该企业成员(只支持企业内成员)。", False)
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid)
if uid is None:
return _wecom_page(
"该企业微信还未绑定 zcbot 账号:请先在电脑端 zcbot 控制台登录,"
"点左栏「企业微信」渠道卡片完成绑定,再回来打开本应用。", False)
token, _exp = mint_token(auth_cfg, uid)
# JWT 是 base64url + '.',UUID 是 hex + '-',都无需再转义,fragment 安全
return RedirectResponse(
f"/static/dev.html?embed=1&wecom=1#token={token}&user_id={uid}",
status_code=302,
)
@app.get("/v1/wecom/bind", tags=["wecom"]) @app.get("/v1/wecom/bind", tags=["wecom"])
def wecom_bind_get(user_id: UUID = Depends(require_user)): def wecom_bind_get(user_id: UUID = Depends(require_user)):

View File

@ -3,11 +3,11 @@
// (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。 // (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。
// 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting // 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting
// (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。 // (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。
import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, setIdentity } from "./state.js"; import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, EMBED_WECOM, setIdentity } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { api } from "./api.js"; import { api } from "./api.js";
import { enterApp } from "./main.js"; import { enterApp } from "./main.js";
import { embedPostToParent, embedShowWaiting } from "./embed.js"; import { embedPostToParent, embedShowWaiting, embedWecomRelogin } from "./embed.js";
// ───── login ───── // ───── login ─────
let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个 let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个
@ -100,6 +100,11 @@ export function logout() {
// 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户 // 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户
setIdentity({ user_id: "" }); setIdentity({ user_id: "" });
if (state.evtSrc) state.evtSrc.close(); if (state.evtSrc) state.evtSrc.close();
if (EMBED_WECOM) {
// 企微免登:401 直接回 entry 静默重签,客户端内用户无感
embedWecomRelogin();
return;
}
if (EMBED) { if (EMBED) {
embedPostToParent({ type: "zcbot-401" }); embedPostToParent({ type: "zcbot-401" });
embedShowWaiting("登录已失效,等待父页面重新签发…", false); embedShowWaiting("登录已失效,等待父页面重新签发…", false);

View File

@ -1,7 +1,9 @@
// embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。 // embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。
// wecom 变体(?embed=1&wecom=1):token 来自 /v1/wecom/entry 回调的 URL fragment,
// 401 时 embedWecomRelogin 重定向回 entry 静默重签(企微客户端内无感)。
// 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+ // 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+
// embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。 // embedPostToParent / embedShowWaiting / embedWecomRelogin(auth 的 logout 用)。
import { state, LS_TOKEN, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js"; import { state, LS_TOKEN, EMBED_WECOM, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { enterApp } from "./main.js"; import { enterApp } from "./main.js";
import { loadTaskList, selectTask } from "./chat.js"; import { loadTaskList, selectTask } from "./chat.js";
@ -49,7 +51,35 @@ function embedHandleMessage(e) {
} }
} }
} }
// ───── wecom 免登变体 ─────
export function embedWecomRelogin() {
// 回 entry 重走 OAuth(snsapi_base 静默,企微客户端内用户无感);replace 不留历史,
// 避免返回键回到已失效的页面
location.replace("/v1/wecom/entry");
}
function embedWecomInit() {
document.body.classList.add("embed-mode");
// fragment 里的 token 优先于 localStorage 缓存(每次从 entry 进来都是新签的)
const h = new URLSearchParams(location.hash.replace(/^#/, ""));
const tok = h.get("token"), uid = h.get("user_id");
if (tok && uid) {
state.token = tok;
localStorage.setItem(LS_TOKEN, tok);
// name/email 由 enterApp → loadRole 拉 /v1/me 补齐
setIdentity({ user_id: uid });
// 读完立即清 hash,token 不留在地址栏 / 浏览历史
history.replaceState(null, "", location.pathname + location.search);
}
if (!state.token) { embedWecomRelogin(); return; }
enterApp();
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
_embedInitialTaskHandled = true;
selectTask(EMBED_INITIAL_TASK_ID);
}
}
export function embedInit() { export function embedInit() {
if (EMBED_WECOM) { embedWecomInit(); return; }
if (!EMBED_PARENT_ORIGIN) { if (!EMBED_PARENT_ORIGIN) {
document.body.classList.add("embed-mode", "embed-waiting"); document.body.classList.add("embed-mode", "embed-waiting");
embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true); embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true);

View File

@ -13,9 +13,12 @@ export const LS_RIGHT_WIDTH = "zcbot.right-width";
export const LS_TASK_FILTERS_COLLAPSED = "zcbot.task-filters-collapsed"; // 左栏筛选区折叠偏好(默认折叠) export const LS_TASK_FILTERS_COLLAPSED = "zcbot.task-filters-collapsed"; // 左栏筛选区折叠偏好(默认折叠)
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token // ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
// ?embed=1&wecom=1 → 企业微信免登模式;token 由 /v1/wecom/entry 回调经 URL fragment 带入,
// 401 时重定向回 entry 静默重签(不走 postMessage,不需要 parent_origin)
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息 // 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
const _embedQS = new URLSearchParams(location.search); const _embedQS = new URLSearchParams(location.search);
export const EMBED = _embedQS.get("embed") === "1"; export const EMBED = _embedQS.get("embed") === "1";
export const EMBED_WECOM = EMBED && _embedQS.get("wecom") === "1";
export const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim(); export const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim(); export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();