# 微信扫码登录第三方平台 · API 文档

> 本文件是 <https://login.fps5.cn/api-docs> 的 Markdown 版本，便于用户与 AI 直接读取和接入。
> 接入步骤与常见问题请阅读 <https://login.fps5.cn/docs.md>。

- 平台名称：微信扫码登录第三方平台
- Base URL：`https://login.fps5.cn`
- 使用说明（网页）：<https://login.fps5.cn/docs>
- 使用说明（Markdown）：<https://login.fps5.cn/docs.md>
- PHP Demo：<https://login.fps5.cn/php-demo>
- Python 桌面 Demo：<https://login.fps5.cn/python-demo>

## 快速摘要

- **用途**：为第三方网站提供微信扫码登录 / 微信一键登录，并由业务后端换取微信用户资料。
- **凭证**：每个项目有独立的 `client_id`（`ak_...`）与 `client_secret`；`client_secret` 严禁出现在浏览器端。
- **典型流程**：`GET /oauth/qr` 获取二维码参数 → 用户在微信扫码授权 → 回调带回 `code`（ticket）→ 业务后端 `POST /api/user/info` 换取 `openid`、`unionid`、昵称、头像。
- **成功判断**：接口统一返回 HTTP 200，业务结果看 `code`，成功时 `code` 为 `200`。
- **一次性**：ticket 默认 300 秒内有效且只能使用一次；网关内部 state 有效期 1800 秒。

## 接口概览

| 方法 | 路径 | 调用方 | 说明 |
| --- | --- | --- | --- |
| GET | `/oauth/qr` | 业务后端 | 获取渲染微信二维码所需的参数（页面内嵌二维码）。 |
| GET | `/oauth/authorize` | 浏览器 | 业务站跳转发起微信授权。 |
| POST | `/api/user/info` | 业务后端 | 用一次性 ticket 换取用户资料。 |
| POST | `/api/v1/user/info` | 业务后端 | 与 `/api/user/info` 完全等价的别名。 |
| GET | `/api/ping` | 任意 | 健康检查，返回 pong 与运行状态。 |

响应结构：`code` 为业务状态码，`msg` 为说明，成功时附带 `data`。

`/oauth/qr`、`/api/user/info`、`/api/ping` 的 HTTP 状态码统一为 **200**，请以 `code` 字段判断结果；`/oauth/authorize` 不返回 JSON，而是直接跳转微信扫码页，失败时在浏览器展示错误页。

> 内部接口：`/oauth/callback` 由微信回调平台使用，业务站不需要调用，也无需在项目中配置它。

## 鉴权方式

换取用户资料的接口需要项目凭证。支持两种传递方式，任选其一：

- **请求体**：`client_id`、`client_secret`、`code` 作为表单字段。
- **请求头**：`X-Client-Id`、`X-Client-Secret`、`X-Ticket`（对应 code）。

`/oauth/qr` 与 `/oauth/authorize` 只需要 `client_id`，不需要 client_secret；其中 `/oauth/qr` 必须由业务后端发起请求（平台未开放 CORS）。

> **client_secret 严禁出现在浏览器端。** 换票请求必须由业务后端发起；前端只需要把回调带回的 ticket 交给后端。

## 获取二维码参数

### GET /oauth/qr

业务后端调用，返回可直接交给微信官方 `wxLogin.js` 的二维码参数。适用于「二维码渲染在页面登录卡片内、不跳转新页面」的接入方式。

请求参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `client_id` | string | 是 | 项目凭证 ID，格式 `ak_...`。 |
| `redirect_url` | string | 否 | 扫码完成后的回跳地址，需 URL 编码，域名必须在项目白名单内；不传则使用项目后台设置的默认回调地址。**项目也没设置默认地址时会直接返回失败**，建议始终显式传入。 |
| `state` | string | 建议 | 业务方自己生成的随机串（建议 32 位十六进制），回调时原样带回，用于防 CSRF。 |

