# Python 桌面扫码登录 Demo · 使用说明

> 本文件是 <https://login.fps5.cn/python-demo> 的 Markdown 版本，便于用户与 AI 直接读取和接入。
> 平台使用说明请阅读 <https://login.fps5.cn/docs.md>，API 细节请阅读 <https://login.fps5.cn/api-docs.md>。

- 平台地址：<https://login.fps5.cn>
- 网页版 Demo：<https://login.fps5.cn/python-demo>
- 源码下载：<https://login.fps5.cn/static/python/wx_login_demo.py>
- 依赖清单：<https://login.fps5.cn/static/python/requirements-demo.txt>
- API 文档（Markdown）：<https://login.fps5.cn/api-docs.md>

## 快速摘要

- **这是什么**：一个 PySide6 桌面程序，用 `requests` 直接跑完整条微信扫码登录链路，不内嵌 QtWebEngine，也不依赖系统浏览器。
- **适合谁**：桌面软件、内网工具，或需要独立登录窗口、不方便拉起浏览器的场景。
- **怎么用**：下载两个文件 → `pip install -r requirements-demo.txt` → 设置三个环境变量 → `python wx_login_demo.py`。
- **必须准备**：平台「我的项目」中的 `client_id`、`client_secret`，以及项目域名白名单内的回调地址。
- **安全提示**：`client_secret` 放在桌面客户端无法真正保密，正式产品建议改为「客户端 → 自己的后端 → 网关」，由后端持有 secret。

## 1. 下载与运行

运行环境：Python 3.10+，Windows / macOS / Linux 均可。

```bash
# 1. 安装依赖
pip install -r requirements-demo.txt

# 2. 填入「我的项目」里的凭证与白名单回调地址
# Windows PowerShell
$env:WX_CLIENT_ID     = "ak_你的client_id"
$env:WX_CLIENT_SECRET = "sk_你的client_secret"
$env:WX_REDIRECT_URL  = "https://你的站点/callback"

# macOS / Linux
export WX_CLIENT_ID=ak_你的client_id
export WX_CLIENT_SECRET=sk_你的client_secret
export WX_REDIRECT_URL=https://你的站点/callback

# 3. 启动
python wx_login_demo.py
```

依赖清单内容：

```text
PySide6>=6.6
requests>=2.28
```

## 2. 配置项

| 环境变量 | 必填 | 说明 |
| --- | --- | --- |
| `WX_CLIENT_ID` | 是 | 项目 client_id，在「我的项目」中查看。 |
| `WX_CLIENT_SECRET` | 是 | 项目 client_secret，仅用于最后一步换取用户资料。 |
| `WX_REDIRECT_URL` | 是 | 必须属于项目域名白名单。它只作为 `/oauth/qr` 的参数，Demo 不会真正打开它。 |
| `WX_GATEWAY` | 否 | 登录网关地址，默认 `https://login.fps5.cn`。 |

关于 `redirect_url`：网关会按项目的域名白名单校验这个地址。Demo 拿到微信 code 后直接调用网关的 `/oauth/callback`，并关闭自动跳转，只从 302 的 `Location` 里取一次性 ticket，因此不需要在 `redirect_url` 对应的站点上写任何 PHP / Python 代码。

## 3. 登录链路

桌面端没有网页上下文，每一步都由 Python 直接发起：

1. **申请二维码参数**：`GET {GW}/oauth/qr`，传 `client_id`、`redirect_url` 和本地生成的 `state`，拿到 `appid` / `scope` / 网关 state。
2. **解析微信 uuid**：请求 `https://open.weixin.qq.com/connect/qrconnect`，从返回 HTML 中用正则提取 uuid，不加载浏览器。
3. **显示二维码**：下载 `https://open.weixin.qq.com/connect/qrcode/{uuid}` 图片，用 `QPixmap` 渲染到窗口。
4. **长轮询扫码状态**：`https://lp.open.weixin.qq.com/connect/l/qrconnect`，408 等待、404 已扫码、405 确认并返回 `wx_code`。
5. **向网关换取 ticket**：把 `wx_code` 与网关 state 交给 `GET {GW}/oauth/callback`，不跟随 302，从 `Location` 中取 ticket。
6. **换取用户资料**：`POST {GW}/api/user/info`，用 `client_id` + `client_secret` + ticket 拿到 `openid`、昵称、头像并展示。

## 4. 扫码状态码

长轮询返回的 `wx_errcode` 是整条链路最容易踩坑的地方。

