From b0a8802d35b9abfad99cc40a4ac674ec6e8c35fd Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 7 Jul 2026 10:09:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(wecom):=20=E4=BC=81=E4=B8=9A=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=BA=94=E7=94=A8=E4=B8=BB=E9=A1=B5=E5=85=8D=E7=99=BB?= =?UTF-8?q?=E8=BF=9B=20embed=20=E6=8E=A7=E5=88=B6=E5=8F=B0(bump=200.44.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新端点 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 --- EMBED.md | 29 ++++++++++++++ PROGRESS.md | 1 + RUN.md | 3 +- core/__init__.py | 2 +- core/wechat/wecom.py | 20 ++++++++++ web/app.py | 87 ++++++++++++++++++++++++++++++++++-------- web/static/js/auth.js | 9 ++++- web/static/js/embed.js | 34 ++++++++++++++++- web/static/js/state.js | 3 ++ 9 files changed, 167 insertions(+), 21 deletions(-) diff --git a/EMBED.md b/EMBED.md index 9671db5..c7cd78f 100644 --- a/EMBED.md +++ b/EMBED.md @@ -25,6 +25,7 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 | `embed` | 是 | 固定 `1`,触发 embed 模式 | | `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** | | `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 是否变过 | | 嵌入页面 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 | + +--- + +## 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 两端可用。 diff --git a/PROGRESS.md b/PROGRESS.md index 6f856a4..081dd65 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 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.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)。 diff --git a/RUN.md b/RUN.md index 1fa7d08..f70ccaf 100644 --- a/RUN.md +++ b/RUN.md @@ -80,7 +80,7 @@ # WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息) # WECOM_AGENTID=1000002 # 自建应用 AgentId # 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 + 下面两项, # 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。 # 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`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 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)**:企微后台应用「应用主页」填 `<公网 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`。 - **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))"`。 diff --git a/core/__init__.py b/core/__init__.py index 33476b9..c4a1822 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.43.1" +__version__ = "0.44.0" diff --git a/core/wechat/wecom.py b/core/wechat/wecom.py index be07f46..a2f1837 100644 --- a/core/wechat/wecom.py +++ b/core/wechat/wecom.py @@ -32,6 +32,9 @@ QYAPI = "https://qyapi.weixin.qq.com/cgi-bin" # 不能用 open.weixin.qq.com/connect/oauth2/authorize —— 那条是「网页授权」,只能在 # 企业微信客户端内打开,桌面浏览器会报「请在企业微信客户端打开链接」。 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 # 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]: """OAuth 回调用 code 换企业成员 userid(非成员返回 None)。""" d = _api_get("auth/getuserinfo", {"code": code}) diff --git a/web/app.py b/web/app.py index 54ad542..1f450b2 100644 --- a/web/app.py +++ b/web/app.py @@ -1397,6 +1397,16 @@ def create_app() -> FastAPI: # ───────────── 企业微信接入(渠道 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"" + f"
" + f"{msg}
可关闭本页返回 zcbot
" + ) + @app.get("/v1/wecom/oauth/url", tags=["wecom"]) def wecom_oauth_url(request: Request, user_id: UUID = Depends(require_user)): """生成企业微信 OAuth 网页授权链接(前端打开 → 扫码授权)。回调用 state 带回身份。 @@ -1413,31 +1423,78 @@ def create_app() -> FastAPI: @app.get("/v1/wecom/oauth/callback", include_in_schema=False) async def wecom_oauth_callback(code: str = "", state: str = ""): """企业微信授权后浏览器回跳到这(无 JWT;身份从 state 验)。换 userid → 写绑定 → 回提示页。""" - from fastapi.responses import HTMLResponse from core.wechat import service as _wx from core.wechat import wecom - def _page(msg: str, ok: bool) -> HTMLResponse: - color = "#1a7f37" if ok else "#cf222e" - return HTMLResponse( - f"" - f"
" - f"{msg}
可关闭本页返回 zcbot
" - ) - uid = wecom.verify_state(state) - if not uid: - return _page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False) + if not uid or uid == _WECOM_LOGIN_STATE: + return _wecom_page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False) if not code: - return _page("绑定失败:未拿到授权码。", False) + return _wecom_page("绑定失败:未拿到授权码。", False) try: wecom_userid = await asyncio.to_thread(wecom.get_user_id, code) except Exception as e: - return _page(f"绑定失败:{type(e).__name__}", False) + return _wecom_page(f"绑定失败:{type(e).__name__}", False) if not wecom_userid: - return _page("绑定失败:你不是该企业成员(只支持企业内成员)。", False) + return _wecom_page("绑定失败:你不是该企业成员(只支持企业内成员)。", False) 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"]) def wecom_bind_get(user_id: UUID = Depends(require_user)): diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 091fbd0..5960db4 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -3,11 +3,11 @@ // (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。 // 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting // (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 { api } from "./api.js"; import { enterApp } from "./main.js"; -import { embedPostToParent, embedShowWaiting } from "./embed.js"; +import { embedPostToParent, embedShowWaiting, embedWecomRelogin } from "./embed.js"; // ───── login ───── let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个 @@ -100,6 +100,11 @@ export function logout() { // 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户 setIdentity({ user_id: "" }); if (state.evtSrc) state.evtSrc.close(); + if (EMBED_WECOM) { + // 企微免登:401 直接回 entry 静默重签,客户端内用户无感 + embedWecomRelogin(); + return; + } if (EMBED) { embedPostToParent({ type: "zcbot-401" }); embedShowWaiting("登录已失效,等待父页面重新签发…", false); diff --git a/web/static/js/embed.js b/web/static/js/embed.js index 68a69e3..5619ec3 100644 --- a/web/static/js/embed.js +++ b/web/static/js/embed.js @@ -1,7 +1,9 @@ // embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。 +// wecom 变体(?embed=1&wecom=1):token 来自 /v1/wecom/entry 回调的 URL fragment, +// 401 时 embedWecomRelogin 重定向回 entry 静默重签(企微客户端内无感)。 // 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+ -// embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。 -import { state, LS_TOKEN, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js"; +// embedPostToParent / embedShowWaiting / embedWecomRelogin(auth 的 logout 用)。 +import { state, LS_TOKEN, EMBED_WECOM, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js"; import { $ } from "./dom.js"; import { enterApp } from "./main.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() { + if (EMBED_WECOM) { embedWecomInit(); return; } if (!EMBED_PARENT_ORIGIN) { document.body.classList.add("embed-mode", "embed-waiting"); embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true); diff --git a/web/static/js/state.js b/web/static/js/state.js index 6bb766f..2536c27 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -13,9 +13,12 @@ export const LS_RIGHT_WIDTH = "zcbot.right-width"; export const LS_TASK_FILTERS_COLLAPSED = "zcbot.task-filters-collapsed"; // 左栏筛选区折叠偏好(默认折叠) // ?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=:首次签发 token 后自动定位到该 task 并加载消息 const _embedQS = new URLSearchParams(location.search); 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_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();