成功响应（HTTP 200）：

```json
{
  "code": 200,
  "msg": "ok",
  "data": {
    "state": "09445a5564538f6c1daf48f9",
    "appid": "wx1234567890abcdef",
    "scope": "snsapi_login",
    "redirect_uri": "https://login.fps5.cn/oauth/callback",
    "mock": false
  }
}
```

字段说明：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `state` | string | **网关内部 state**（24 位十六进制），只用于传给 `wxLogin.js`，不要当作业务 state 保存或校验。 |
| `appid` | string | 微信网站应用 AppID，直接传给 `wxLogin.js`。 |
| `scope` | string | 固定为 `snsapi_login`。 |
| `redirect_uri` | string | 微信回调平台使用的固定地址，**不要改写成业务站地址**。 |
| `mock` | bool | 是否为模拟模式；为 `true` 时会额外返回 `mock_url`。 |

渲染二维码：

```html
<script src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>
<div id="qrMount"></div>

<script>
new WxLogin({
  self_redirect: true,    // 必须为 true：false 时确认授权后无法跳转
  id: 'qrMount',          // 容器必须已存在于 DOM 中
  appid: data.appid,
  scope: data.scope,
  redirect_uri: encodeURIComponent(data.redirect_uri),
  state: data.state,      // 网关内部 state
  style: 'black'
});
</script>
```

`wxLogin.js` 会先清空容器再插入约 300×400 的 iframe，所以容器需要先渲染到页面上，宽度建议不小于 300px。

> **两种 state 不要混用：** 请求时传给 `/oauth/qr` 的 `state` 是你的**业务 state**，回调时网关原样带回，应存入 Session 并在回调中校验；响应里的 `state` 是**网关内部 state**，只交给 `wxLogin.js`。如果把响应里的 state 存成业务校验值，回调时必然校验失败。

> **必须由业务后端调用。** 网关未开放跨域响应头，浏览器直接请求会被 CORS 拦截；由后端调用也便于统一保存业务 state。网关内部 state 有效期 **1800 秒**、仅可使用一次；微信二维码本身约 5 分钟过期，过期后重新申请即可。

> **`self_redirect` 必须为 `true`。** 设为 `false` 时，确认授权后由 `open.weixin.qq.com` 的 iframe 驱动顶层窗口跳转，跨域顶层导航会被浏览器拦截，表现为手机已确认但页面停在「扫描成功」。设为 `true` 后回调页会运行在二维码 iframe 内，拿不到业务站的 Session Cookie，因此不能在该页直接校验 state，需把 `code` / `state` 交回顶层处理，完整做法见接入说明的「第二步：回调处理」。

## 发起授权

### GET /oauth/authorize

由业务站点将用户浏览器重定向到该地址，平台校验通过后跳转微信扫码页。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `client_id` | string | 是 | 项目凭证 ID，格式 `ak_...`。 |
| `redirect_url` | string | 否 | 授权完成后的回跳地址，需 URL 编码，域名必须在项目的域名白名单内；不传则使用项目后台设置的默认回调地址。**项目也没设置默认地址时会直接返回失败**，建议始终显式传入。 |
| `state` | string | 建议 | 业务方随机串，平台原样带回，用于防 CSRF。 |

授权成功后，浏览器会回到 `redirect_url`，并附加参数：

```
https://你的站点/callback.php?code=32位一次性ticket&state=你的state
```

传了业务 `state` 才会追加 `state` 参数；未传则只返回 `code`。回调里的 `state` 是业务方原值，不是平台的内部 state。

> **登录上下文有效期 1800 秒。** 平台会为每次授权生成一个内部 state 并保存 1800 秒，超时或已被使用后再次回调会提示「state 校验失败或已过期」，此时应引导用户重新发起登录。
>
> **ticket 有效期默认 300 秒**，只能使用一次。业务后端应在回调中立即换票，不要把 ticket 存起来延迟处理。