| 状态码 | 含义 | 正确处理 |
| --- | --- | --- |
| `408` | 等待扫码 | 继续轮询 |
| `404` | 已扫码，等待手机确认 | **继续轮询**，提示用户点「允许」 |
| `405` | 已确认授权 | 从响应中取 `wx_code`，进入换票 |
| `403` | 用户在手机取消 | 提示后继续轮询 |
| `402` | 二维码过期 | 停止轮询，提示刷新二维码 |
| `500` | 微信服务异常 | 停止轮询，提示稍后重试 |

**404 不是二维码过期。** 如果把 404 和 402 一起当作过期处理，用户刚扫完码轮询就会停止；等他在手机上点「允许」时，返回的 `wx_code` 已经没有接收方，界面会一直停在原地且没有任何提示。

## 5. 关键代码

完整实现见下载文件，核心逻辑只有三段。

### 5.1 申请参数并解析 uuid

```python
payload = requests.get(
    f"{GATEWAY}/oauth/qr",
    params={
        "client_id": CLIENT_ID,
        "redirect_url": REDIRECT_URL,
        "state": biz_state,          # 本地随机串，回跳时校验
    },
    timeout=15,
).json()["data"]

qr_url = "https://open.weixin.qq.com/connect/qrconnect?" + urlencode({
    "appid": payload["appid"],
    "redirect_uri": payload["redirect_uri"],
    "response_type": "code",
    "scope": payload.get("scope") or "snsapi_login",
    "state": payload["state"],       # 网关内部 state，原样回传
}) + "#wechat_redirect"

html = requests.get(qr_url, timeout=20).text
uuid = re.search(r"l/qrconnect\?uuid=([A-Za-z0-9_-]+)", html).group(1)
```

### 5.2 长轮询扫码状态

```python
last = ""
while True:
    params = {"uuid": uuid}
    if last:
        params["last"] = last

    body = requests.get(
        "https://lp.open.weixin.qq.com/connect/l/qrconnect",
        params=params,
        timeout=(10, 40),                # 微信会挂起约 15 秒
    ).text

    errcode = int(re.search(r"wx_errcode\s*=\s*(\d+)", body).group(1))
    code = re.search(r"wx_code\s*=\s*'([^']*)'", body).group(1)

    if code:                             # 405 确认，拿到授权码
        break
    if errcode == 404:                   # 已扫码，继续等手机确认
        show_tip("扫描成功，请在手机上点击「允许」")
    elif errcode == 402:                 # 真正过期
        raise RuntimeError("二维码已过期，请刷新")
    last = str(errcode)
```

### 5.3 换 ticket 并拉取用户资料

```python
# 关键：不跟随 302，只读 Location
resp = requests.get(
    f"{GATEWAY}/oauth/callback",
    params={"code": wx_code, "state": gateway_state},
    allow_redirects=False,
    timeout=20,
)

location = resp.headers["Location"]      # https://你的站点/callback?code=TICKET&state=...
query = parse_qs(urlsplit(location).query)
ticket = query["code"][0]
if query.get("state", [""])[0] != biz_state:
    raise RuntimeError("state 校验失败")

user = requests.post(
    f"{GATEWAY}/api/user/info",
    data={
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "code": ticket,
    },
    timeout=20,
).json()["data"]

print(user["openid"], user["nickname"], user["avatar"])
```

## 6. 常见问题

### 启动后提示「请先设置环境变量」

说明 `client_id`、`client_secret` 或 `redirect_url` 没读到。确认环境变量是在同一个终端里设置的，再执行 `python wx_login_demo.py`。

### 扫码后一直不跳转

先看窗口提示：显示「扫描成功，请在手机上点击「允许」」说明轮询正常，等手机确认即可。若手机已点允许仍无反应，查看日志 `%TEMP%\wx_login_demo.log`（macOS / Linux 为系统临时目录），确认轮询是否收到 405、网关回调是否返回 302。

### 提示 state 校验失败

通常是重复使用了旧二维码，或回调地址被其他页面接管。点击「刷新二维码」重新发起即可；Demo 每次刷新都会生成新的 state。

### 提示域名不在白名单

`WX_REDIRECT_URL` 的域名必须与项目里配置的域名一致。修改项目域名或回调地址后需要重新审核。

### 能打包成 exe 吗

可以，用 PyInstaller 打包即可。但 `client_secret` 会随程序分发，建议正式产品改为「客户端 → 自己的后端 → 网关」，由后端持有 secret 并完成换票。

## 7. 相关文档

- 平台使用说明（网页）：<https://login.fps5.cn/docs>
- 平台使用说明（Markdown）：<https://login.fps5.cn/docs.md>
- API 文档（网页）：<https://login.fps5.cn/api-docs>
- API 文档（Markdown）：<https://login.fps5.cn/api-docs.md>
- 服务端 Python（Flask）示例：<https://login.fps5.cn/api-docs#examples>
