# PHP 扫码登录 Demo · 使用说明

> 本文件是 <https://login.fps5.cn/php-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/php-demo>
- 源码压缩包：<https://login.fps5.cn/static/php/wx-login-php-demo.zip>
- API 文档（Markdown）：<https://login.fps5.cn/api-docs.md>

## 快速摘要

- **这是什么**：一套无第三方依赖的 PHP 接入示例，覆盖「申请二维码参数 → 页面内嵌二维码 → 扫码回调 → 换 ticket → 写 Session」完整链路。
- **适合谁**：PHP 业务站、需要把微信扫码登录嵌进现有页面的场景。
- **怎么用**：解压到站点目录 → 修改 `config.php` 四个配置项 → 访问 `index.php`。
- **必须准备**：平台「我的项目」中的 `client_id`、`client_secret`，以及项目域名白名单内的回调地址。
- **关键约束**：`redirect_url` 指向 `callback.php`；业务 state 由本站生成并存入 Session，回调必须回到顶层页面校验。

## 1. 下载与运行

运行环境：PHP 7.4+（推荐 8.0+），需要 `curl`、`json`、`session`、`mbstring` 扩展；Nginx / Apache / 宝塔面板均可，无需 Composer。

```bash
# 1. 解压到站点目录（或子目录）
unzip wx-login-php-demo.zip -d /www/wwwroot/你的站点/

# 2. 编辑 config.php，填入「我的项目」中的凭证
#    gateway / client_id / client_secret / redirect_url

# 3. 确认 redirect_url 的域名已在项目域名白名单内

# 4. 浏览器访问
#    https://你的站点/index.php
```

目录结构：

```text
wx-login-php-demo/
├── config.php      # 项目配置：网关地址、client_id、client_secret、redirect_url
├── lib.php         # 公共函数：HTTP 请求、申请二维码参数、业务 state 存取
├── index.php       # Demo 首页：渲染二维码 / 展示用户资料 / 完成换票与 Session 写入
├── qr.php          # 申请或刷新二维码，返回 JSON
├── callback.php    # 回调桥接页：把 code / state 交回顶层窗口
├── login.php       # 整页跳转登录（备用方式）
├── logout.php      # 退出登录，清理 Session
├── style.css       # 页面样式
└── README.md
```

## 2. 配置项

全部配置集中在 `config.php`：

```php
<?php
return [
    'gateway'       => 'https://login.fps5.cn',
    'client_id'     => 'ak_你的client_id',
    'client_secret' => 'sk_你的client_secret',
    'redirect_url'  => 'https://你的站点/callback.php',
];
```

| 配置项 | 必填 | 说明 |
| --- | --- | --- |
| `gateway` | 是 | 登录网关地址，默认 `https://login.fps5.cn`。 |
| `client_id` | 是 | 项目 client_id，在「我的项目」中查看。 |
| `client_secret` | 是 | 项目 client_secret，只用于服务端换取用户资料，不会出现在前端。 |
| `redirect_url` | 是 | 微信授权后回跳的地址，域名必须在项目白名单内，通常填 `https://你的站点/callback.php`。 |

关于 `redirect_url`：网关只校验域名，不要求路径完全一致。但本 Demo 依赖 `callback.php` 完成桥接，因此路径请指向它，不要直接指到 `index.php`。

## 3. 登录链路

整个流程有两类 state，不要混用：业务 state 由本站生成并存入 Session；网关 state 由 `/oauth/qr` 返回，只用于网关与微信之间的校验。

1. **申请二维码参数**：`GET {gateway}/oauth/qr`，服务端传 `client_id`、`redirect_url` 和本地生成的业务 state，拿到 `appid` / `scope` / `redirect_uri` / 网关 state。
2. **渲染二维码**：页面加载微信官方 `wxLogin.js`，用 `self_redirect: true` 在登录卡片内渲染二维码。
3. **扫码确认**：用户在微信中确认授权后，二维码 iframe 自身跳转到 `redirect_url`，并带上 `code` 与网关 state。
4. **回调桥接**：`callback.php` 运行在第三方 iframe 上下文，不读写 Session，只把 `code` / `state` 交给顶层 `index.php`。
5. **校验业务 state**：顶层页面与本站同源，Session 可用，取出并消费业务 state，一次性有效。
6. **换取用户资料**：`POST {gateway}/api/user/info`，用 `client_id` + `client_secret` + ticket 拿到 openid、unionid、昵称、头像，写入 Session。

为什么必须经过 `callback.php`：微信回调发生在 iframe 内，属于第三方上下文，浏览器不会带上本站的 Session Cookie。如果在回调页直接校验 state，会读到空的 Session，从而误报「state 校验失败」。