## 换取用户资料

### POST /api/user/info

别名：`POST /api/v1/user/info`（完全等价）。

业务后端凭 client_secret 与 ticket 换取微信用户资料，ticket 消费后立即失效。仅支持 POST，不接受 URL query 传参。

请求参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `client_id` | string | 是 | 项目凭证 ID。 |
| `client_secret` | string | 是 | 项目密钥，仅服务端保存。 |
| `code` | string | 是 | 回调带回的一次性 ticket。 |

成功响应（HTTP 200）：

```json
{
  "code": 200,
  "msg": "ok",
  "data": {
    "openid": "oX7Kj5xxxxxxxxxxxxxxxxxxxx",
    "unionid": "uN9Yt2xxxxxxxxxxxxxxxxxxxx",
    "nickname": "微信用户",
    "avatar": "https://thirdwx.qlogo.cn/.../132",
    "sex": 1,
    "country": "中国",
    "province": "广东",
    "city": "深圳"
  }
}
```

字段说明：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `openid` | string | 用户在微信开放平台下的唯一标识，同一微信用户固定不变；本平台各项目共用同一微信应用，openid 跨项目一致。 |
| `unionid` | string | 同一开放平台账号下的统一用户标识，可能为空。 |
| `nickname` | string | 微信昵称，可能为空字符串。 |
| `avatar` | string | 头像地址，可能为空字符串。 |
| `sex` | int | 1 男、2 女、0 未知。 |
| `country` | string | 国家。 |
| `province` | string | 省份。 |
| `city` | string | 城市。 |

用哪个字段识别用户：同一微信用户在同一开放平台账号下 `openid` 固定不变，可直接作为用户主键；需要跨应用打通时优先使用 `unionid`，但它可能为空，应降级为 `openid`。

## 健康检查

### GET /api/ping

用于监控平台是否可用，无需鉴权。

```json
{
  "code": 200,
  "msg": "pong",
  "data": {
    "time": "2026-10-17 12:00:00",
    "version": "v1",
    "db": "ok",
    "mock": false
  }
}
```

`db` 为 `ok` 或 `error`，表示平台存储连接状态；`mock` 为布尔值，表示平台是否使用模拟数据（线上正常返回 `false`）。

## 错误码

| code | msg | 处理建议 |
| --- | --- | --- |
| `200` | ok | 成功。 |
| `40001` | 参数缺失：client_id、client_secret、code 均为必填 | 检查 client_id、client_secret、code 是否都已传递。 |
| `40002` | client_id 或 client_secret 校验失败 | 核对 client_id / client_secret，确认项目未被停用且审核已通过。 |
| `40003` | code 无效、已过期或已被使用 | ticket 过期、已使用或不属于该项目，引导用户重新登录。 |
| `50000` | 服务器内部错误 | 平台侧处理异常，可稍后重试；持续失败请联系管理员。 |

### `/oauth/qr` 的错误

| code | msg | 处理建议 |
| --- | --- | --- |
| `400` | 无效的 client_id，或该项目已停用 / 尚未通过管理员审核 | 核对 client_id，确认项目审核通过且未被停用。 |
| `400` | redirect_url 不合法：必须是 http(s) 绝对地址，且域名需在项目白名单内 | 检查回跳地址与项目域名白名单。 |
| `500` | 网关尚未配置微信 AppID / AppSecret，请先登录后台「系统设置」填写 | 平台侧配置问题，请联系管理员。 |

该接口的 `code` 沿用 HTTP 语义（`400` / `500`），但 HTTP 状态码仍为 200、响应体为 JSON，与换票接口的 `40001 / 40002 / 40003` 不同。

授权页与回调失败时不会返回 JSON，而是在浏览器展示失败原因，例如：

