diff --git a/docs/wecom-oauth-lessons.md b/docs/wecom-oauth-lessons.md new file mode 100644 index 0000000..0ebebd7 --- /dev/null +++ b/docs/wecom-oauth-lessons.md @@ -0,0 +1,153 @@ +# 企业微信 OAuth 静默登录 — 踩坑记录 + +## 目标 + +用户在企微内点击菜单 → 自动登录 → 直达应用首页,零手动操作。 + +## 架构 + +``` +企微菜单(view) → OAuth authorize URL(snsapi_base) + → 企微静默获取 code + → 302 到 callback URL + → 后端换 wecom_userid → 查 DB → 签 JWT + → 返回 HTML 页面(JS 写 localStorage + 跳转) + → 前端启动 → auth store 读到 localStorage → 已登录 +``` + +## 踩坑清单 + +### 坑 1:`redirect_uri` 不能夹带自定义 query 参数 + +**错误做法:** + +``` +redirect_uri = https://xxx.com/api/callback?redirect=/m +``` + +企微 OAuth 完成后会在 `redirect_uri` 后追加 `?code=xxx&state=xxx`,但可能覆盖或丢弃原有的 query 参数。 + +**正确做法:** 把自定义参数编码到 `state` 里: + +```python +state = f"r={urllib.parse.quote('/m')}" +redirect_uri = "https://xxx.com/api/callback" # 干净,无额外 query +``` + +回调时从 `state` 中解析: + +```python +redirect = "/m" +if state.startswith("r="): + redirect = urllib.parse.unquote(state[2:]) +``` + +### 坑 2:必须配置可信域名 + +企微后台 → 应用管理 → 网页授权及 JS-SDK → 设置可信域名。 + +不加的话 OAuth 直接报错:`redirect_uri 需使用应用可信域名`。 + +验证文件(`WW_verify_xxx.txt`)需要放在前端可访问的静态目录下。 + +### 坑 3:`agentid` 参数 + +内部应用使用 `snsapi_base` 静默授权时,OAuth URL 必须带 `agentid`: + +``` +?appid=CORPID&agentid=AGENTID&redirect_uri=... +``` + +不加的话企微可能静默授权失败(表现:跳到登录页而不是自动登录)。 + +### 坑 4:服务端 302/307 重定向在 webview 中不可靠 + +**这是最大的坑。** 后端回调返回 HTTP 重定向(307/302),企微内置浏览器可能不跟进,或跟进时丢掉 query 参数。 + +**解决方案:** 回调返回 HTML 页面,用 JS 做客户端跳转。 + +```python +html = """
+""" +return HTMLResponse(content=html, status_code=200) +``` + +**为什么直接写 localStorage 而不是 URL 传 token?** + +- URL `?token=xxx` 需要前端路由守卫解析 JWT(`atob` 解码) +- 企微 webview 中 `atob` 可能不可靠 +- `localStorage` 是浏览器原生 API,前端 auth store 启动时直接读取 + +### 坑 5:用户名字符串中的特殊字符 + +如果直接把用户名拼进 JS 字符串,包含 `'` `"` `\` 会破坏 JS 语法。 + +**安全做法:** 整个 auth data 用 `json.dumps` 序列化后直接作为 JS 对象字面量: + +```python +import json +auth_data = json.dumps({ + "token": token, + "userId": str(user.id), + "userName": user.name, # json.dumps 会自动转义特殊字符 + "userRole": user.role, + "theme": user.theme or "editorial", +}, ensure_ascii=False) +# 在 HTML 模板中: +# var d = {auth_data}; ← 直接是 JS 对象,不需要 JSON.parse +``` + +### 坑 6:`wecom_userid` 大小写 + +数据库中存的 `wecom_userid` 可能与 OAuth 返回的大小写不一致(如 `WeiJueSen` vs `weijuesen`)。 + +**修复:** 查询时做大小写不敏感匹配: + +```python +from sqlalchemy import func +result = await db.execute( + select(User).where(func.lower(User.wecom_userid) == wecom_userid.lower()) +) +``` + +### 坑 7:Login 页面死循环 + +Login.vue 检测到企微环境后自动跳 OAuth,但如果 OAuth 回调又回到 Login 页,就形成死循环。 + +**原因分析:** +- OAuth 成功 → 回调 → 写 localStorage → 跳到首页 +- 如果 localStorage 写入失败或 token 无效 → 路由守卫 → /login +- Login 检测 isWecom() → 又跳 OAuth → 死循环 + +**防护:** 在 Login 页跳 OAuth 前检查是否刚从 OAuth 回来(如 URL 带 `?from_wecom=1`),避免重复跳转。本项目通过 localStorage 写入 + 前端 auth store 从 localStorage 初始化避开了这个问题。 + +## 调试技巧 + +1. **后端日志看回调是否被触发:** `grep oauth-callback /var/log/app.log` +2. **HTTP 状态码:** 回调返回 307 说明走到了 RedirectResponse,返回 200 说明走了 HTML 方案 +3. **检查绑定状态:** 查数据库确认 `users.wecom_userid` 有值 +4. **OAuth URL 自测:** 浏览器直接访问 `/api/wecom/oauth-url`,看 302 跳转的目标 URL 是否正确 + +## 完整代码量 + +| 文件 | 新增行数 | 说明 | +|------|---------|------| +| 后端 OAuth 回调 | ~30 行 | `_build_oauth_url` + `oauth-callback` 端点 | +| 前端路由守卫 | ~15 行 | 处理 `?token=` 参数自动登录(本方案中作为 fallback) | +| 前端 Login 页 | ~8 行 | 企微环境检测 + 跳 OAuth | + +## 最终效果 + +用户视角:企微点击「开始填报」→ 白屏 0.5 秒 → 首页。 + +技术视角: +``` +点菜单 → OAuth(snsapi_base静默) → 回调HTML → JS写localStorage → 跳首页 → 已登录 +```