## 4. 关键代码

### 4.1 服务端申请二维码参数（lib.php）

```php
// 业务 state：回调时原样带回，用于校验来源
$bizState = bin2hex(random_bytes(16));

$query = http_build_query([
    'client_id'    => $config['client_id'],
    'redirect_url' => $config['redirect_url'],
    'state'        => $bizState,
]);

[$http, $body] = demo_http_get(
    rtrim($config['gateway'], '/') . '/oauth/qr?' . $query
);

$json = json_decode($body, true);
if ((int) ($json['code'] ?? 0) !== 200 || empty($json['data']['state'])) {
    return ['ok' => false, 'msg' => $json['msg'] ?? '网关返回异常'];
}

// 只记录业务 state，不要记录网关内部 state
demo_remember_state($bizState);

return ['ok' => true, 'data' => $json['data']];
```

### 4.2 回调桥接页（callback.php）

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

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

$target = 'index.php' . ($query ? '?' . http_build_query($query) : '');
?>

<script>
(function () {
    var target = <?= json_encode($target) ?>;
    try {
        if (window.top && window.top !== window) {
            // 顶层与本站同源，可安全导航
            window.top.location.replace(target);
            return;
        }
    } catch (e) {}
    window.location.replace(target);
})();
</script>
```

### 4.3 顶层校验 state 并换取用户资料（index.php）

```php
$wxCode  = trim((string) ($_GET['wx_code'] ?? ''));
$wxState = (string) ($_GET['wx_state'] ?? '');

if ($wxCode !== '') {
    if (!demo_consume_state($wxState)) {
        $loginError = '登录会话已过期或来源不可信，请重新扫码登录。';
    } else {
        [$http, $body] = demo_http_post(
            rtrim($config['gateway'], '/') . '/api/user/info',
            [
                'client_id'     => $config['client_id'],
                'client_secret' => $config['client_secret'],
                'code'          => $wxCode,
            ]
        );

        $json = json_decode($body, true);
        if ($http !== 200 || (int) ($json['code'] ?? 0) !== 200) {
            $loginError = '换取用户信息失败：' . ($json['msg'] ?? '接口异常');
        } else {
            session_regenerate_id(true);           // 防会话固定
            $_SESSION['wx_user'] = $json['data'];
            header('Location: index.php');         // 去掉地址栏里的 code
            exit;
        }
    }
}
```

## 5. 接口一览

| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `{gateway}/oauth/qr` | GET | 申请二维码参数：`client_id`、`redirect_url`、`state` |
| `{gateway}/oauth/authorize` | GET | 整页跳转授权入口，参数同上 |
| `{gateway}/oauth/callback` | GET | 微信回调入口，由网关处理，业务方无需直接调用 |
| `{gateway}/api/user/info` | POST | 用 ticket 换取用户资料：`client_id`、`client_secret`、`code` |

`/api/user/info` 成功返回：

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

## 6. 常见问题

**页面提示「请先完成配置」**
`config.php` 里仍然是 `ak_你的client_id` 这类占位符。填入真实凭证并刷新页面。

**提示「登录会话已过期或来源不可信」**
通常是二维码放置超过 30 分钟，或刷新页面后又扫了旧二维码。点击「刷新二维码」重新发起即可。若频繁出现，检查服务器 `session.save_path` 是否可写。

**提示「域名不在白名单」**
`redirect_url` 的域名必须与项目里配置的域名一致，修改白名单后需要重新审核。

**二维码区域空白或一直加载中**
`res.wx.qq.com` 无法访问时会出现。可先用页面底部的「整页跳转登录」验证链路是否正常。

**扫码后停在「正在返回…」**
顶层跳转被浏览器安全策略拦截。页面已提供「点此继续」链接，点击即可；也可改用 `login.php` 的整页跳转方式。

**服务器无法访问网关**
确认服务器可出网访问 `https://login.fps5.cn`，并已开启 PHP 的 `curl` 或 `allow_url_fopen`。

## 7. 上线建议

- 不要在前端暴露 `client_secret`，换票请求全部放在 PHP 服务端。
- 生产环境替换 Session 存储，多台服务器请改用 Redis / 数据库共享 Session。
- 登录成功后把 `$_SESSION['wx_user']` 映射到自己的用户表，而不是每次请求都调网关。
- 记录 `state` 校验失败、ticket 换取失败等异常日志，便于排查。

## 8. 相关链接

- 使用说明（网页）：<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 桌面 Demo：<https://login.fps5.cn/python-demo>