- 无效的 client_id，或该项目已停用 / 尚未通过管理员审核
- redirect_url 不合法：必须是 http(s) 绝对地址，且域名需在项目白名单内（未传且项目未设置默认回调地址时也会提示此项）
- 网关尚未配置微信 AppID / AppSecret，请先登录后台「系统设置」填写
- state 校验失败或已过期，请重新发起登录
- 未获取到 code，用户可能取消了授权
- 向微信换取 access_token 失败：…（微信返回的 errcode / errmsg）

## 调用示例

建议的调用顺序：

1. `GET /api/ping` 确认网关可达；
2. 后端 `GET /oauth/qr` 取二维码参数（或浏览器跳 `/oauth/authorize`）；
3. 用户扫码，网关回调后浏览器带回 `code`；
4. 后端 `POST /api/user/info` 换资料。

所有示例都遵循同一原则：`client_secret` 只出现在服务端代码中。

可直接跑通的参考实现：<https://login.fps5.cn/php-demo>（页面内嵌二维码 + 刷新），源码压缩包见 <https://login.fps5.cn/static/php/wx-login-php-demo.zip>。其中 `qr.php` 申请二维码参数，`callback.php` 把 code / state 交回顶层，`index.php` 在顶层校验 state 并换取用户资料，`lib.php` 为公共函数。

### cURL

```bash
# 1) 获取二维码参数（业务后端调用）
curl -s 'https://login.fps5.cn/oauth/qr?client_id=ak_你的client_id&redirect_url=https%3A%2F%2F你的站点%2Fcallback.php&state=0123456789abcdef0123456789abcdef'

# 2) 用回调带回的 ticket 换用户资料
curl -s -X POST 'https://login.fps5.cn/api/user/info' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'client_id=ak_你的client_id' \
  -d 'client_secret=sk_你的client_secret' \
  -d 'code=一次性的ticket'

# 3) 健康检查
curl -s 'https://login.fps5.cn/api/ping'

# 4) 换票也可以改用请求头传凭证（三个请求头分别对应三个参数）
curl -s -X POST 'https://login.fps5.cn/api/user/info' \
  -H 'X-Client-Id: ak_你的client_id' \
  -H 'X-Client-Secret: sk_你的client_secret' \
  -H 'X-Ticket: 一次性的ticket'
```

凭证只接受 POST body 或上述三个请求头，**不接受 URL query**，这样 client_secret 与 ticket 不会落进服务器访问日志。两种方式任选其一，混用会以 body 优先。

### PHP · 申请二维码参数（qr.php）

```php
<?php
declare(strict_types=1);
session_start();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

const GATEWAY      = 'https://login.fps5.cn';
const CLIENT_ID    = 'ak_你的client_id';
const REDIRECT_URL = 'https://你的站点/callback.php';   // 域名需在项目白名单内

$bizState = bin2hex(random_bytes(16));
$_SESSION['wx_states'][$bizState] = time();

if (count($_SESSION['wx_states']) > 10) {
    $_SESSION['wx_states'] = array_slice($_SESSION['wx_states'], -10, null, true);
}

$url = GATEWAY . '/oauth/qr?' . http_build_query([
    'client_id'    => CLIENT_ID,
    'redirect_url' => REDIRECT_URL,
    'state'        => $bizState,
]);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 8,
]);
$body = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$json = json_decode((string) $body, true);
if (!is_array($json) || (int) ($json['code'] ?? 0) !== 200 || empty($json['data']['state'])) {
    http_response_code(502);
    echo json_encode([
        'ok'  => false,
        'msg' => is_array($json) ? ($json['msg'] ?? '网关返回异常')
                                 : '网关返回非 JSON 内容（HTTP ' . $http . '）',
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

echo json_encode(['ok' => true, 'data' => $json['data']], JSON_UNESCAPED_UNICODE);
```

### PHP · 整页跳转（login.php）

