# 微信扫码登录第三方平台 · 使用说明

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

- 平台名称：微信扫码登录第三方平台
- 平台地址：<https://login.fps5.cn>
- PHP Demo：<https://login.fps5.cn/php-demo>
- Python 桌面 Demo：<https://login.fps5.cn/python-demo>
- API 文档（网页）：<https://login.fps5.cn/api-docs>
- API 文档（Markdown）：<https://login.fps5.cn/api-docs.md>

## 快速摘要

- **这是什么**：一个第三方微信扫码登录平台。业务站无需自己申请微信开放平台网站应用，也无需企业认证。
- **适合谁**：没有企业主体、无法完成微信开放平台认证，或不想为多个站点分别维护微信网站应用的开发者。
- **怎么用**：微信扫码注册平台账号 → 创建项目 → 审核通过 → 用 `client_id` / `client_secret` 接入 → 后端用一次性 ticket 换取用户资料。
- **两种接入方式**：页面内嵌二维码（`GET /oauth/qr` + 微信官方 `wxLogin.js`）或整页跳转（`GET /oauth/authorize`）。
- **用户标识**：同一微信用户在所有项目中 `openid` / `unionid` 一致。
- **关键限制**：ticket 默认 300 秒且仅能使用一次；项目必须审核通过且未停用；回调域名必须在项目域名白名单内。

## 1. 平台能做什么

微信开放平台的「授权回调域」只能填写一个域名。当你有一个或多个网站都需要微信扫码登录时，自己申请网站应用、维护 OAuth 流程都比较麻烦。

**微信扫码登录第三方平台**已经把微信扫码登录做成一个现成的平台：

- 你在平台上申请项目，把项目凭证配置到自己的网站；
- 用户扫码后，你的后端就能拿到微信用户资料；
- 微信侧的应用配置由平台统一维护，你不需要接触 AppID / AppSecret。

核心能力：

| 能力 | 说明 |
| --- | --- |
| 不用申请微信应用 | 平台已完成微信开放平台接入，注册后直接申请项目即可。 |
| 一个账号管理多个站点 | 每个站点一个项目，最多可创建 10 个项目。 |
| 项目级独立凭证 | 每个项目有自己的 client_id / client_secret，密钥可随时重置。 |
| 一次性 ticket | 浏览器只带回短效票据，用户资料由业务后端凭密钥换取。 |

### 你的使用流程

1. **微信扫码登录**：打开本平台，用微信扫码，首次登录自动完成注册。
2. **申请项目**：填写项目名称、域名白名单和默认回调地址，提交审核。
3. **等待审核**：审核通过后项目才能发起授权；修改域名或回调会重新审核。
4. **获取凭证并接入**：复制 client_id / client_secret，按第 5 节配置你的业务站。

## 2. 快速开始

1. 点击页面右上角的「微信扫码登录」，在弹窗中用微信扫码；也可以打开 <https://login.fps5.cn/user/login> 整页登录。首次扫码会自动创建账号并进入用户中心，之后扫码即为登录。
2. 在用户中心进入「我的项目」，点击「申请项目」，填写项目名称、**域名**和**默认回调地址**，提交申请。
3. 项目初始状态为「待审核」。审核通过后状态变为「已通过」，项目才可以发起授权。
4. 在「我的项目」中打开该项目，复制 `client_id` 与 `client_secret`，按第 5 节完成业务站接入。

> 建议先准备好信息再申请：项目名称、业务站域名、登录完成后的回调地址。信息填写准确可以减少审核往返。

## 3. 登录与用户中心

平台账号就是你扫码使用的微信身份，不需要单独设置用户名和密码。同一个微信重复扫码只会更新资料，不会重复注册。

| 地址 | 作用 |
| --- | --- |
| `/user/login` | 微信扫码登录；首次扫码自动注册。 |
| `/user` | 用户中心：账号资料、openid / unionid、登录统计与站内通知。 |
| `/user/projects` | 我的项目：申请、编辑、删除项目，查看与重置项目密钥。 |
| `/user/logout` | 退出当前登录。 |

