* @Date: 2026-04-29 02:32:33 * @LastEditors: YwxApp * @LastEditTime: 2026-08-16 15:34:28 * @Description: * @FilePath: \ywxapp_dev\app\backend\controller\Addons.php * @CustomString: Copyright (c) 2026 YwxApp */ declare(strict_types=1); namespace app\backend\controller; use think\exception\HttpException; use think\facade\Config; use think\facade\Request; use think\facade\View; use ywxapp\exception\AddonException; use ywxapp\service\AddonService; use ywxapp\service\AddonDevService; use ywxapp\service\RemoteService; use Exception; use ywxapp\controller\BackendBase; /** * addon 类 * * @author ywxapp */ class Addons extends BackendBase { /** * 控制器初始化 initialize * @return void */ public function initialize() {} /** * 插件列表 */ public function index() { if ($this->request->isAjax()) { $results = scandir(ADDON_PATH); $list = []; foreach ($results as $name) { if ($name === '.' or $name === '..' or is_file(ADDON_PATH . $name)) { continue; } $addonDir = ADDON_PATH . $name . DIRECTORY_SEPARATOR; if (! is_dir($addonDir)) { continue; } $infoFile = $addonDir . 'info.php'; if (! is_file($infoFile)) { continue; } $info = include $infoFile; if (! isset($info['name'])) { continue; } // 统一字段,适配前端 layui 表格(列: title/author/version/description/status/id) $info['id'] = $info['name']; $info['description'] = $info['intro'] ?? ''; $info['status'] = $info['state'] ?? 0; $info['hasConfig'] = is_file($addonDir . 'config.php'); $list[] = $info; } // 关键字搜索 $keyword = $this->request->param('keyword', ''); if ($keyword !== '') { $list = array_values(array_filter($list, function ($it) use ($keyword) { return stripos((string) ($it['title'] ?? ''), $keyword) !== false || stripos((string) ($it['name'] ?? ''), $keyword) !== false; })); } // 运行状态筛选 $status = $this->request->param('status', ''); if ($status !== '') { $list = array_values(array_filter($list, function ($it) use ($status) { return (string) ($it['status'] ?? '') === (string) $status; })); } // 分页 $total = count($list); $page = (int) $this->request->param('page', 1); $limit = (int) $this->request->param('limit', 10); $pageList = array_slice($list, max(0, ($page - 1) * $limit), $limit); $this->result->setCount($total)->success($pageList, '获取成功'); } return View::fetch('addon/index'); } /** * 应用市场(Discuz 式远程插件市场,核心后台「插件管理」) * * 服务中心(本机,ywxapp.api_url 为空或指向自己):直接读取本地市场目录(wxapp_appmarket_addon_list), * 无需依赖外部地址,应用商店即刻可见本机托管的插件。 * 客户机(ywxapp.api_url 指向其他服务器,见 is_market_client()):经中心站 API(RemoteService::lists)拉取远程市场列表, * 可一键安装到本客户机(downloadInstall)。客户机不安装 market 插件,应用商店功能统一落在本核心控制器。 */ public function market() { $apiUrl = Config::get('ywxapp.api_url', ''); $isClient = (bool) is_market_client(); $keyword = trim((string) $this->request->param('keyword', '')); $category = trim((string) $this->request->param('category', '')); $type = trim((string) $this->request->param('type', '')); $order = trim((string) $this->request->param('order', 'new')); $list = []; $error = ''; $categories = []; if ($isClient) { // 客户机:经服务中心 API 拉取市场列表 if ($apiUrl) { try { $params = []; if ($keyword !== '') { $params['keyword'] = $keyword; } if ($category !== '') { $params['category'] = $category; } if ($order !== '' && $order !== 'new') { $params['order'] = $order; } if ($type !== '') { $params['type'] = $type; } $resp = (new RemoteService())->lists($params); if (!empty($resp['success']) && !empty($resp['data']['list'])) { $list = $resp['data']['list']; } else { $error = $resp['message'] ?? '获取市场列表失败'; } } catch (\Throwable $e) { $error = '连接市场失败:' . $e->getMessage(); } } else { $error = '未配置市场地址(请在后台配置 ywxapp.api_url)'; } } elseif (!class_exists(\addon\appmall\service\MarketService::class)) { // 本机即中心站但未安装 appmall 插件:市场目录不可用 $error = '本机未安装 appmall 插件:请安装插件以启用市场目录,或配置 ywxapp.api_url 指向中心站'; } else { // 服务中心(本机):委托 appmall 插件读取本地市场目录(中心域逻辑全部在插件侧) try { $r = \addon\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type); $list = $r['list']; $categories = $r['categories']; if (empty($list)) { $error = '本机市场目录为空:可导入 docs/*-appmarket_addon_list.sql 测试记录,或在「开发者中心」上传插件'; } } catch (\Throwable $e) { $error = '读取本机市场目录失败:' . $e->getMessage(); } } // 标注本地安装状态与可升级性(addon 看 addon//info.php;template 看 templates//template.json) foreach ($list as &$it) { $name = $it['name'] ?? ''; $installed = false; $localVersion = ''; if (($it['type'] ?? 'addon') === 'template') { $tplJson = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR . 'template.json'; if (is_file($tplJson)) { $tj = (array) json_decode((string) @file_get_contents($tplJson), true); $localVersion = (string) ($tj['version'] ?? ''); $installed = true; } } else { $localInfoFile = ADDON_PATH . $name . DIRECTORY_SEPARATOR . 'info.php'; if (is_file($localInfoFile)) { $li = include $localInfoFile; $localVersion = $li['version'] ?? ''; $installed = true; } } $it['installed'] = $installed; $it['local_version'] = $localVersion; $it['upgradable'] = $installed && $localVersion !== '' && isset($it['version']) && version_compare($it['version'], $localVersion, '>'); // 预解析 tags 为数组,供前端标签展示(避免模板内嵌 PHP) $rawTags = trim((string) ($it['tags'] ?? '')); $it['tag_list'] = $rawTags === '' ? [] : array_values(array_filter( array_map('trim', explode(',', $rawTags)), function ($t) { return $t !== ''; } )); // 元数据键兜底:旧库无 category/rating/screenshots 列或远程列表未返回时, // 避免模板({$it.category} / {$it.rating})触发 Undefined array key 报错。 foreach (['category', 'screenshots', 'rating'] as $mk) { if (!isset($it[$mk])) { $it[$mk] = ''; } } if (!isset($it['type']) || $it['type'] === '') { $it['type'] = 'addon'; } } unset($it); // 客户机模式:分类下拉数据源从返回列表中动态归纳 if ($isClient && empty($categories) && !empty($list)) { $seen = []; foreach ($list as $it) { $c = (string) ($it['category'] ?? ''); if ($c !== '' && !in_array($c, $seen, true)) { $seen[] = $c; } } $categories = $seen; } View::assign([ 'list' => $list, 'error' => $error, 'is_client' => $isClient, 'api_url' => $apiUrl, 'keyword' => $keyword, 'category' => $category, 'type' => $type, 'order' => $order, 'categories' => $categories, ]); return View::fetch('addon/market'); } /** * 我的插件(核心后台「插件管理」) * 列出本机已安装插件,便于从应用市场跳转后集中管理(配置/升级/启停/卸载见「插件管理」列表)。 */ public function my() { $list = []; if (is_dir(ADDON_PATH)) { foreach (scandir(ADDON_PATH) as $name) { if ($name === '.' || $name === '..' || !is_dir(ADDON_PATH . $name)) { continue; } $infoFile = ADDON_PATH . $name . DIRECTORY_SEPARATOR . 'info.php'; if (!is_file($infoFile)) { continue; } $info = include $infoFile; if (!isset($info['name'])) { continue; } $info['id'] = $info['name']; $info['description'] = $info['intro'] ?? ''; $info['status'] = $info['state'] ?? 0; $info['hasConfig'] = is_file(ADDON_PATH . $name . DIRECTORY_SEPARATOR . 'config.php'); $list[] = $info; } } View::assign('list', $list); View::assign('is_client', (bool) is_market_client()); return View::fetch('addon/my'); } /** * 运营退款(后台视角):按插件名退本地最新「已支付」订单, * 吊销授权 + 订单置已退款 + 收益冲正(与会员端 /api/v1/addon/refund 逻辑一致)。 * 仅中心站模式(ywxapp.api_url 为空或指向自己)支持;客户机模式订单在中心站、属会员 uid, * 后台无会员信息,需到会员中心申请退款。 */ public function refund(Request $request) { if (is_market_client()) { return $this->result->error('远程模式订单存于中心站,请于会员中心申请退款'); } if (!class_exists(\addon\appmall\service\MarketService::class)) { return $this->result->error('本机未安装 appmall 插件,无法执行退款'); } try { \addon\appmall\service\MarketService::instance() ->operatorRefund((string) $request->post('name', '')); } catch (\Throwable $e) { return $this->result->error($e->getMessage()); } return $this->result->success([], '退款成功,授权已吊销'); } /** * 发布到官方公共市场(www.ywxapp.cn) * 打包本机已安装插件并上传到官方市场提交接口,进入「待审核」状态, * 运营审核通过后才在公开市场可见。需先在 .env 配置 APPMARKET_DEV_TOKEN。 */ public function submitOfficial() { try { $name = input('name', ''); if (!$name || !preg_match('/^[a-zA-Z0-9_]+$/', $name)) { return $this->result->error('插件名称格式不正确'); } $token = config('ywxapp.developer_token', ''); if (!$token) { return $this->result->error('未配置开发者令牌:请在 .env 设置 DEVELOPER_TOKEN'); } $AddonService = AddonService::instance($name); if (!$AddonService->isInstalled()) { return $this->result->error('插件未安装,无法发布'); } // 打包(返回本地 zip 路径) $zipFile = $AddonService->package(); $infoFile = ADDON_PATH . $name . DIRECTORY_SEPARATOR . 'info.php'; $info = is_file($infoFile) ? (array) include $infoFile : []; $meta = [ 'name' => $info['name'] ?? $name, 'title' => $info['title'] ?? $name, 'author' => $info['author'] ?? '', 'version' => $info['version'] ?? '', 'price' => $info['price'] ?? 0, 'description' => $info['intro'] ?? ($info['description'] ?? ''), ]; if (empty($meta['version'])) { return $this->result->error('插件版本号缺失,无法提交'); } $client = new \GuzzleHttp\Client([ 'base_uri' => config('ywxapp.api_url'), 'timeout' => 60, 'verify' => (bool) config('appmall.ssl_verify', true), ]); $response = $client->post('/appmall/api/addon/submit', [ 'multipart' => [ ['name' => 'token', 'contents' => $token], ['name' => 'name', 'contents' => $meta['name']], ['name' => 'title', 'contents' => $meta['title']], ['name' => 'author', 'contents' => $meta['author']], ['name' => 'version', 'contents' => $meta['version']], ['name' => 'price', 'contents' => (string) $meta['price']], ['name' => 'description', 'contents' => $meta['description']], ['name' => 'file', 'contents' => fopen($zipFile, 'r'), 'filename' => basename($zipFile)], ], ]); $json = json_decode($response->getBody()->getContents(), true); if (empty($json) || (int) ($json['code'] ?? 0) !== 1) { return $this->result->error('官方市场返回:' . ($json['msg'] ?? '未知错误')); } return $this->result->success($json['data'] ?? [], '已提交,等待官方审核'); } catch (\GuzzleHttp\Exception\RequestException $e) { $msg = $e->getMessage(); if (stripos($msg, 'SSL certificate') !== false || stripos($msg, 'cURL error 60') !== false) { $msg = 'SSL 证书验证失败(cURL error 60):请配置 php.ini 的 curl.cainfo,或在 .env 临时设置 APPMARKET_SSL_VERIFY=false。'; } return $this->result->error('提交到官方市场失败:' . $msg); } catch (AddonException $e) { return $this->result->error($e->getMessage()); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } public function pack() { $addonName = input('name'); if (!$addonName) { return $this->result->error('请指定插件名称'); } $AddonService = AddonService::instance($addonName); if (!$AddonService->isInstalled()) { return $this->result->error('插件不存在'); } try { $zipFile = $AddonService->package(); return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip'); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 上传安装插件 */ public function upload() { if (Request::isAjax()) { Config::set(['default_return_type' => 'json'], 'app'); $info = []; $file = $this->request->file('file'); try { $uid = $this->request->post("uid"); $token = $this->request->post("token"); $faversion = $this->request->post("faversion"); // 鉴权由 Backend 中间件统一处理;uid/token 仅作为离线安装校验参数透传 $extend = [ 'uid' => $uid, 'token' => $token, 'faversion' => $faversion, ]; $info = AddonService::instance()->local($file, $extend); } catch (AddonException $e) { $this->result->error(LANG($e->getMessage(), $e->getCode())); } catch (\Exception $e) { $this->result->error(lang($e->getMessage())); } $this->result->success(['addon' => $info], lang('Offline installed tips'),); } return View::fetch('addon/index'); } /** * 安装插件 */ public function install() { try { $file = Request::file('addon_file'); if (!$file) { return $this->result->error('请上传插件文件'); } $AddonService = AddonService::instance(); $extend = [ 'install_user' => session('user_id'), 'install_ip' => Request::ip(), 'install_time' => time() ]; $info = $AddonService->local($file, $extend); return $this->result->success($info, '插件安装成功'); } catch (AddonException $e) { return $this->result->error($e->getMessage()); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 卸载插件 */ public function uninstall() { try { $addonName = input('name'); // 从请求参数获取插件名 // 验证插件名称 if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) { return json(['code' => 0, 'msg' => '插件名称格式不正确']); } $AddonService = AddonService::instance($addonName); $result = $AddonService->uninstall(); if ($result) { $this->result->success([], '插件卸载成功'); } else { $this->result->error('插件卸载失败'); } } catch (AddonException $e) { $this->result->error('插件卸载失败:' . $e->getMessage()); } catch (\Exception $e) { $this->result->error('插件卸载失败:' . $e->getMessage()); } } /** * 启用/禁用插件 */ public function toggle() { try { $addonName = input('name'); $action = input('action'); // enable or disable $AddonService = AddonService::instance($addonName); if (!$AddonService->isInstalled()) { $this->result->error('插件不存在'); } if ($action === 'enable') { $AddonService->enable(); $this->result->success([], '插件已启用'); } else { $AddonService->disable(); $this->result->success([], '插件已禁用'); } } catch (\Exception $e) { $this->result->error($e->getMessage()); } } /** * 打包插件 */ public function package() { try { $addonName = input('name'); $AddonService = AddonService::instance($addonName); if (!$AddonService->isInstalled()) { return $this->result->error('插件不存在'); } $zipFile = $AddonService->package(); return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 下载并安装 */ public function downloadInstall() { try { $addonName = input('name'); $version = input('version', ''); // 商品类型:addon=插件(默认)/ template=模板(下载后走 TemplateInstaller 落地) $type = strtolower((string) input('type', 'addon')); $AddonService = AddonService::instance($addonName); $extend = []; if ($version) { $extend['version'] = $version; } // 运营安装:透传运营者身份用于审计(不消耗会员下载额度;付费插件按运营特权放行)。 // 会员端的真实下载限额由 RemoteService::downloadBinary 携带 uid 触发,本路径不重复计限。 $extend['operator_id'] = session('user_id') ?? 0; // 下载 $zipFile = $AddonService->download($extend); // 模板商品:不走插件安装流程,交给 TemplateInstaller 还原 templates// 与静态资源 if ($type === 'template') { $tplInfo = \ywxapp\library\TemplateInstaller::install($zipFile, root_path()); @unlink($zipFile); return $this->result->success($tplInfo, '模板下载并安装成功,请到「模板中心」启用'); } // 安装(download 返回本地路径,包装为 File 后走离线安装流程) $file = new \think\File($zipFile); $info = $AddonService->local($file, $extend); return $this->result->success($info, '下载并安装成功'); } catch (AddonException $e) { return $this->result->error($e->getMessage()); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 在线升级插件(从市场下载新版本覆盖安装) * 访问:POST /backend/addon/upgrade {name, version?} */ public function upgrade() { try { $addonName = input('name', ''); $version = input('version', ''); if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) { return $this->result->error('插件名称格式不正确'); } $AddonService = AddonService::instance($addonName); if (!$AddonService->isInstalled()) { return $this->result->error('插件未安装,无法升级'); } $res = $AddonService->onlineUpgrade($version); return $this->result->success( $res, '升级成功:' . ($res['from'] ?? '') . ' → ' . ($res['to'] ?? '') ); } catch (AddonException $e) { return $this->result->error($e->getMessage()); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 插件配置(通用后台配置页) * 依据插件 config.php 的字段定义渲染表单,保存写入统一配置表(数据库独立项 + 缓存)。 * 访问:/backend/addon/setting?addon=<插件标识> */ public function setting() { $addon = input('addon', ''); if (!$addon || !preg_match('/^[a-zA-Z0-9_]+$/', $addon) || !is_dir(ADDON_PATH . $addon)) { return $this->result->error('插件不存在'); } $configFile = ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'config.php'; $fields = is_file($configFile) ? (array) include $configFile : []; if ($this->request->isPost()) { $post = input('post.'); $data = []; foreach ($fields as $f) { $n = $f['name'] ?? ''; if ($n !== '' && array_key_exists($n, $post)) { $data[$n] = $post[$n]; } } AddonService::config($addon, $data); AddonService::clearConfigCache($addon); return $this->result->success('保存成功'); } $saved = AddonService::config($addon); foreach ($fields as &$f) { if (isset($saved[$f['name']])) { $f['value'] = $saved[$f['name']]; } } unset($f); View::assign(['addon' => $addon, 'fields' => $fields]); return View::fetch('addon/setting'); } // ==================== 开发模式:插件设计器(抄 Discuz!) ==================== /** * 设计器页面(仅开发模式可访问) */ public function design() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启,请在 .env 设置 ADDON_DEVELOPER=true'); } $name = input('addon', ''); View::assign('addon', $name); return View::fetch('addon/design'); } /** * 创建插件骨架 */ public function designCreate() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启'); } try { $name = input('name', ''); if (!$name) { return $this->result->error('请填写插件标识'); } $meta = [ 'title' => input('title', ''), 'intro' => input('intro', ''), 'author' => input('author', ''), 'website' => input('website', ''), 'version' => input('version', '1.0.0'), 'url' => input('url', ''), 'license' => input('license', ''), ]; $info = AddonDevService::instance($name)->createSkeleton($meta); return $this->result->success($info, '插件骨架创建成功'); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 读取插件现有数据(供设计器回显) */ public function designRead() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启'); } $name = input('addon', ''); if (!$name) { return $this->result->error('缺少插件标识'); } try { $data = AddonDevService::instance($name)->readAll(); return $this->result->success($data); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 保存插件配置(type: basic/config/menu/hooks/route) */ public function designSave() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启'); } $name = input('addon', ''); $type = input('type', ''); if (!$name) { return $this->result->error('缺少插件标识'); } try { $svc = AddonDevService::instance($name); switch ($type) { case 'basic': $svc->saveBasic(input('post.')); break; case 'config': $fields = json_decode(input('fields', '[]'), true) ?: []; $svc->saveConfig($fields); break; case 'menu': $menu = json_decode(input('menu', '[]'), true) ?: []; $svc->saveMenu($menu); break; case 'hooks': $events = json_decode(input('events', '[]'), true) ?: []; $middleware = json_decode(input('middleware', '[]'), true) ?: []; $services = json_decode(input('services', '[]'), true) ?: []; $svc->saveHooks($events, $middleware, $services); break; case 'route': $routes = json_decode(input('routes', '[]'), true) ?: []; $svc->saveRoute($routes); break; default: return $this->result->error('未知保存类型:' . $type); } return $this->result->success([], '保存成功'); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 生成源码文件(gtype: controller/model/event/listener/middleware/service/subscribe/validate/command) */ public function designGenerate() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启'); } $name = input('addon', ''); $type = input('gtype', ''); if (!$name || !$type) { return $this->result->error('缺少参数'); } try { $opts = json_decode(input('opts', '[]'), true) ?: []; $file = AddonDevService::instance($name)->generate($type, $opts); $rel = ltrim(str_replace(ADDON_PATH . $name . DIRECTORY_SEPARATOR, '', $file), DIRECTORY_SEPARATOR); return $this->result->success(['file' => $rel], '生成成功:' . $rel); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 开发模式安装(原地建表/注入菜单/启用,免打包,实时生效) */ public function designInstall() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启'); } $name = input('addon', ''); if (!$name) { return $this->result->error('缺少插件标识'); } try { $info = AddonDevService::instance($name)->developInstall(); return $this->result->success($info, '开发模式安装成功,菜单已注入并启用'); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } /** * 删除插件目录(开发调试用) */ public function designRemove() { if (!AddonDevService::enabled()) { return $this->result->error('开发模式未开启'); } $name = input('addon', ''); if (!$name) { return $this->result->error('缺少插件标识'); } try { AddonDevService::instance($name)->remove(); return $this->result->success([], '插件目录已删除'); } catch (\Exception $e) { return $this->result->error($e->getMessage()); } } }