安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
939 lines
38 KiB
PHP
939 lines
38 KiB
PHP
<?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 think\facade\Cache;
|
||
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 = [];
|
||
// 市场最新版本:用于列表内「可升级」提醒(市场不可达时静默降级为空)
|
||
$latestMap = $this->latestVersions();
|
||
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');
|
||
$latest = (string) ($latestMap[$info['name']] ?? '');
|
||
$info['latest_version'] = $latest;
|
||
$info['upgradable'] = $latest !== ''
|
||
&& version_compare($latest, (string) ($info['version'] ?? '0'), '>');
|
||
$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');
|
||
}
|
||
|
||
/**
|
||
* 市场最新版本号映射(插件标识 => 版本号),5 分钟缓存
|
||
*
|
||
* 用于已装列表的「可升级」提醒。市场不可达 / 未配置 / 未安装 appmall 时返回空数组,
|
||
* 列表退化为不显示升级标记,不影响插件管理主流程。
|
||
*/
|
||
private function latestVersions(): array
|
||
{
|
||
try {
|
||
return Cache::remember('addon_market_latest_versions', function () {
|
||
$list = [];
|
||
try {
|
||
if (is_market_client()) {
|
||
$resp = (new RemoteService())->lists([]);
|
||
$list = $resp['data']['list'] ?? [];
|
||
} elseif (class_exists(\app\appmall\service\MarketService::class)) {
|
||
$list = \app\appmall\service\MarketService::instance()
|
||
->catalog('', '', 'new', 'addon')['list'] ?? [];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
return [];
|
||
}
|
||
$map = [];
|
||
foreach ((array) $list as $it) {
|
||
if (($it['type'] ?? 'addon') !== 'addon') {
|
||
continue;
|
||
}
|
||
$n = (string) ($it['name'] ?? '');
|
||
$v = (string) ($it['version'] ?? '');
|
||
if ($n !== '' && $v !== '') {
|
||
$map[$n] = $v;
|
||
}
|
||
}
|
||
return $map;
|
||
}, 300);
|
||
} catch (\Throwable $e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 应用市场(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(\app\appmall\service\MarketService::class)) {
|
||
// 本机即中心站但未安装 appmall 插件:市场目录不可用
|
||
$error = '本机未安装 appmall 插件:请安装插件以启用市场目录,或配置 ywxapp.api_url 指向中心站';
|
||
} else {
|
||
// 服务中心(本机):委托 appmall 插件读取本地市场目录(中心域逻辑全部在插件侧)
|
||
try {
|
||
$r = \app\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.php;template 看 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(\app\appmall\service\MarketService::class)) {
|
||
return $this->result->error('本机未安装 appmall 插件,无法执行退款');
|
||
}
|
||
try {
|
||
\app\appmall\service\MarketService::instance()
|
||
->operatorRefund((string) $request->post('name', ''));
|
||
} catch (\Throwable $e) {
|
||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
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 设置 APPMARKET_DEV_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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
} catch (\Exception $e) {
|
||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
|
||
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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传安装插件
|
||
*/
|
||
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()),
|
||
$e->getCode() ?: 1,
|
||
$e->getData() ?: null
|
||
);
|
||
} 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(),
|
||
// 中心站 Market::valid() 读的是 **uid**(不是 install_user):
|
||
// 离线上传付费插件时若不带 uid,服务端收到 uid=0 会直接判
|
||
// 「该插件为付费插件,请先购买」,导致后台永远装不上自己买的插件。
|
||
// 这里的 uid 指「市场会员 ID」;后台管理员 ID 只在「本机即中心站、
|
||
// 且管理员与市场会员同一套账号」时等价,跨机器场景请走会员登录态。
|
||
'uid' => (int) (session('user_id') ?? 0),
|
||
];
|
||
$info = $AddonService->local($file, $extend);
|
||
return $this->result->success($info, '插件安装成功');
|
||
} catch (AddonException $e) {
|
||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
} catch (\Exception $e) {
|
||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
/**
|
||
* 卸载插件
|
||
*/
|
||
public function uninstall()
|
||
{
|
||
try {
|
||
$addonName = input('name'); // 从请求参数获取插件名
|
||
// 验证插件名称
|
||
if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) {
|
||
return json(['code' => 0, 'msg' => '插件名称格式不正确']);
|
||
}
|
||
// 卸载选项(前端勾选,缺省沿用服务层默认:备份 + 删除)
|
||
// backup_data=0 不备份;delete_data=0 保留插件数据表
|
||
$options = [
|
||
'backup_data' => (bool) input('backup_data', 1),
|
||
'delete_data' => (bool) input('delete_data', 1),
|
||
];
|
||
$AddonService = AddonService::instance($addonName);
|
||
$result = $AddonService->uninstall($options);
|
||
if ($result) {
|
||
$this->result->success([], '插件卸载成功');
|
||
} else {
|
||
$this->result->error('插件卸载失败');
|
||
}
|
||
} catch (AddonException $e) {
|
||
$this->result->error('插件卸载失败:' . $e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
} 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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载并安装
|
||
*/
|
||
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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
} catch (Exception $e) {
|
||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在线升级插件(从市场下载新版本覆盖安装)
|
||
* 访问: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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
} catch (\Exception $e) {
|
||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 插件配置(通用后台配置页)
|
||
* 依据插件 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'] ?? '';
|
||
$type = $f['type'] ?? 'text';
|
||
if ($n === '') {
|
||
continue;
|
||
}
|
||
if (array_key_exists($n, $post)) {
|
||
$v = $post[$n];
|
||
} else {
|
||
// 未提交:开关视为关闭、多选视为空数组;其余字段不覆盖已有值
|
||
if ($type === 'switch') {
|
||
$v = 0;
|
||
} elseif ($type === 'checkbox') {
|
||
$v = [];
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
// array 型:textarea 的「键=值」多行文本转数组存储
|
||
if ($type === 'array' && is_string($v)) {
|
||
$v = self::parseKvText($v);
|
||
}
|
||
$data[$n] = $v;
|
||
}
|
||
AddonService::config($addon, $data);
|
||
AddonService::clearConfigCache($addon);
|
||
return $this->result->success('保存成功');
|
||
}
|
||
$saved = AddonService::config($addon);
|
||
foreach ($fields as &$f) {
|
||
$n = $f['name'] ?? '';
|
||
$type = $f['type'] ?? 'text';
|
||
if ($n !== '' && isset($saved[$n])) {
|
||
$val = $saved[$n];
|
||
// array 型:数组还原成「键=值」文本供 textarea 编辑
|
||
if ($type === 'array' && is_array($val)) {
|
||
$val = implode(PHP_EOL, array_map(
|
||
function ($k, $v) {
|
||
return $k . '=' . (is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
|
||
},
|
||
array_keys($val),
|
||
$val
|
||
));
|
||
}
|
||
$f['value'] = $val;
|
||
}
|
||
// 缺省值兜底:无已存值时用 config.php 的 default,避免模板取到未定义变量
|
||
if (! isset($f['value'])) {
|
||
$f['value'] = $f['default'] ?? ($type === 'checkbox' ? [] : ($type === 'switch' ? 0 : ''));
|
||
}
|
||
// options 容错:正则化写法。select/radio/checkbox 的 options 既可以是数组,
|
||
// 也可以写字符串('0:否,1:是' 或 '北京,上海'),省得插件作者写一堆嵌套数组
|
||
if (isset($f['options'])) {
|
||
$f['options'] = self::normalizeOptions($f['options']);
|
||
}
|
||
}
|
||
unset($f);
|
||
View::assign(['addon' => $addon, 'fields' => $fields]);
|
||
return View::fetch('addon/setting');
|
||
}
|
||
|
||
/**
|
||
* 解析「键=值」多行文本为数组(array 型插件配置用)
|
||
*
|
||
* 每行一条:`key=value` 写入关联键,无等号的整行作为列表项。
|
||
*/
|
||
private static function parseKvText(string $text): array
|
||
{
|
||
$arr = [];
|
||
foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
|
||
$line = trim($line);
|
||
if ($line === '') {
|
||
continue;
|
||
}
|
||
if (strpos($line, '=') !== false) {
|
||
[$k, $v] = explode('=', $line, 2);
|
||
$arr[trim($k)] = trim($v);
|
||
} else {
|
||
$arr[] = $line;
|
||
}
|
||
}
|
||
return $arr;
|
||
}
|
||
|
||
/**
|
||
* 归一化 select/radio/checkbox 的 options
|
||
*
|
||
* 支持三种写法(最终统一成 值=>标题 数组):
|
||
* [1 => '开启', 0 => '关闭'] 数组(原样返回)
|
||
* '0:关闭,1:开启' 键值对字符串
|
||
* '北京,上海' 纯列表(值与标题相同)
|
||
*/
|
||
private static function normalizeOptions($options): array
|
||
{
|
||
if (is_array($options)) {
|
||
return $options;
|
||
}
|
||
if (! is_string($options) || trim($options) === '') {
|
||
return [];
|
||
}
|
||
$out = [];
|
||
foreach (explode(',', $options) as $item) {
|
||
$item = trim($item);
|
||
if ($item === '') {
|
||
continue;
|
||
}
|
||
if (str_contains($item, ':')) {
|
||
[$k, $v] = explode(':', $item, 2);
|
||
$out[trim($k)] = trim($v);
|
||
} else {
|
||
$out[$item] = $item;
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
// ==================== 开发模式:插件设计器(抄 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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 读取插件现有数据(供设计器回显)
|
||
*/
|
||
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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存插件配置(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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成源码文件(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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开发模式安装(原地建表/注入菜单/启用,免打包,实时生效)
|
||
*/
|
||
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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除插件目录(开发调试用)
|
||
*/
|
||
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(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||
}
|
||
}
|
||
}
|