- **站内通知**：项目审核结果、项目被停用等消息会出现在用户中心，查看后自动标记为已读。
- **账号资料**：包括昵称、头像、openid、unionid、注册时间、最近登录时间与登录次数。
- **登录态**：仅用于管理本平台的项目，与你的业务站登录态相互独立。

## 4. 创建与管理项目

一个业务站点对应一个项目。在「我的项目 → 申请项目」中填写以下信息：

| 字段 | 说明 |
| --- | --- |
| 项目名称 | 便于识别的名称，会显示在授权记录中。 |
| 域名（白名单） | **必填**，授权回跳地址的域名白名单，多个用英文逗号分隔，支持 `*.example.com` 通配。 |
| 默认回调地址 | 发起授权时未传 `redirect_url` 时使用，必须是 http(s) 绝对地址，且域名在域名白名单内。 |
| 审核状态 | 待审核 / 已通过 / 已拒绝，仅「已通过」的项目可以发起授权。 |
| 使用状态 | 被停用后该项目无法再发起授权。 |

提交申请后，系统会立即生成一对凭证；项目审核通过后即可用于授权：

| 凭证 | 说明 |
| --- | --- |
| `client_id` | 可公开，用于标识项目，会出现在跳转地址中，格式 `ak_...`。 |
| `client_secret` | 必须保密，只能保存在业务后端，用于换取用户资料，格式 `sk_...`。 |

> **域名不能留空。** 空白名单的项目会拒绝所有授权回跳。请填写明确域名或通配域名，并确保回调地址的域名在白名单内，避免开放重定向。

常用管理操作：

- **编辑项目**：修改项目名称、域名或回调地址。修改域名或回调地址后，项目会重新进入「待审核」。
- **重置密钥**：生成新的 client_secret，旧密钥**立即失效**，请同步更新业务站配置。
- **删除项目**：项目及其授权记录会被一并删除，操作不可恢复。
- **数量限制**：每个账号最多创建 10 个项目。

## 5. 业务站接入

业务站只需要两个动作：让用户完成微信授权，以及在回调里用 ticket 换取用户资料。授权入口有两种形态，可按页面设计选择：

| 方式 | 用户体验 | 用到的接口 | 适用场景 |
| --- | --- | --- | --- |
| **页面内嵌二维码**（推荐） | 二维码直接出现在你的登录框里，不弹窗、不跳转新页面；过期可在卡片内刷新。 | `/oauth/qr` + 微信 `wxLogin.js` | 官网首页、登录弹层、单页应用。 |
| **整页跳转** | 用户离开本站进入微信扫码页，扫码确认后回跳你的回调地址。 | `/oauth/authorize` | 已有独立登录页，接入成本最低。 |

两种方式的**回调处理完全相同**；同一个项目也可以同时使用。如果不确定选哪个，优先用页面内嵌二维码。

### 5.1 方式一：页面内嵌二维码（推荐）

**第一步：`qr.php`，由业务后端向平台申请二维码参数。**

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

$clientId    = 'ak_你的client_id';
$redirectUrl = 'https://你的站点/callback.php';   // 建议显式传入；域名需在项目白名单内

// 1. 生成业务 state 并保存：回调里带回来的就是它
$bizState = bin2hex(random_bytes(16));
$_SESSION['wx_states'][$bizState] = time();

// 只保留最近 10 个，兼容多标签页和刷新后旧二维码
if (count($_SESSION['wx_states']) > 10) {
    $_SESSION['wx_states'] = array_slice($_SESSION['wx_states'], -10, null, true);
}

