WechatMini.php 9.93 KB
<?php
declare(strict_types=1);

namespace app\service;

/**
 * 微信小程序 jscode2session
 */
class WechatMini
{
    /**
     * @return array{ok:bool,msg:string,phone?:string}
     */
    public static function getPhoneByCode(string $phoneCode): array
    {
        $appId  = (string) config('wechat.mini_appid');
        $secret = (string) config('wechat.mini_secret');
        if ($appId === '' || $secret === '') {
            return ['ok' => false, 'msg' => '服务端未配置微信小程序 AppID/Secret'];
        }
        if ($phoneCode === '') {
            return ['ok' => false, 'msg' => '缺少手机号授权 code'];
        }

        $tokenRes = self::getAccessToken($appId, $secret);
        if (!$tokenRes['ok']) {
            return ['ok' => false, 'msg' => (string) $tokenRes['msg']];
        }
        $token = (string) ($tokenRes['access_token'] ?? '');
        if ($token === '') {
            return ['ok' => false, 'msg' => '获取微信 access_token 失败'];
        }

        $url = 'https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=' . rawurlencode($token);
        $httpErr = '';
        $raw = self::httpPostJson($url, ['code' => $phoneCode], $httpErr);
        if ($raw === null || $raw === '') {
            return ['ok' => false, 'msg' => '请求微信手机号接口失败' . ($httpErr !== '' ? (':' . $httpErr) : '')];
        }
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['ok' => false, 'msg' => '微信手机号接口返回异常'];
        }
        if (!empty($data['errcode'])) {
            $msg = (string) ($data['errmsg'] ?? '微信错误');
            return ['ok' => false, 'msg' => $msg . ' (' . ($data['errcode'] ?? '') . ')'];
        }
        $phoneInfo = (array) ($data['phone_info'] ?? []);
        $phone = trim((string) ($phoneInfo['phoneNumber'] ?? $phoneInfo['purePhoneNumber'] ?? ''));
        if ($phone === '') {
            return ['ok' => false, 'msg' => '未获取到手机号'];
        }
        return ['ok' => true, 'msg' => 'ok', 'phone' => $phone];
    }

    /**
     * @return array{ok:bool,msg:string,access_token?:string}
     */
    private static function getAccessToken(string $appId, string $secret): array
    {
        $url = sprintf(
            'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s',
            rawurlencode($appId),
            rawurlencode($secret)
        );
        $httpErr = '';
        $raw = self::httpGet($url, $httpErr);
        if ($raw === null || $raw === '') {
            return ['ok' => false, 'msg' => '获取微信 access_token 失败' . ($httpErr !== '' ? (':' . $httpErr) : '')];
        }
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['ok' => false, 'msg' => '微信 access_token 返回异常'];
        }
        if (!empty($data['errcode'])) {
            $msg = (string) ($data['errmsg'] ?? '微信错误');
            return ['ok' => false, 'msg' => $msg . ' (' . ($data['errcode'] ?? '') . ')'];
        }
        $token = (string) ($data['access_token'] ?? '');
        if ($token === '') {
            return ['ok' => false, 'msg' => '微信 access_token 为空'];
        }
        return ['ok' => true, 'msg' => 'ok', 'access_token' => $token];
    }

    /**
     * @return array{ok:bool,msg:string,openid?:string,unionid?:string,session_key?:string}
     */
    public static function code2Session(string $jsCode): array
    {
        $appId  = (string) config('wechat.mini_appid');
        $secret = (string) config('wechat.mini_secret');
        if ($appId === '' || $secret === '') {
            return [
                'ok'  => false,
                'msg' => '服务端未配置微信小程序 AppID/Secret,请在 backend/.env 填写 WECHAT_MINI_APPID 与 WECHAT_MINI_SECRET 后重启后端',
            ];
        }

        $url = sprintf(
            'https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code',
            rawurlencode($appId),
            rawurlencode($secret),
            rawurlencode($jsCode)
        );

        $httpErr = '';
        $raw     = self::httpGet($url, $httpErr);
        if ($raw === null || $raw === '') {
            $msg = '请求微信接口失败(本机 PHP 无法访问 https://api.weixin.qq.com)';
            if ($httpErr !== '' && (bool) env('APP_DEBUG', false)) {
                $msg .= ':' . $httpErr;
            } elseif ($httpErr !== '') {
                $msg .= '。可开启 APP_DEBUG 查看详情,或检查是否启用 php_curl / openssl、防火墙是否拦截出站 HTTPS';
            } else {
                $msg .= '。Windows 常见原因:未启用 curl 扩展,或 php.ini 中 allow_url_fopen=Off;请启用 extension=curl 后重启 PHP';
            }

            return ['ok' => false, 'msg' => $msg];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['ok' => false, 'msg' => '微信接口返回异常'];
        }

        if (!empty($data['errcode'])) {
            $msg = (string) ($data['errmsg'] ?? '微信错误');
            return ['ok' => false, 'msg' => $msg . ' (' . ($data['errcode'] ?? '') . ')'];
        }

        $openid = (string) ($data['openid'] ?? '');
        if ($openid === '') {
            return ['ok' => false, 'msg' => '未获取到 openid'];
        }

        return [
            'ok'          => true,
            'msg'         => 'ok',
            'openid'      => $openid,
            'unionid'     => (string) ($data['unionid'] ?? ''),
            'session_key' => (string) ($data['session_key'] ?? ''),
        ];
    }

    /**
     * 访问微信 HTTPS 接口:优先 cURL(Windows 上比 allow_url_fopen 更可靠),否则 file_get_contents。
     *
     * @param-out string $errorDetail 失败时简要原因(调试展示用)
     */
    private static function httpGet(string $url, string &$errorDetail = ''): ?string
    {
        $errorDetail = '';
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            if ($ch !== false) {
                curl_setopt_array($ch, [
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_CONNECTTIMEOUT => 10,
                    CURLOPT_TIMEOUT        => 20,
                    CURLOPT_SSL_VERIFYPEER => true,
                    CURLOPT_SSL_VERIFYHOST => 2,
                ]);
                $body = curl_exec($ch);
                $errno = curl_errno($ch);
                $cerr  = $errno ? (string) curl_error($ch) : '';
                $code  = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);
                if ($body !== false && $body !== '' && $code >= 200 && $code < 300) {
                    return $body;
                }
                $errorDetail = $cerr !== '' ? ('cURL ' . $cerr) : ('HTTP ' . $code);
            }
        }

        if (!ini_get('allow_url_fopen')) {
            $errorDetail = $errorDetail !== '' ? $errorDetail : 'allow_url_fopen=Off 且 cURL 未成功';

            return null;
        }

        $ctx = stream_context_create([
            'http' => [
                'timeout' => 20,
            ],
            'ssl'  => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);
        $body = @file_get_contents($url, false, $ctx);
        if ($body !== false && $body !== '') {
            return $body;
        }
        if ($errorDetail === '') {
            $errorDetail = 'file_get_contents 失败(可检查 openssl 扩展与网络)';
        }

        return null;
    }

    private static function httpPostJson(string $url, array $payload, string &$errorDetail = ''): ?string
    {
        $errorDetail = '';
        $json = json_encode($payload, JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            $errorDetail = 'JSON 编码失败';
            return null;
        }

        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            if ($ch !== false) {
                curl_setopt_array($ch, [
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_CONNECTTIMEOUT => 10,
                    CURLOPT_TIMEOUT        => 20,
                    CURLOPT_SSL_VERIFYPEER => true,
                    CURLOPT_SSL_VERIFYHOST => 2,
                    CURLOPT_POST           => true,
                    CURLOPT_POSTFIELDS     => $json,
                    CURLOPT_HTTPHEADER     => ['Content-Type: application/json; charset=utf-8'],
                ]);
                $body = curl_exec($ch);
                $errno = curl_errno($ch);
                $cerr  = $errno ? (string) curl_error($ch) : '';
                $code  = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);
                if ($body !== false && $body !== '' && $code >= 200 && $code < 300) {
                    return $body;
                }
                $errorDetail = $cerr !== '' ? ('cURL ' . $cerr) : ('HTTP ' . $code);
            }
        }

        if (!ini_get('allow_url_fopen')) {
            $errorDetail = $errorDetail !== '' ? $errorDetail : 'allow_url_fopen=Off 且 cURL 未成功';
            return null;
        }

        $ctx = stream_context_create([
            'http' => [
                'method'  => 'POST',
                'header'  => "Content-Type: application/json; charset=utf-8\r\n",
                'content' => $json,
                'timeout' => 20,
            ],
            'ssl'  => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);
        $body = @file_get_contents($url, false, $ctx);
        if ($body !== false && $body !== '') {
            return $body;
        }
        if ($errorDetail === '') {
            $errorDetail = 'file_get_contents POST 失败';
        }
        return null;
    }
}