// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\Api\Controller\V1; use ywxapp\controller\ApiController; use ywxapp\service\RemoteService; use think\Request; /** * 插件商店接口(瘦分发器,不持有市场领域逻辑): * * - 客户机模式(is_market_client()=true,ywxapp.api_url 指向中心站): * 经 RemoteService 代理中心站 /appmall/api/addon/*。 * - 中心站模式(api_url 为空/指向本机): * 进程内委托 appmall 插件的 MarketService(订单/授权/收益/支付回调全部在插件侧)。 * - 未安装 appmall 插件且非客户机:市场功能不可用,返回明确提示。 * * 中心站领域数据(appmarket_* 表)已全部收敛到 addon/appmall,核心不再直接读写。 */ class Addon extends ApiController { // notify/payResult 由支付平台回调/跳转,无需登录;info 公开 protected $noNeedLogin = ['info', 'notify', 'payResult']; protected $needRight = ['*']; /** * 中心站模式下委托的插件市场服务 * @var \addon\appmall\service\MarketService|null */ protected $market = null; public function initialize() { if (!is_market_client() && class_exists(\addon\appmall\service\MarketService::class)) { $this->market = \addon\appmall\service\MarketService::instance(); $this->market->ensureTables(); } } /** * 本机为中心站但未安装市场插件时的统一出口 */ protected function marketUnavailable(): \think\response\Json { return $this->result->error('市场服务不可用:本机未配置中心站地址(ywxapp.api_url),也未安装 appmall 插件', 503); } /** * 将插件领域异常转换为 Result 错误响应 */ protected function marketError(\Throwable $e): \think\response\Json { $code = (int) $e->getCode(); $code = $code >= 400 && $code < 600 ? $code : 500; if ($code === 404) { return $this->result->setStatusCode(404)->error($e->getMessage(), 404); } return $this->result->error($e->getMessage(), $code); } /** * 获取插件信息(支持列表和详情) */ public function info(Request $request): \think\response\Json { if (is_market_client()) { return $this->remoteInfo($request); } if (!$this->market) { return $this->marketUnavailable(); } $id = (int) $request->get('id', 0); if ($id > 0) { try { $data = $this->market->info($id, $this->getLoginUid($request) ?? 0); } catch (\Throwable $e) { return $this->marketError($e); } $data['installed'] = is_dir(ADDON_PATH . ($data['addon']['name'] ?? '') . DIRECTORY_SEPARATOR); return $this->result->success($data, '获取成功'); } $r = $this->market->lists( max((int) $request->get('page', 1), 1), (int) $request->get('per_page', 15) ); $this->result->setCount($r['total']); return $this->result->success(['data' => $r['items']], '获取成功'); } /** * 轻量解析请求中的 JWT,返回用户ID(不触发完整登录副作用) */ protected function getLoginUid(Request $request): ?int { $auth = $request->header('authorization'); if (!$auth || !str_starts_with($auth, 'Bearer ')) { return null; } try { $token = \ywxapp\service\JwtService::instance()->parseAndValidate(substr($auth, 7)); $uid = $token->claims()->get('uid'); return $uid ? (int) $uid : null; } catch (\Throwable) { return null; } } /** * 发起购买:创建/复用待支付订单并拉起支付(或模拟支付直接签发授权) */ public function buy(Request $request) { if (is_market_client()) { return $this->remoteBuy($request); } if (!$this->market) { return $this->marketUnavailable(); } $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录后再购买'); } try { $r = $this->market->buy( (int) $userId, (int) $request->post('addon_id'), (string) $request->post('method', 'alipay'), (string) $request->root(true) ); } catch (\Throwable $e) { return $this->marketError($e); } return $this->result->success($r['data'], $r['message']); } /** * 支付结果查询(前端轮询用) */ public function orderStatus(Request $request): \think\response\Json { if (is_market_client()) { return $this->remoteOrderStatus($request); } if (!$this->market) { return $this->marketUnavailable(); } $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } try { $data = $this->market->orderStatus((int) $userId, (string) $request->get('trade_no')); } catch (\Throwable $e) { return $this->marketError($e); } return $this->result->success($data, '获取成功'); } /** * 支付完成落地页(支付宝 return_url 跳转,无需登录,仅展示状态) */ public function payResult(Request $request): \think\response\Json { if (is_market_client()) { $r = RemoteService::instance()->payResult($request->get()); return $this->result->success($r['data'] ?? ['status' => 0], $r['message'] ?: '获取成功'); } if (!$this->market) { return $this->marketUnavailable(); } $r = $this->market->payResult((string) $request->get('out_trade_no', '')); return $this->result->success($r['data'], $r['message']); } /** * 退款 / 吊销授权(买家或运营) * POST /api/v1/addon/refund {trade_no 或 order_id} */ public function refund(Request $request): \think\response\Json { if (is_market_client()) { return $this->remoteRefund($request); } if (!$this->market) { return $this->marketUnavailable(); } $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } try { $r = $this->market->refund( (int) $userId, (string) $request->post('trade_no', ''), (int) $request->post('order_id', 0) ); } catch (\Throwable $e) { return $this->marketError($e); } return $this->result->success($r['data'], $r['message']); } /** * 我的已购插件(买家视角):列出已购付费插件 + 授权状态 + 是否可退款 */ public function mine(Request $request): \think\response\Json { if (is_market_client()) { return $this->remoteMine($request); } if (!$this->market) { return $this->marketUnavailable(); } $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } return $this->result->success($this->market->mine((int) $userId), '获取成功'); } /** * 支付宝/微信异步回调:验签 -> 校验金额 -> 订单置已付 -> 生成授权 */ public function notify() { if (is_market_client()) { return response(RemoteService::instance()->notify(input())); } if (!$this->market) { return response('fail'); } return $this->market->notify((string) input('method', 'alipay')); } /** * 用户下载已购插件(需登录 + 有效授权 + 次数限制) */ public function download(Request $request) { if (is_market_client()) { return $this->remoteDownload($request); } if (!$this->market) { return $this->marketUnavailable(); } $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } try { $r = $this->market->download((int) $userId, (int) $request->get('addon_id'), $request); } catch (\Throwable $e) { return $this->marketError($e); } return download($r['file'], $r['filename']); } // ============ 客户机模式(ywxapp.api_url 指向其他服务器,见 is_market_client())下的代理实现 ============ /** * 远程:插件详情 / 列表(代理中心站) */ protected function remoteInfo(Request $request): \think\response\Json { $id = (int) $request->get('id', 0); $uid = $this->getLoginUid($request) ?? 0; if ($id > 0) { $r = RemoteService::instance()->info($id, $uid); if (!$r['success']) { return $this->result->setStatusCode(404)->error($r['message'] ?: '获取失败', 404); } $addon = $r['data']['addon'] ?? []; $installed = is_dir(ADDON_PATH . ($addon['name'] ?? '') . DIRECTORY_SEPARATOR); return $this->result->success([ 'addon' => $addon, 'purchased' => $r['data']['purchased'] ?? false, 'installed' => $installed, ], '获取成功'); } // 透传分页/筛选参数给中心站,由服务端完成分页(避免全量拉回后本地切片) $params = [ 'keyword' => $request->get('keyword', ''), 'category' => $request->get('category', ''), 'order' => $request->get('order', ''), 'page' => max((int) $request->get('page', 1), 1), 'page_size' => min(max((int) $request->get('per_page', 15), 1), 50), ]; $r = RemoteService::instance()->lists($params); if (!$r['success']) { return $this->result->error($r['message'] ?: '获取失败'); } // 优先消费中心站服务端分页(Market::lists 在 page>0 时返回 data.list/data.total); // 兼容历史 data.count 键;老中心站未分页时降级为本地切片。 if (isset($r['data']['list']) && (isset($r['data']['total']) || isset($r['data']['count']))) { $items = $r['data']['list']; $total = (int) ($r['data']['total'] ?? $r['data']['count']); } else { $rows = $r['data']['list'] ?? []; $perPage = min(max((int) $request->get('per_page', 15), 1), 50); $page = max((int) $request->get('page', 1), 1); $total = count($rows); $items = array_slice($rows, ($page - 1) * $perPage, $perPage); } $this->result->setCount($total); return $this->result->success(['data' => $items], '获取成功'); } /** * 远程:发起购买(代理中心站) */ protected function remoteBuy(Request $request): \think\response\Json { $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录后再购买'); } $addonId = (int) $request->post('addon_id'); $method = $request->post('method', 'alipay'); $r = RemoteService::instance()->buy($addonId, $userId, $method); if (!$r['success']) { return $this->result->error($r['message'] ?: '购买失败'); } return $this->result->success($r['data'] ?? [], $r['message'] ?: '操作成功'); } /** * 远程:订单状态(代理中心站) */ protected function remoteOrderStatus(Request $request): \think\response\Json { $tradeNo = $request->get('trade_no'); $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } $r = RemoteService::instance()->orderStatus($tradeNo, $userId); if (!$r['success']) { return $this->result->error($r['message'] ?: '查询失败'); } return $this->result->success($r['data'] ?? [], '获取成功'); } /** * 远程:下载已购插件(代理中心站 /appmall/api/index,付费插件需已购买) */ protected function remoteDownload(Request $request) { $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } $addonId = (int) $request->get('addon_id'); $r = RemoteService::instance()->info($addonId, $userId); if (!$r['success']) { return $this->result->error($r['message'] ?: '插件信息获取失败'); } $addon = $r['data']['addon'] ?? []; $name = $addon['name'] ?? ''; $version = $addon['version'] ?? ''; if ($name === '' || $version === '') { return $this->result->error('插件信息不完整', 404); } if (!empty($addon['price']) && (float) $addon['price'] > 0 && empty($r['data']['purchased'])) { return $this->result->error('您尚未购买该插件', 403); } try { $domain = (string) $request->domain(); $srcFile = RemoteService::instance()->downloadBinary($name, $version, $userId, $domain); } catch (\Exception $e) { return $this->result->error($e->getMessage(), 404); } $tmpDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon_download' . DIRECTORY_SEPARATOR; if (!is_dir($tmpDir)) { @mkdir($tmpDir, 0755, true); } $tmpFile = $tmpDir . $name . '-' . $version . '.zip'; if (is_file($srcFile)) { copy($srcFile, $tmpFile); @unlink($srcFile); } else { return $this->result->error('下载失败:未获取到文件', 404); } return new \ywxapp\library\StreamZipResponse($tmpFile, $name . '-' . $version . '.zip'); } /** * 远程:退款 / 吊销授权(代理中心站) */ protected function remoteRefund(Request $request): \think\response\Json { $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } $r = RemoteService::instance()->refund( (string) $request->post('trade_no', ''), (int) $request->post('order_id', 0), $userId ); if (!$r['success']) { return $this->result->error($r['message'] ?: '退款失败'); } return $this->result->success($r['data'] ?? [], $r['message'] ?: '退款成功'); } /** * 远程:我的已购插件(代理中心站) */ protected function remoteMine(Request $request): \think\response\Json { $userId = $this->auth->model->uid ?? null; if (empty($userId)) { return $this->result->setStatusCode(401)->error('请先登录'); } $r = RemoteService::instance()->my($userId); if (!$r['success']) { return $this->result->error($r['message'] ?: '获取失败'); } return $this->result->success($r['data'] ?? ['list' => []], $r['message'] ?: '获取成功'); } }