exchange($_GET['hub_ticket']); // 登录:票据换身份 * $o = $hub->payOrder(['out_trade_no'=>'VIP-1','amount_total'=>50000, * 'description'=>'咨询','return_url'=>'https://vip.onzx.cn/me']); * header('Location: ' . $o['cashier_url']); // 托管收银台 * * 约定: * · 所有调用都是 POST + JSON,签名算法见中台 /docs/sign; * · 失败抛 HubException,getCode() 是中台错误码(或微信 errcode),getMessage() 带中文说明,hint 给下一步; * · 只有站点服务端可以用它。密钥不要放进前端、不要写进仓库。 * * 版本:与中台 V0.1.0 同批产出;接口向后兼容,新增字段不影响老调用。 */ if (!class_exists('HubClient')) { class HubException extends RuntimeException { /** @var string */ public $hubCode; /** @var string */ public $hint; /** @var int */ public $http; public function __construct($hubCode, $message, $hint = '', $http = 0) { parent::__construct((string)$message); $this->hubCode = (string)$hubCode; $this->hint = (string)$hint; $this->http = (int)$http; } public function code() { return $this->hubCode; } public function hint() { return $this->hint; } public function http() { return $this->http; } /** 打印给人看的排障一行(不含密钥) */ public function __toString() { return sprintf('HubException [%s] %s%s', $this->hubCode, $this->getMessage(), $this->hint !== '' ? ' → ' . $this->hint : ''); } } class HubClient { /** @var string */ private $siteKey; /** @var string */ private $secret; /** @var string */ private $base; /** @var int */ private $timeout; /** @var bool 最近一次响应的 http 状态 */ public $lastStatus = 0; /** @var array 最近一次原始响应,便于排查 */ public $lastRaw = ''; public function __construct($siteKey, $secret, $base = null, $timeout = 8) { $this->siteKey = (string)$siteKey; $this->secret = (string)$secret; $b = $base !== null ? $base : (getenv('WXHUB_BASE') ?: 'https://wx.onzx.cn'); $this->base = rtrim($b, '/'); $this->timeout = (int)$timeout; if ($this->siteKey === '' || $this->secret === '') { throw new HubException('config', '站点短码或接入密钥为空', '在中台后台「接入站点」注册后复制,放环境变量'); } } /** 登录中转入口 URL:把它放进 或 header('Location:') */ public function loginUrl($return = '/me', $scope = 'snsapi_base') { return $this->base . '/oauth/entry?' . http_build_query([ 'site' => $this->siteKey, 'scope' => $scope, 'return' => $return, ], '', '&'); } /** 订阅授权引导页(中台域名下,省得站点各占一条 JS 接口安全域名) */ public function subscribeLink($eventKey, $return = '/me') { $r = $this->call('/api/v1/message/grant-link', ['event_key' => $eventKey, 'return' => $return]); return isset($r['url']) ? $r['url'] : ''; } // ---------- 身份 ---------- public function exchange($ticket) { return $this->call('/api/v1/identity/exchange', ['ticket' => (string)$ticket]); } public function lookupOpenid($openid) { return $this->call('/api/v1/identity/lookup', ['openid' => (string)$openid]); } public function lookupUnionid($unionid, $eventKey = '') { $p = ['unionid' => (string)$unionid]; if ($eventKey !== '') { $p['event_key'] = $eventKey; } return $this->call('/api/v1/identity/lookup', $p); } // ---------- JS-SDK ---------- /** 站点服务端转发给前端时,只回这四个字段即可 */ public function jsConfig($pageUrl) { return $this->call('/api/v1/js/config', ['url' => (string)$pageUrl]); } // ---------- 消息 ---------- /** * @param array $p ['event_key'=>, 'openid'=>|'unionid'=>, 'params'=>[], 'url'=>, * 'channel'=>, 'miniprogram_appid'=>, 'page'=>, 'send_now'=>bool] */ public function messageSend(array $p) { if (!empty($p['send_now'])) { $p['send_now'] = '1'; } else { unset($p['send_now']); } return $this->call('/api/v1/message/send', $p); } public function messageStatus(array $jobIds) { return $this->call('/api/v1/message/status', ['job_ids' => array_values($jobIds)]); } /** 站点自己弹订阅窗后,把额度回报给中台记账 */ public function quotaGrant($openid, $templateId, $times = 1) { return $this->call('/api/v1/message/grant', [ 'openid' => (string)$openid, 'template_id' => (string)$templateId, 'times' => (int)$times, ]); } // ---------- 支付 ---------- /** * @param array $p ['out_trade_no'=>, 'amount_total'=>分, 'description'=>, * 'scene'=>'cashier'|'jsapi'|'native'|'h5'|'miniprogram', * 'openid'=>, 'return_url'=>, 'attach'=>[], 'channel'=>] */ public function payOrder(array $p) { if (!isset($p['scene'])) { $p['scene'] = 'cashier'; } return $this->call('/api/v1/pay/order', $p); } /** 便捷方法:下单后直接拿到托管收银台链接 */ public function cashierFor($outTradeNo, $amountFen, $description, $returnUrl, array $attach = []) { $r = $this->payOrder([ 'out_trade_no' => $outTradeNo, 'amount_total' => (int)$amountFen, 'description' => $description, 'scene' => 'cashier', 'return_url' => $returnUrl, 'attach' => $attach, ]); return isset($r['cashier_url']) ? $r['cashier_url'] : ''; } public function payQuery($outTradeNo, $refresh = false) { $p = ['out_trade_no' => (string)$outTradeNo]; if ($refresh) { $p['refresh'] = '1'; } return $this->call('/api/v1/pay/query', $p); } public function payClose($outTradeNo) { return $this->call('/api/v1/pay/close', ['out_trade_no' => (string)$outTradeNo]); } public function payRefund($outTradeNo, $outRefundNo, $amountFen, $reason = '') { return $this->call('/api/v1/pay/refund', [ 'out_trade_no' => (string)$outTradeNo, 'out_refund_no' => (string)$outRefundNo, 'amount_refund' => (int)$amountFen, 'reason' => (string)$reason, ]); } // ---------- 小程序 ---------- public function mpLogin($code, $profile = '') { $p = ['code' => (string)$code]; if ($profile !== '') { $p['profile'] = $profile; } return $this->call('/api/v1/miniprogram/login', $p); } public function mpQrcode($scene, $profile = '') { $p = ['scene' => (string)$scene]; if ($profile !== '') { $p['profile'] = $profile; } return $this->call('/api/v1/miniprogram/qrcode', $p); } // ---------- 连通性 ---------- public function ping($echo = null) { return $this->call('/api/v1/ping', $echo === null ? new stdClass() : ['echo' => $echo]); } /** * 验中台发来的回调(支付结果等)。算法与入站签名一致,密钥用「回调密钥」。 * @param string $webhookSecret 站点自己的回调密钥 * @param array $headers 形如 ['X-Hub-Time'=>…,'X-Hub-Nonce'=>…,'X-Hub-Signature'=>…] * @param string $rawBody 原始请求体,不要先解析再重新编码 * @param string $path 回调 URL 的 path(不含域名与 query) */ public static function verifyCallback($webhookSecret, array $headers, $rawBody, $path, $tolerance = 300) { $time = isset($headers['X-Hub-Time']) ? (int)$headers['X-Hub-Time'] : 0; $nonce = isset($headers['X-Hub-Nonce']) ? (string)$headers['X-Hub-Nonce'] : ''; $sign = isset($headers['X-Hub-Signature']) ? strtolower((string)$headers['X-Hub-Signature']) : ''; if ($time === 0 || abs(time() - $time) > $tolerance) { return [false, 'time_window', '回调时间戳超出容差(服务器时钟或伪造)']; } if (strlen($nonce) < 16 || !preg_match('/^[0-9a-f]{64}$/', $sign)) { return [false, 'sign_format', '签名头格式不对']; } $expect = self::sign($webhookSecret, 'POST', $path, $time, $nonce, $rawBody); if (!hash_equals($expect, $sign)) { return [false, 'sign_mismatch', '签名不一致:确认用的是回调密钥而不是接入密钥,且 body 未被重新编码']; } return [true, '', '']; } /** 从 php://input 读原始体并解出回调 JSON(成功时返回数组,失败抛 HubException) */ public function readCallback($webhookSecret) { $raw = file_get_contents('php://input'); $headers = []; foreach (($_SERVER ?? []) as $k => $v) { if (strncmp((string)$k, 'HTTP_X_HUB_', 11) === 0) { $name = 'X-Hub-' . ucwords(str_replace('_', '-', strtolower(substr((string)$k, 11))), '-'); $headers[$name] = (string)$v; } } if (!isset($headers['X-Hub-Signature'])) { // 兼容 header name 大小写差异 foreach ($_SERVER as $k => $v) { if (stripos((string)$k, 'HTTP_X_HUB_SIGNATURE') === 0) { $headers['X-Hub-Signature'] = (string)$v; } if (stripos((string)$k, 'HTTP_X_HUB_TIME') === 0) { $headers['X-Hub-Time'] = (string)$v; } if (stripos((string)$k, 'HTTP_X_HUB_NONCE') === 0) { $headers['X-Hub-Nonce'] = (string)$v; } } } $path = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); list($ok, $code, $msg) = self::verifyCallback($webhookSecret, $headers, $raw, $path); if (!$ok) { throw new HubException($code, $msg, '验签失败时请直接返回 401,不要处理业务', 401); } $p = json_decode((string)$raw, true); if (!is_array($p)) { throw new HubException('body_invalid', '回调报文不是合法 JSON'); } return $p; } // ---------- 底层 ---------- public static function sign($secret, $method, $path, $time, $nonce, $body) { $p = (string)$path; $q = strpos($p, '?'); if ($q !== false) { $p = substr($p, 0, $q); } if ($p === '' || $p[0] !== '/') { $p = '/' . $p; } $string = strtoupper((string)$method) . "\n" . $p . "\n" . (int)$time . "\n" . (string)$nonce . "\n" . hash('sha256', (string)$body); return hash_hmac('sha256', $string, (string)$secret); } private function call($path, $payload) { $body = $payload instanceof stdClass ? '{}' : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $time = time(); $nonce = bin2hex(random_bytes(12)); $sign = self::sign($this->secret, 'POST', $path, $time, $nonce, $body); $ch = curl_init($this->base . $path); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json; charset=utf-8', 'X-Hub-Site: ' . $this->siteKey, 'X-Hub-Time: ' . $time, 'X-Hub-Nonce: ' . $nonce, 'X-Hub-Signature: ' . $sign, 'User-Agent: HubClient PHP', ], ]); $raw = curl_exec($ch); $this->lastStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); $this->lastRaw = is_string($raw) ? $raw : ''; if ($err !== '') { throw new HubException('transport', '调用中台失败:' . $err, '确认 https 可达、证书有效、以及中台域名解析正常', 0); } $j = json_decode((string)$this->lastRaw, true); if (!is_array($j)) { throw new HubException('bad_response', '中台返回不是 JSON(HTTP ' . $this->lastStatus . ')', '可能被网关/WAF 拦了,或路径写错落到了 HTML 错误页', $this->lastStatus); } if (empty($j['ok'])) { throw new HubException( isset($j['code']) ? (string)$j['code'] : 'unknown', isset($j['msg']) ? (string)$j['msg'] : '中台返回失败', isset($j['hint']) ? (string)$j['hint'] : '', $this->lastStatus ); } return isset($j['data']) ? $j['data'] : []; } } }