278 lines
10 KiB
PHP
278 lines
10 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|