```php
<?php
declare(strict_types=1);
session_start();

const GATEWAY      = 'https://login.fps5.cn';
const CLIENT_ID    = 'ak_你的client_id';
const REDIRECT_URL = 'https://你的站点/callback.php';

$state = bin2hex(random_bytes(16));
$_SESSION['wx_states'][$state] = time();   // 业务 state，回调时校验

$query = http_build_query([
    'client_id'    => CLIENT_ID,
    'redirect_url' => REDIRECT_URL,
    'state'        => $state,
]);

header('Location: ' . GATEWAY . '/oauth/authorize?' . $query, true, 302);
exit;
```

### PHP · 回调换票（callback.php）

```php
<?php
declare(strict_types=1);
session_start();

const GATEWAY       = 'https://login.fps5.cn';
const CLIENT_ID     = 'ak_你的client_id';
const CLIENT_SECRET = 'sk_你的client_secret';

$ticket = (string) ($_GET['code'] ?? '');
$state  = (string) ($_GET['state'] ?? '');

if ($state === '' || !isset($_SESSION['wx_states'][$state])) {
    exit('登录失败：state 校验失败或会话已过期，请返回重新扫码。');
}

$issuedAt = (int) $_SESSION['wx_states'][$state];
unset($_SESSION['wx_states'][$state]);   // 一次性消费

if (time() - $issuedAt > 1800) {
    exit('登录失败：登录会话已过期，请重新扫码。');
}
if ($ticket === '') {
    exit('登录失败：未获取到授权票据，用户可能取消了授权。');
}

$ch = curl_init(GATEWAY . '/api/user/info');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query([
        'client_id'     => CLIENT_ID,
        'client_secret' => CLIENT_SECRET,
        'code'          => $ticket,
    ]),
    CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
]);
$body = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err  = curl_error($ch);
curl_close($ch);

$json = $body === false ? null : json_decode((string) $body, true);

if (!is_array($json)) {
    exit('登录失败：无法连接登录服务（' . htmlspecialchars($err !== '' ? $err : 'HTTP ' . $http) . '）');
}
if ((int) ($json['code'] ?? 0) !== 200) {
    exit('登录失败：' . htmlspecialchars((string) ($json['msg'] ?? '未知错误')));
}

$user = $json['data'] ?? [];
if (empty($user['openid'])) {
    exit('登录失败：未获取到用户标识');
}

session_regenerate_id(true);
$_SESSION['wx_user'] = [
    'openid'   => (string) $user['openid'],
    'unionid'  => (string) ($user['unionid'] ?? ''),
    'nickname' => (string) ($user['nickname'] ?? ''),
    'avatar'   => (string) ($user['avatar'] ?? ''),
    'sex'      => (int) ($user['sex'] ?? 0),
];

header('Location: /', true, 302);
exit;
```

### Node.js（Express）

