Files

781 lines
31 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
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-04-29 02:32:33
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @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 <admin@ywxapp.cn>
*/
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()):经中心站 APIRemoteService::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/<name>/info.phptemplate 看 templates/<name>/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/<name>/ 与静态资源
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());
}
}
}