// 2. 必须由服务端请求：浏览器直接请求会被 CORS 拦截
$url = 'https://login.fps5.cn/oauth/qr?' . http_build_query([
    'client_id'    => $clientId,
    'redirect_url' => $redirectUrl,
    '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;
}

// 3. 只把渲染需要的字段返回给前端
echo json_encode(['ok' => true, 'data' => $json['data']], JSON_UNESCAPED_UNICODE);
```

**第二步：页面里的登录卡片与渲染脚本。**

```html
<div class="login-card">
    <div id="qrMount"></div>
    <p id="qrTip">二维码加载中…</p>
    <button type="button" id="qrRefresh">刷新二维码</button>
</div>

<script src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>
<script>
(function () {
    var mount = document.getElementById('qrMount');
    var tip   = document.getElementById('qrTip');

    function render(data) {
        mount.innerHTML = '';                  // 刷新时重建容器
        new WxLogin({
            self_redirect: true,               // 必须为 true，false 会导致确认后无法跳转
            id: 'qrMount',                     // 容器必须已存在于 DOM 中
            appid: data.appid,
            scope: data.scope || 'snsapi_login',
            redirect_uri: encodeURIComponent(data.redirect_uri),   // 用平台返回值并编码，勿改写
            state: data.state,                 // 平台内部 state，原样传入
            style: 'black'
        });
        tip.textContent = '二维码 5 分钟内有效，过期可点击刷新';
    }

    function load() {
        tip.textContent = '正在获取二维码…';
        fetch('qr.php', { credentials: 'same-origin', cache: 'no-store' })
            .then(function (r) { return r.json(); })
            .then(function (res) {
                if (!res || !res.ok) throw new Error((res && res.msg) || '获取二维码失败');
                render(res.data);
            })
            .catch(function (e) { tip.textContent = e.message; });
    }

    document.getElementById('qrRefresh').addEventListener('click', load);
    load();                                    // 打开页面即出码
})();
</script>
```

> **`self_redirect` 必须为 `true`。** 设为 `false` 时，用户确认授权后由 `open.weixin.qq.com` 的 iframe 驱动顶层窗口跳转，这属于跨域顶层导航，会被浏览器拦截，表现为手机已点「允许」但页面一直停在「扫描成功」。设为 `true` 后由 iframe 自身导航到回调地址，页面会进入二维码 iframe 内部，回调处理方式见 5.3。

> **两种 state 不要混用。** 你在请求里传给平台的 `state` 是**业务 state**，回调时原样带回，需要保存在自己的 Session 里校验；`/oauth/qr` 响应里的 `state` 是**平台内部 state**，只交给 `wxLogin.js`。把响应里的 state 存进 Session，回调时会一直提示「state 校验失败」。

### 5.2 方式二：整页跳转

生成随机 state 并保存到 Session，然后 302 到平台授权地址：

```php
<?php
session_start();

$state = bin2hex(random_bytes(16));
$_SESSION['wx_states'][$state] = time();   // 保存业务 state

// http_build_query 会自动完成 URL 编码
$query = http_build_query([
    'client_id'    => 'ak_你的client_id',
    'redirect_url' => 'https://你的站点/callback.php',
    'state'        => $state,
]);

header('Location: https://login.fps5.cn/oauth/authorize?' . $query, true, 302);
exit;
```

| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `client_id` | 是 | 项目凭证中的 client_id。 |
| `redirect_url` | 否 | 授权完成后的回跳地址，**需要 URL 编码**，域名必须在项目域名白名单内；不传则使用项目后台设置的默认回调地址，**项目也没有默认地址时会直接失败**，建议始终显式传入。 |
| `state` | 建议 | 业务方自定义随机串（建议 32 位十六进制），平台会原样带回，用于校验请求来源。 |

### 5.3 第二步：回调处理（两种方式共用）

微信授权完成后，浏览器会回到你的 `redirect_url`，并携带 `code`（一次性 ticket）和 `state`。

> **回调页可能在微信 iframe 内运行。** 页面内嵌方式（5.1）必须设置 `self_redirect: true`，此时回调页运行在 `open.weixin.qq.com` 的 iframe 中，属于第三方上下文，浏览器**不会带上你站点的 Session Cookie**（`SameSite=Lax` 的会话 Cookie 在跨站嵌套导航中不发送）。在回调页里直接 `session_start()` 会得到一个全新的空会话，业务 state 必然找不到，表现为「state 校验失败」。

推荐把回调页做成**桥接页**：只把 `code` / `state` 交回顶层窗口（顶层与你的站点同源，请求会带上 Cookie），由顶层页面统一校验 state、换票并写入 Session。

第一步，`callback.php` 只做转发，不读写 Session：

```php
<?php
$code  = trim((string) ($_GET['code'] ?? ''));
$state = (string) ($_GET['state'] ?? '');
$error = trim((string) ($_GET['error_description'] ?? $_GET['error'] ?? ''));

$query = [];
if ($error !== '') {
    $query['wx_error'] = $error;
} elseif ($code !== '') {
    $query['wx_code']  = $code;
    $query['wx_state'] = $state;
}

$target = 'index.php' . ($query ? '?' . http_build_query($query) : '');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>正在返回…</title>
<script>
(function () {
    var target = <?= json_encode($target, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    try {
        if (window.top && window.top !== window) {
            window.top.location.replace(target);   // 顶层与本站同源，可正常导航
            return;
        }
    } catch (e) {}
    window.location.replace(target);
})();
</script>
</head>
<body>
<p>正在返回… 若未自动跳转，<a href="<?= htmlspecialchars($target, ENT_QUOTES) ?>" target="_top">点此继续</a>。</p>
</body>
</html>
```

第二步，在顶层页面（如 `index.php`）里完成校验与换票：

```php
<?php
session_start();
require __DIR__ . '/lib.php';

$ticket = trim((string) ($_GET['wx_code'] ?? ''));
$state  = (string) ($_GET['wx_state'] ?? '');
$error  = trim((string) ($_GET['wx_error'] ?? ''));

if ($error !== '') {
    $loginError = $error;
} elseif ($ticket !== '') {
    // 1. 校验业务 state：存在、未用过、未超过 1800 秒
    if (!demo_consume_state($state)) {
        $loginError = '登录失败：state 校验失败或会话已过期，请返回重新扫码。';
    } else {
        // 2. 用 ticket 换用户资料
        [$http, $body] = demo_http_post('https://login.fps5.cn/api/user/info', [
            'client_id'     => 'ak_你的client_id',
            'client_secret' => 'sk_你的client_secret',
            'code'          => $ticket,
        ]);

        $json = json_decode($body, true);
        if ($http !== 200 || !is_array($json) || (int) ($json['code'] ?? 0) !== 200) {
            $loginError = '登录失败：' . (is_array($json) ? (string) ($json['msg'] ?? '未知错误') : '接口返回异常');
        } elseif (empty($json['data']['openid'])) {
            $loginError = '登录失败：未获取到用户标识';
        } else {
            // 3. 防会话固定，建立自己的登录态
            session_regenerate_id(true);
            $_SESSION['wx_user'] = $json['data'];
            header('Location: index.php');   // 地址栏去掉 code/state，避免刷新重复消费
            exit;
        }
    }
}
```

`demo_consume_state()` 是 Demo 里的辅助函数：从 `$_SESSION['wx_states']` 取出并删除业务 state，同时判断是否超过 1800 秒，完整实现见 `lib.php`。

> **整页跳转方式（5.2）不走 iframe。** 它的回调本身就是顶层导航，Cookie 正常，可以直接在 `callback.php` 里校验 state 并换票；为了两种方式共用一套代码，统一使用桥接页 + 顶层处理即可。

> **ticket 只能使用一次。** ticket 默认 300 秒有效，重复调用会返回 `40003`；如果用户刷新回调页导致失败，请引导用户重新扫码。拿到用户资料后请立即建立自己的会话。
>
> **登录上下文有效期 1800 秒。** 平台为每次授权生成的内部 state 保存 1800 秒，超时或已被使用后再次回调会提示「state 校验失败或已过期」，此时应引导用户重新发起登录。

### 5.4 用哪个字段识别用户

本平台的所有项目共用同一个微信网站应用，因此同一个微信用户在不同项目里拿到的 `openid` 与 `unionid` 是一致的。单站使用直接以 `openid` 作为用户唯一键即可；如果你的多个站点共享一套账号体系，也可以统一使用 `unionid`（为空时回退到 `openid`）。

### 5.5 PHP Demo

想直接对照可运行的代码，可以下载 <https://login.fps5.cn/static/php/wx-login-php-demo.zip>，或先看 <https://login.fps5.cn/php-demo> 的使用示例。压缩包内含服务端申请二维码参数、页面内嵌二维码、回调桥接、业务 state 校验与 ticket 换票的完整实现，不含任何真实密钥，修改 `config.php` 即可运行。

建议按下面的顺序验证一遍，正好覆盖接入时最容易出错的几个点：

- [ ] 解压到站点目录，编辑 `config.php` 填入 client_id / client_secret / redirect_url。
- [ ] 确认 `redirect_url` 指向 `callback.php`，且域名在项目白名单内。
- [ ] 访问 `index.php`，确认二维码直接出现在登录卡片内，没有弹出新窗口。
- [ ] 扫码确认后页面自动切换为已登录状态，地址栏不残留 code。
- [ ] 对比 `login.php` 的整页跳转方式，选择适合自己站点的接入形态。

文件说明：`index.php`（页面与二维码渲染、顶层完成 state 校验与换票）、`qr.php`（服务端申请二维码参数）、`callback.php`（桥接页，把 code / state 交回顶层）、`lib.php`（公共函数）、`login.php`（整页跳转方式）、`config.php`（网关地址与项目凭证）。该页面的 Markdown 版本见 <https://login.fps5.cn/php-demo.md>。

### 5.6 Python 桌面 Demo

桌面软件、内网工具或希望登录窗口独立于浏览器时，可以下载 <https://login.fps5.cn/python-demo> 提供的 PySide6 Demo。它不内嵌 QtWebEngine，用 requests 直接完成取 uuid、长轮询扫码状态、网关换票与拉取用户资料，填入项目凭证即可运行。该页面的 Markdown 版本见 <https://login.fps5.cn/python-demo.md>。

## 6. 接入自检

业务站上线前，逐项确认：

- [ ] 已用微信扫码登录本平台，并成功创建项目。
- [ ] 项目审核状态为「已通过」，且项目未被停用。
- [ ] 「域名」已填写，并覆盖回调地址使用的域名。
- [ ] 回调地址是 http(s) 绝对地址，且可正常访问。
- [ ] 业务服务器可以出站访问 <https://login.fps5.cn>（HTTPS）。
- [ ] client_secret 只存在于业务后端，未出现在前端代码或公开仓库中。
- [ ] 业务回调已校验 state，且校验的必须是**自己生成的业务 state**。
- [ ] 页面内嵌模式：二维码容器已渲染到 DOM，刷新时会重建容器。
- [ ] 页面内嵌模式：`self_redirect` 已设为 `true`，回调通过桥接页交给顶层完成 state 校验与换票。
- [ ] 换票失败（如 40003）时不会误判为登录成功。
- [ ] 已用真实微信完整走通一次：扫码 → 回调 → 换取用户 → 建立会话。
- [ ] 已验证二维码过期后点「刷新」能重新出码并完成登录。

## 7. 常见问题

### 申请后一直显示「待审核」？

项目需要管理员审核通过后才能使用。提交后请耐心等待，审核结果会以站内通知的形式出现在用户中心。修改域名或回调地址后，项目会重新进入待审核状态。

### 跳转平台后提示 redirect_url 不合法？

回调地址必须是 http(s) 绝对地址，且主机名在项目的「域名」白名单内。支持 `*.example.com` 通配。**域名不能留空**，空白名单会拒绝所有授权回跳。

### 回调提示「state 校验失败或已过期」？

回调地址里的 `state` 是**你在业务站自己生成的业务 state**，平台只负责原样带回。请确认：

1. 发起登录前已把业务 state 存入 Session；
2. 回调时用收到的 `state` 去 Session 里比对，而不是拿 `/oauth/qr` 响应里的 `state` 存 Session（那是平台内部的 24 位 state，两边混用必然失败）；
3. 登录链路在 1800 秒内完成，且同一个 state 只能消费一次；
4. 页面内嵌方式下，回调页是否运行在微信 iframe 中而拿不到本站 Cookie。回调页应做成桥接页，把 `code` / `state` 交回顶层再校验，见 5.3。

### 扫码后手机已确认，页面一直停在「扫描成功」？

这是 `self_redirect: false` 造成的：微信确认页会驱动**顶层窗口**跳转，而该跳转由 `open.weixin.qq.com` 的 iframe 发起，属于跨域顶层导航，会被浏览器拦截。请把 `wxLogin.js` 的 `self_redirect` 改为 `true`，由 iframe 自身导航到回调地址。

### 页面内嵌二维码显示空白怎么办？

依次检查：

1. 二维码容器（如 `<div id="qrMount">`）是否已渲染到 DOM，且在调用 `new WxLogin()` 之前存在；
2. 浏览器能否加载 `https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js`，是否有网络或内容安全策略拦截；
3. 传入的 `appid` / `redirect_uri` 是否来自 `/oauth/qr` 的响应的 `data`，而不是从别处硬编码。

### 二维码过期了怎么刷新？

微信二维码约 5 分钟过期。重新请求一次 `/oauth/qr` 拿到新的 `state`，清空并重建二维码容器后再调用 `new WxLogin()` 即可。刷新会产生新的业务 state，请同样写入 Session。

### `/oauth/qr` 返回 400 或 500？

- `400`：`client_id` 无效、项目未审核通过 / 已停用，或 `redirect_url` 不在域名白名单内。
- `500`：平台尚未配置微信 AppID / AppSecret，需要管理员在后台「系统设置」补全。

### 浏览器能直接请求 `/oauth/qr` 吗？

不能。平台未对浏览器开放 CORS，`/oauth/qr` 必须由业务后端调用，再把 `data` 交给前端渲染二维码。由后端调用也便于同时生成并保存业务 state，保证回调时能正确校验。

### 换票返回 40003 是什么原因？

ticket 无效、已过期或已被使用。ticket 默认 300 秒且仅能消费一次，请让用户重新发起登录，不要重复提交同一个 code。

### 换票返回 40002 是什么原因？

client_id 或 client_secret 校验失败。请确认凭证复制完整；如果项目尚未审核通过、已被停用，或密钥刚被重置，也会返回该错误。

### 同一个微信在不同项目里的 openid 一致吗？

一致。本平台所有项目共用同一个微信网站应用，openid 与 unionid 在各项目间相同，可以直接作为跨站点的用户标识。

### 用户昵称为空或头像缺失？

微信可能未返回完整资料，平台会保留可用的基础信息，不影响登录。可以让用户重新扫码，或稍后再次登录刷新资料。

### 密钥泄露或想更换域名怎么办？

在「我的项目」中重置 client_secret，旧密钥会立即失效，请同步更新业务站配置。修改域名或回调地址会触发重新审核，审核通过后新配置才会生效。

---

下一步：查看 [API 文档 Markdown](https://login.fps5.cn/api-docs.md) 了解参数、返回结构、错误码与多语言调用示例。
