文件
fenleixinxi/application/common/library/WxMini.php

308 行
11 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\common\library;
use EasyWeChat\Factory;
use EasyWeChat\MiniProgram\Application;
use think\Db;
/**
* 微信小程序统一入口(基于 overtrue/wechat 4.x,项目已随 composer 安装)
*
* 设计原则:
* · 小程序侧的登录 / 手机号 / access_token **全部交给 EasyWeChat SDK**,
* 业务代码不再手写 cURL、不再自行拼接微信接口 URL 与签名;
* · 配置即用:只读 fa_fc_config 的 wx.appid / wx.secret(后台「小程序设置 → 微信登录」维护),
* 换小程序或换密钥只需改配置,无需改代码;
* · access_token 由 SDK 自带缓存管理(落盘 runtime/cache/wechat),到期自动刷新,
* 多进程共享,不再写数据库。
*
* 使用示例:
* $openid = WxMini::openid($code); // jscode2session
* $phone = WxMini::phoneNumber($phoneCode); // getPhoneNumber
*
* @see \EasyWeChat\MiniProgram\Application
*/
class WxMini
{
/** @var \EasyWeChat\MiniProgram\Application|null SDK 实例(单请求内复用) */
protected static $app = null;
/** @var array|null 配置缓存(fa_fc_config 中 group_key = wx) */
protected static $cfg = null;
/* ==================== 配置 ==================== */
/**
* 读取小程序配置
*
* @param string|null $key 配置键,如 wx.appid;传 null 返回全部
* @param mixed $default 缺省值
* @return mixed
*/
public static function config($key = null, $default = null)
{
if (self::$cfg === null) {
self::$cfg = [];
try {
foreach (Db::name('fc_config')
->where('group_key', 'wx')
->whereOr('key', 'like', 'wx.%')
->select() as $row) {
self::$cfg[$row['key']] = $row['value'];
}
} catch (\Exception $e) {
// 业务表尚未初始化时不阻断请求,按「未配置」处理
}
}
if ($key === null) {
return self::$cfg;
}
return array_key_exists($key, self::$cfg) ? self::$cfg[$key] : $default;
}
/** 清除运行时配置缓存(后台保存配置后调用) */
public static function flush()
{
self::$cfg = null;
self::$app = null;
}
/** 是否已配置 AppID / AppSecret */
public static function isConfigured()
{
return (string)self::config('wx.appid', '') !== ''
&& (string)self::config('wx.secret', '') !== '';
}
/* ==================== SDK ==================== */
/**
* 获取 EasyWeChat 小程序实例
*
* @return \EasyWeChat\MiniProgram\Application
* @throws \RuntimeException 未配置 AppID/AppSecret 时抛出
*/
public static function app()
{
if (self::$app instanceof Application) {
return self::$app;
}
if (!self::isConfigured()) {
throw new \RuntimeException('微信小程序未配置,请先在后台「系统设置 → 系统配置 → 小程序配置」填写 AppID 与 AppSecret');
}
$app = Factory::miniProgram([
'app_id' => (string)self::config('wx.appid', ''),
'secret' => (string)self::config('wx.secret', ''),
'response_type' => 'array',
'http' => [
'timeout' => 8.0,
'connect_timeout' => 5.0,
],
]);
// access_token 缓存落盘到 runtime(SDK 默认用系统临时目录,易被清理且不便排查)
if (class_exists('Symfony\Component\Cache\Psr16Cache')
&& class_exists('Symfony\Component\Cache\Adapter\FilesystemAdapter')) {
$base = defined('CACHE_PATH') ? CACHE_PATH : RUNTIME_PATH . 'cache' . DIRECTORY_SEPARATOR;
$dir = $base . 'wechat';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$app['cache'] = new \Symfony\Component\Cache\Psr16Cache(
new \Symfony\Component\Cache\Adapter\FilesystemAdapter('easywechat', 7200, $dir)
);
}
return self::$app = $app;
}
/* ==================== 登录 / 手机号 ==================== */
/**
* code 换取登录态(jscode2session)
*
* @param string $code wx.login / uni.login 返回的 code
* @return array{openid:string,session_key?:string,unionid?:string}
* @throws \RuntimeException 微信侧返回错误时抛出(message 可直接展示给用户)
*/
public static function session($code)
{
$code = trim((string)$code);
if ($code === '') {
throw new \RuntimeException('缺少微信授权 code');
}
$res = self::toArray(self::app()->auth->session($code));
if (empty($res['openid'])) {
throw new \RuntimeException(self::errorMessage($res, '微信登录失败,请重试'));
}
return $res;
}
/**
* code 换取 openid(登录场景的简写)
*
* @param string $code
* @return string
* @throws \RuntimeException
*/
public static function openid($code)
{
$res = self::session($code);
return (string)$res['openid'];
}
/**
* 手机号快速验证 code 换取真实手机号
* (<button open-type="getPhoneNumber"> 回调里的 e.detail.code)
*
* @param string $code
* @return string 11 位手机号
* @throws \RuntimeException 微信侧失败 / 号码不合法时抛出
*/
public static function phoneNumber($code)
{
$code = trim((string)$code);
if ($code === '') {
throw new \RuntimeException('缺少手机号授权 code');
}
// SDK 的 BaseClient 会自动带上 access_token(含缓存与 401 重试)
$res = self::toArray(
self::app()->getClient()->httpPostJson('wxa/business/getuserphonenumber', ['code' => $code])
);
$info = (isset($res['phone_info']) && is_array($res['phone_info'])) ? $res['phone_info'] : [];
$phone = '';
if (!empty($info['phoneNumber'])) {
$phone = (string)$info['phoneNumber'];
} elseif (!empty($info['purePhoneNumber'])) {
$phone = (string)$info['purePhoneNumber'];
}
if (!preg_match('/^1\d{10}$/', $phone)) {
throw new \RuntimeException(self::errorMessage($res, '微信手机号获取失败,请改用手动输入'));
}
return $phone;
}
/**
* 小程序 access_token(由 SDK 缓存与刷新,业务侧一般不需要直接调用)
*
* @return string
* @throws \RuntimeException
*/
public static function accessToken()
{
$token = self::toArray(self::app()->access_token->getToken());
return isset($token['access_token']) ? (string)$token['access_token'] : '';
}
/**
* 生成并缓存不限次数微信小程序码(wxacode.getUnlimited)
* 若未配置小程序或线上尚未发布正式版,静默返回空字符串以便前端自动回退至普通二维码
*
* @param string $scene 场景值(最长32个可见字符,如 ic_ABCD12)
* @param string $page 小程序页面路径(不带前导 /,如 pages/index/index)
* @param int $width 二维码宽度(px)
* @return string 成功返回相对 URL(如 /uploads/wxacode/xxx.png),失败返回空字符串
*/
public static function wxacodeUnlimit($scene, $page = 'pages/index/index', $width = 280)
{
$scene = substr(trim((string)$scene), 0, 32);
$page = ltrim(trim((string)$page), '/');
if ($scene === '' || !self::isConfigured()) {
return '';
}
$hash = md5($scene . '|' . $page . '|' . (int)$width);
$relDir = '/uploads/wxacode';
$absDir = ROOT_PATH . 'public' . DS . 'uploads' . DS . 'wxacode';
$relUrl = $relDir . '/' . $hash . '.png';
$absFile = $absDir . DS . $hash . '.png';
if (is_file($absFile) && filesize($absFile) > 200) {
return $relUrl;
}
try {
$response = self::app()->app_code->getUnlimit($scene, [
'page' => $page !== '' ? $page : 'pages/index/index',
'width' => max(280, min(1280, (int)$width)),
'check_path' => false,
]);
if ($response instanceof \EasyWeChat\Kernel\Http\StreamResponse) {
$body = $response->getBody()->getContents();
// 校验 PNG (\x89PNG) 或 JPEG (\xFF\xD8) 魔数,避免把微信返回的 JSON 错误当图片落盘
if (strlen($body) > 200 && (substr($body, 0, 4) === "\x89PNG" || substr($body, 0, 2) === "\xFF\xD8")) {
if (!is_dir($absDir)) {
@mkdir($absDir, 0755, true);
}
@file_put_contents($absFile, $body);
if (is_file($absFile) && filesize($absFile) > 200) {
return $relUrl;
}
}
}
} catch (\Throwable $e) {
// 静默降级:小程序未发布或网络受限时由前端自动回退绘制二维码
}
return '';
}
/* ==================== 内部工具 ==================== */
/** SDK 返回 Collection / array / object 统一转数组 */
protected static function toArray($res)
{
if (is_array($res)) {
return $res;
}
if ($res instanceof \EasyWeChat\Kernel\Support\Collection) {
return $res->all();
}
if (is_object($res) && method_exists($res, 'toArray')) {
return (array)$res->toArray();
}
return (array)$res;
}
/**
* 把微信返回的错误码翻译成可展示的中文
*
* @param array $res 微信应答
* @param string $fallback 兜底文案
* @return string
*/
protected static function errorMessage(array $res, $fallback)
{
$code = isset($res['errcode']) ? (int)$res['errcode'] : 0;
$msg = isset($res['errmsg']) ? (string)$res['errmsg'] : '';
$known = [
40029 => '微信登录凭证已失效,请重新授权',
40163 => '微信登录凭证已被使用,请重新授权',
41004 => '小程序 AppSecret 不正确,请在后台检查微信登录配置',
45011 => '微信接口调用过于频繁,请稍后再试',
-1 => '微信服务繁忙,请稍后再试',
];
if (isset($known[$code])) {
return $known[$code];
}
return $msg !== '' ? $fallback . '(' . $code . ': ' . $msg . ')' : $fallback;
}
}