```javascript
// npm i express express-session
const express = require('express');
const session = require('express-session');
const crypto  = require('node:crypto');

const GATEWAY       = 'https://login.fps5.cn';
const CLIENT_ID     = 'ak_你的client_id';
const CLIENT_SECRET = 'sk_你的client_secret';
const REDIRECT_URL  = 'https://你的站点/callback';

const app = express();
app.use(session({
  secret: process.env.SESSION_SECRET || 'change-me',
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 }
}));

// 生成业务 state，只保留最近 10 个
function newState(req) {
  const state = crypto.randomBytes(16).toString('hex');
  req.session.wxStates = req.session.wxStates || {};
  req.session.wxStates[state] = Date.now();
  const keys = Object.keys(req.session.wxStates);
  if (keys.length > 10) delete req.session.wxStates[keys[0]];
  return state;
}

// 页面内嵌二维码：前端请求本路由拿到渲染参数
app.get('/api/qr', async (req, res) => {
  const query = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_url: REDIRECT_URL,
    state: newState(req)
  });

  try {
    const resp = await fetch(`${GATEWAY}/oauth/qr?${query}`);
    const json = await resp.json();
    if (json.code !== 200 || !json.data || !json.data.state) {
      return res.status(502).json({ ok: false, msg: json.msg || '获取二维码失败' });
    }
    res.json({ ok: true, data: json.data });
  } catch (err) {
    res.status(502).json({ ok: false, msg: '无法连接登录服务' });
  }
});

app.get('/login', (req, res) => {
  const query = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_url: REDIRECT_URL,
    state: newState(req)
  });
  res.redirect(`${GATEWAY}/oauth/authorize?${query}`);
});

app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  const issuedAt = req.session.wxStates && req.session.wxStates[state];

  if (!state || !issuedAt) {
    return res.status(400).send('state 校验失败或会话已过期，请重新登录');
  }
  delete req.session.wxStates[state];

  if (Date.now() - issuedAt > 30 * 60 * 1000) {
    return res.status(400).send('登录会话已过期，请重新扫码');
  }
  if (!code) {
    return res.status(400).send('未获取到授权票据，用户可能取消了授权');
  }

  try {
    const resp = await fetch(`${GATEWAY}/api/user/info`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        code
      })
    });
    const json = await resp.json();

    if (json.code !== 200 || !json.data || !json.data.openid) {
      return res.status(401).send(`登录失败：${json.msg || '未知错误'}`);
    }

    req.session.user = {
      openid:   json.data.openid,
      unionid:  json.data.unionid || '',
      nickname: json.data.nickname || '',
      avatar:   json.data.avatar || ''
    };
    res.redirect('/');
  } catch (err) {
    res.status(502).send('无法连接登录服务，请稍后重试');
  }
});

app.listen(3000);
```

### Python（Flask）

> 桌面客户端示例（PySide6，不内嵌浏览器内核，含完整源码下载）：<https://login.fps5.cn/python-demo>
> 该示例的 Markdown 版本：<https://login.fps5.cn/python-demo.md>

```python
# pip install flask requests
import secrets
import time
from urllib.parse import urlencode

import requests
from flask import Flask, redirect, request, session

GATEWAY       = 'https://login.fps5.cn'
CLIENT_ID     = 'ak_你的client_id'
CLIENT_SECRET = 'sk_你的client_secret'
REDIRECT_URL  = 'https://你的站点/callback'

app = Flask(__name__)
app.secret_key = 'change-me'

@app.get('/login')
def login():
    state = secrets.token_hex(16)
    states = session.get('wx_states', {})
    states[state] = int(time.time())
    session['wx_states'] = dict(list(states.items())[-10:])

    query = urlencode({
        'client_id': CLIENT_ID,
        'redirect_url': REDIRECT_URL,
        'state': state
    })
    return redirect(f'{GATEWAY}/oauth/authorize?{query}')

@app.get('/api/qr')
def qr():
    state = secrets.token_hex(16)
    states = session.get('wx_states', {})
    states[state] = int(time.time())
    session['wx_states'] = dict(list(states.items())[-10:])

    try:
        resp = requests.get(
            f'{GATEWAY}/oauth/qr',
            params={
                'client_id': CLIENT_ID,
                'redirect_url': REDIRECT_URL,
                'state': state
            },
            timeout=8
        )
        payload = resp.json()
    except (requests.RequestException, ValueError):
        return {'ok': False, 'msg': '无法连接登录服务'}, 502

    if payload.get('code') != 200 or not (payload.get('data') or {}).get('state'):
        return {'ok': False, 'msg': payload.get('msg', '获取二维码失败')}, 502

    return {'ok': True, 'data': payload['data']}

@app.get('/callback')
def callback():
    state = request.args.get('state', '')
    code  = request.args.get('code', '')

    states = session.get('wx_states', {})
    issued_at = states.pop(state, None) if state else None
    session['wx_states'] = states

    if issued_at is None:
        return 'state 校验失败或会话已过期，请重新登录', 400
    if time.time() - issued_at > 1800:
        return '登录会话已过期，请重新扫码', 400
    if not code:
        return '未获取到授权票据，用户可能取消了授权', 400

    try:
        resp = requests.post(
            f'{GATEWAY}/api/user/info',
            data={
                'client_id': CLIENT_ID,
                'client_secret': CLIENT_SECRET,
                'code': code
            },
            timeout=10
        )
        payload = resp.json()
    except (requests.RequestException, ValueError):
        return '无法连接登录服务，请稍后重试', 502

    if payload.get('code') != 200 or not (payload.get('data') or {}).get('openid'):
        return f"登录失败：{payload.get('msg', '未知错误')}", 401

    user = payload['data']
    session['user'] = {
        'openid':   user['openid'],
        'unionid':  user.get('unionid', ''),
        'nickname': user.get('nickname', ''),
        'avatar':   user.get('avatar', '')
    }
    return redirect('/')
```

