// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\service; use GuzzleHttp\Client; use GuzzleHttp\Exception\TransferException; use think\facade\Config; use think\facade\Log; /** * 远程市场服务(购买链路远程化) * * 当 ywxapp.api_url 指向其他服务器(见 is_market_client(),本机即客户机)时,客户端插件商店的 * 目录/授权/购买/订单/下载均通过本服务代理到中心站 market 插件 API, * 实现「多租户分库」——客户端不再依赖本地 appmarket_addon_list/appmarket_addon_orders/ * appmarket_addon_licenses/appmarket_addon_download_logs 等市场表。 * * 鉴权:所有写/读敏感请求携带共享密钥 rtoken(与中心站 appmarket_remote_token 一致), * 中心站 Market::authRemote() 校验。中心站响应约定 code=1 为成功。 * * @package ywxapp\service */ class RemoteService { const TIMEOUT = 30; public static function instance(): self { return new self(); } /** * 统一解析中心站响应(中心站成功 code=1) * @return array ['success'=>bool,'message'=>string,'data'=>array] */ private function parse($resp): array { $body = (string) $resp->getBody(); $json = json_decode($body, true) ?: []; $ok = ((int) ($json['code'] ?? 0)) === 1; return [ 'success' => $ok, 'message' => $json['msg'] ?? ($json['message'] ?? '未知错误'), 'data' => $json['data'] ?? [], ]; } private function rtoken(): string { return (string) Config::get('appmall.remote_token', ''); } private function client(): Client { return new Client([ 'base_uri' => rtrim(Config::get('ywxapp.api_url', ''), '/'), 'timeout' => self::TIMEOUT, 'connect_timeout' => 10, 'verify' => (bool) Config::get('appmall.ssl_verify', true), 'http_errors' => false, ]); } /** * 插件列表(应用市场) * @param array $params 可选检索参数:keyword/category/order/page/page_size */ public function lists(array $params = []): array { try { $query = []; foreach (['keyword', 'category', 'order', 'page', 'page_size'] as $k) { if (isset($params[$k]) && $params[$k] !== '') { $query[$k] = $params[$k]; } } $resp = $this->client()->get('/appmall/api/addon/lists', ['query' => $query]); return $this->parse($resp); } catch (TransferException $e) { Log::error('RemoteMarket lists failed: ' . $e->getMessage()); return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 插件详情(买家视角) */ public function info(int $id, int $uid): array { try { $resp = $this->client()->get('/appmall/api/addon/info', [ 'query' => ['id' => $id, 'uid' => $uid, 'rtoken' => $this->rtoken()], ]); return $this->parse($resp); } catch (TransferException $e) { Log::error('RemoteMarket info failed: ' . $e->getMessage()); return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 发起购买 */ public function buy(int $addonId, int $uid, string $method): array { try { $resp = $this->client()->post('/appmall/api/addon/buy', [ 'form_params' => [ 'addon_id' => $addonId, 'uid' => $uid, 'method' => $method, 'rtoken' => $this->rtoken(), ], ]); return $this->parse($resp); } catch (TransferException $e) { Log::error('RemoteMarket buy failed: ' . $e->getMessage()); return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 订单状态查询 */ public function orderStatus(string $tradeNo, int $uid): array { try { $resp = $this->client()->get('/appmall/api/addon/orderStatus', [ 'query' => ['trade_no' => $tradeNo, 'uid' => $uid, 'rtoken' => $this->rtoken()], ]); return $this->parse($resp); } catch (TransferException $e) { return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 支付异步回调(转发到中心站,由中心站完成订单) */ public function notify(array $params): string { try { $params['rtoken'] = $this->rtoken(); $resp = $this->client()->post('/appmall/api/addon/notify', ['form_params' => $params]); return (string) $resp->getBody(); } catch (TransferException $e) { return 'fail'; } } /** * 支付完成落地页 */ public function payResult(array $params): array { try { $params['rtoken'] = $this->rtoken(); $resp = $this->client()->get('/appmall/api/addon/payResult', ['query' => $params]); return $this->parse($resp); } catch (TransferException $e) { return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 退款 / 吊销授权(代理中心站 /appmall/api/addon/refund) */ public function refund(string $tradeNo, int $orderId, int $uid): array { try { $resp = $this->client()->post('/appmall/api/addon/refund', [ 'form_params' => [ 'trade_no' => $tradeNo, 'order_id' => $orderId, 'uid' => $uid, 'rtoken' => $this->rtoken(), ], ]); return $this->parse($resp); } catch (TransferException $e) { return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 我的已购插件(买家视角,运行在中心站) */ public function my(int $uid): array { try { $resp = $this->client()->get('/appmall/api/addon/my', [ 'query' => ['uid' => $uid, 'rtoken' => $this->rtoken()], ]); return $this->parse($resp); } catch (TransferException $e) { Log::error('RemoteMarket my failed: ' . $e->getMessage()); return ['success' => false, 'message' => '连接中心站失败', 'data' => []]; } } /** * 下载插件 zip 二进制(中心站 /appmall/api/index,含可选签名) * @param int $uid 已登录用户ID(>0 时中心站做授权校验,纵深防御) * @param string $domain 当前站点域名(站点授权绑定校验) * @throws \Exception */ public function downloadBinary(string $name, string $version, int $uid = 0, string $domain = ''): string { $query = ['name' => $name, 'version' => $version]; if ($uid > 0) { $query['uid'] = $uid; } if ($domain !== '') { $query['domain'] = $domain; } if (Config::get('ywxapp.addon_download_sign') && $secret = Config::get('ywxapp.addon_secret')) { $ts = time(); $query['ts'] = $ts; $query['sign'] = md5($name . $version . $ts . $secret); } // 流式落地到临时文件(不整包入内存),放宽超时与读取超时 set_time_limit(0); $tmpFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'addon_dl_' . uniqid('', true) . '.zip'; try { $resp = $this->client()->get('/appmall/api/index', [ 'query' => $query, 'sink' => $tmpFile, 'timeout' => 3600, 'read_timeout' => 600, ]); // 服务端错误(HTTP >= 400)或返回 JSON 错误时,sink 写入的是错误内容而非 zip if ($resp->getStatusCode() >= 400) { $err = (string) @file_get_contents($tmpFile); throw new \Exception('下载失败(HTTP ' . $resp->getStatusCode() . '):' . mb_substr($err, 0, 200)); } $fh = fopen($tmpFile, 'rb'); $first = $fh ? fread($fh, 1) : ''; if ($fh) { fclose($fh); } if ($first === '{') { $json = json_decode((string) @file_get_contents($tmpFile), true); throw new \Exception($json['message'] ?? ($json['msg'] ?? '下载失败')); } // 完整性校验:比对 Content-Length 与实际落盘字节数,不一致即判定下载被截断 $size = is_file($tmpFile) ? filesize($tmpFile) : 0; $contentLength = (int) $resp->getHeaderLine('Content-Length'); if ($size <= 0) { throw new \Exception('下载失败:未获取到文件内容'); } if ($contentLength > 0 && $size !== $contentLength) { throw new \Exception('下载插件包不完整(期望 ' . $contentLength . ' 字节,实际 ' . $size . ' 字节)'); } return $tmpFile; } catch (\Throwable $e) { // 任一环节失败都清理临时文件,避免磁盘泄漏(成功路径由调用方 copy+unlink) @unlink($tmpFile); throw $e; } } }