Files

278 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}
}