### Java（HttpClient + Jackson）

```java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class GatewayClient {
    private static final String GATEWAY       = "https://login.fps5.cn";
    private static final String CLIENT_ID     = "ak_你的client_id";
    private static final String CLIENT_SECRET = "sk_你的client_secret";

    private final HttpClient   http = HttpClient.newHttpClient();
    private final ObjectMapper json = new ObjectMapper();

    /** 用回调带回的 ticket 换取用户资料 */
    public JsonNode fetchUser(String ticket) throws Exception {
        if (ticket == null || ticket.isBlank()) {
            throw new IllegalArgumentException("缺少 ticket");
        }

        String form = "client_id=" + enc(CLIENT_ID)
                + "&client_secret=" + enc(CLIENT_SECRET)
                + "&code=" + enc(ticket);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(GATEWAY + "/api/user/info"))
                .header("Content-Type", "application/x-www-form-urlencoded")
                .POST(HttpRequest.BodyPublishers.ofString(form))
                .build();

        HttpResponse<String> response =
                http.send(request, HttpResponse.BodyHandlers.ofString());
        JsonNode body = json.readTree(response.body());

        if (body.path("code").asInt() != 200) {
            throw new IllegalStateException("登录失败：" + body.path("msg").asText("未知错误"));
        }
        JsonNode user = body.path("data");
        if (user.path("openid").asText("").isEmpty()) {
            throw new IllegalStateException("登录失败：未获取到 openid");
        }
        return user;
    }

    private static String enc(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8);
    }
}
```

### 页面内嵌二维码的最小示例（JS 片段）

```html
<div id="qrMount" style="min-width:300px"></div>
<script src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>
<script>
// data 来自你的后端：GET /oauth/qr 的响应体中的 data 字段
new WxLogin({
  self_redirect: true,   // 必须为 true
  id: 'qrMount',
  appid: data.appid,
  scope: data.scope,
  redirect_uri: encodeURIComponent(data.redirect_uri),
  state: data.state,
  style: 'black'
});
</script>
```

`redirect_uri` 必须使用网关返回的值，不要替换成你自己的回调地址。`self_redirect` 必须为 `true`；此时回调页运行在微信 iframe 内，拿不到业务站 Cookie，需要把 `code` / `state` 交回顶层再校验 state 与换票。

## 安全建议

- client_secret 只保存在业务后端，不下发浏览器、不写入前端日志。
- 业务回调必须校验 state，失败时不要建立登录态。
- 使用 unionid 作为跨站用户主键时，处理为空的情况（降级为 openid）。
- 不要把 ticket 拼进日志或监控上报，避免被他人复用。
- 发现密钥泄露时，立即在「我的项目」中重置 client_secret。
- 定期在用户中心查看站内通知与项目状态，及时处理异常授权。

---

还没接入？请阅读 [使用说明 Markdown](https://login.fps5.cn/docs.md)，了解从扫码登录、申请项目到业务站接入的完整步骤。
