Files
YwxAppThink/ywxapp/service/AddonDevService.php
T
ywxapp 1d49e6f5ee feat: 公共表更名 common/member 前缀 + 插件命令自动加载 + 版本 1.1.1
- 18 张公共表更名(addon/attachment/configure/links/spider_log/spider_stat/sms/notice/ad/task/prop/medal/help/card -> common_*,member_wallets->member_wallet,score_rule/score_log -> member_*,addon_config->common_addonconf),模型全部对齐新表名,Db 直引用清零
- Attachment 模型补  表名绑定,修复富文本上传查 wxapp_attachment 1146 隐患
- install.sql + 迁移 SQL:backend_admin/backend_role delete_at 默认 0,修复软删除(NULL != 0)误过滤导致后台菜单为空
- AppService::boot() 支持插件 info.php 声明 commands 自动注册插件命令(psr-4 自动加载,坏类名自动跳过)
- 各插件(haonav/mqttbroker/wxchat/articles/blog/forum 等)字段与配置同步调整
- 框架版本 1.1.0 -> 1.1.1
2026-08-20 20:19:15 +08:00

486 lines
18 KiB
PHP
Raw 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 think\facade\Config;
use think\facade\Env;
/**
* 插件开发模式服务(抄 Discuz! 插件设计器)
*
* 在「开发模式」开启时,为后台「设计插件」提供可视化创建/编辑能力:
* - 创建插件骨架(目录 + info.php/Addon.php/config.php/menu.json/route/app.php/示例控制器)
* - 生成各类源码文件(controller/model/event/listener/middleware/service/subscribe/validate/command),复用 ywxapp/command/addon 模板
* - 写回 info.php(基础信息 / events / middleware / services)、config.php、menu.json、route/app.php
* - 开发模式安装(原地建表/注入菜单/启用,免打包)
*
* 框架以文件扫描驱动插件加载,因此上述写回后前台实时生效,无需重新打包安装。
*
* @package ywxapp\service
*/
class AddonDevService
{
/** @var string 插件标识 */
private string $addon;
/** @var string 插件目录绝对路径 */
private string $addonDir;
public function __construct(string $name)
{
$this->addon = $name;
$this->addonDir = ADDON_PATH . $name . DIRECTORY_SEPARATOR;
}
/**
* 开发模式是否开启(独立于 APP_DEBUG 的独立开关,抄 DZ 的 plugin['developer'])
*/
public static function enabled(): bool
{
if (!defined('ADDON_PATH')) {
return false;
}
return (bool) Env::get('addon_developer', false)
|| (bool) config('ywxapp.addon_developer', false);
}
/**
* 创建插件骨架
* @param array $meta 基础信息:title/intro/author/website/version/url/license
* @return array 生成的 info
* @throws \Exception
*/
public function createSkeleton(array $meta): array
{
$name = $this->addon;
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
throw new \Exception('插件标识不合法(字母开头,仅含字母/数字/下划线)');
}
if (is_dir($this->addonDir)) {
throw new \Exception('插件目录已存在:' . $name);
}
$this->checkDirBuild($this->addonDir);
foreach ([
'controller', 'controller/backend', 'controller/member',
'model', 'view', 'view/frontend', 'view/backend', 'view/member',
'lang', 'route', 'validate', 'event', 'listener', 'middleware',
'service', 'subscribe', 'command',
] as $d) {
$this->checkDirBuild($this->addonDir . $d);
}
// info.php(直接构造,保证字段完整)
$info = [
'name' => $name,
'title' => $meta['title'] ?? $name,
'intro' => $meta['intro'] ?? '',
'author' => $meta['author'] ?? '',
'website' => $meta['website'] ?? '',
'version' => $meta['version'] ?? '1.0.0',
'state' => 1,
'url' => $meta['url'] ?? '/' . $name,
'license' => $meta['license'] ?? '',
'licenseto' => 0,
'config' => [],
'events' => ['bind' => [], 'listen' => [], 'subscribe' => []],
'middleware' => ['alias' => [], 'priority' => []],
'services' => [],
'install_time' => time(),
];
$this->writeInfo($info);
// Addon.php(主类)
$this->writeFile('Addon.php', $this->renderStub('addon.stub', [
'{%namespace%}' => 'addon\\' . $name,
'{%addon%}' => $name,
'{%className%}' => 'Addon',
]));
// common.php
if (!is_file($this->addonDir . 'common.php')) {
$this->writeFile('common.php', "<?php" . PHP_EOL . "// 插件公共文件" . PHP_EOL);
}
// 示例前台控制器 Index
$this->generate('controller', ['name' => 'Index', 'layer' => 'frontend', 'kind' => 'default']);
// route/app.php
if (!is_file($this->addonDir . 'route/app.php')) {
$this->writeFile('route/app.php', "<?php" . PHP_EOL . "use think\\facade\\Route;" . PHP_EOL . PHP_EOL);
}
// menu.json
if (!is_file($this->addonDir . 'menu.json')) {
$menu = ['frontend' => [], 'member' => [], 'backend' => []];
file_put_contents($this->addonDir . 'menu.json',
json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
// config.php
if (!is_file($this->addonDir . 'config.php')) {
$this->writeFile('config.php', "<?php" . PHP_EOL . "// 插件配置项(后台「配置」表单数据源)" . PHP_EOL . "return [];" . PHP_EOL);
}
// 静态资源目录
$pub = public_path() . 'static/' . $name . DIRECTORY_SEPARATOR;
foreach (['', 'img', 'css', 'js'] as $d) {
$this->checkDirBuild($pub . $d);
}
return $info;
}
/**
* 读取插件现有数据(供设计器回显)
*/
public function readAll(): array
{
$infoFile = $this->addonDir . 'info.php';
$info = is_file($infoFile) ? (array) include $infoFile : [];
$config = is_file($this->addonDir . 'config.php') ? (array) include $this->addonDir . 'config.php' : [];
$menu = is_file($this->addonDir . 'menu.json')
? (array) json_decode(file_get_contents($this->addonDir . 'menu.json'), true)
: ['frontend' => [], 'member' => [], 'backend' => []];
$routeRaw = '';
$routeFile = $this->addonDir . 'route/app.php';
if (is_file($routeFile)) {
$routeRaw = preg_replace('/^<\?php\s*use\s+think\\\\facade\\\\Route;\s*/', '', file_get_contents($routeFile));
$routeRaw = trim($routeRaw);
}
return [
'name' => $this->addon,
'info' => $info,
'config' => $config,
'menu' => $menu,
'route_raw' => $routeRaw,
];
}
/**
* 写回 info.php 基础字段
*/
public function saveBasic(array $data): void
{
$infoFile = $this->addonDir . 'info.php';
if (!is_file($infoFile)) {
throw new \Exception('info.php 不存在,请先创建插件');
}
$info = (array) include $infoFile;
$fields = ['title', 'intro', 'author', 'website', 'version', 'url', 'license', 'licenseto'];
foreach ($fields as $f) {
if (array_key_exists($f, $data)) {
$info[$f] = $data[$f];
}
}
$this->writeInfo($info);
}
/**
* 写回 config.php(配置项/变量)
* @param array $fields 每项 [name,title,type,value,tip,options?]
*/
public function saveConfig(array $fields): void
{
$data = [];
foreach ($fields as $f) {
if (!is_array($f)) {
continue;
}
$name = $f['name'] ?? '';
if ($name === '') {
continue;
}
$item = [
'name' => $name,
'title' => $f['title'] ?? $name,
'type' => $f['type'] ?? 'string',
'value' => $f['value'] ?? '',
'tip' => $f['tip'] ?? '',
];
if (isset($f['options']) && is_array($f['options'])) {
$item['options'] = $f['options'];
}
$data[] = $item;
}
$this->writeReturnArray($this->addonDir . 'config.php', $data);
// 设计器改了 config.php 静态默认值后,清运行时配置缓存与插件加载缓存,
// 使下次 readConfig() 回源重新合并(文件默认值 + 数据库已保存值),
// 否则仅改了文件、DB/缓存未动,运行时 config('插件名') 仍读旧值。
\ywxapp\service\AddonService::clearConfigCache($this->addon);
\ywxapp\service\AppService::clearAddonCache();
}
/**
* 写回 menu.json(后台/会员/前台菜单)
*/
public function saveMenu(array $menu): void
{
$menu = array_merge(['frontend' => [], 'member' => [], 'backend' => []], $menu);
file_put_contents($this->addonDir . 'menu.json',
json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* 写回 info.php 的 events / middleware / services
*/
public function saveHooks(array $events, array $middleware, array $services): void
{
$infoFile = $this->addonDir . 'info.php';
if (!is_file($infoFile)) {
throw new \Exception('info.php 不存在,请先创建插件');
}
$info = (array) include $infoFile;
$info['events'] = $events ?: ['bind' => [], 'listen' => [], 'subscribe' => []];
$info['middleware'] = $middleware ?: ['alias' => [], 'priority' => []];
$info['services'] = $services ?: [];
$this->writeInfo($info);
}
/**
* 写回 route/app.php(结构化路由规则)
* @param array $routes 每项 [method,path,controller,action]
*/
public function saveRoute(array $routes): void
{
$lines = ["<?php", "use think\\facade\\Route;", ""];
foreach ($routes as $r) {
if (!is_array($r)) {
continue;
}
$method = strtolower($r['method'] ?? 'get');
$path = $r['path'] ?? '';
$ctrl = $r['controller'] ?? '';
$action = $r['action'] ?? 'index';
if ($path === '' || $ctrl === '') {
continue;
}
if (!in_array($method, ['get', 'post', 'put', 'delete', 'patch', 'any', 'resource'], true)) {
$method = 'get';
}
$lines[] = "Route::{$method}('{$path}', '{$ctrl}/{$action}');";
}
file_put_contents($this->addonDir . 'route/app.php', implode("\n", $lines) . "\n");
}
/**
* 生成源码文件(controller/model/event/listener/middleware/service/subscribe/validate/command)
* @param string $type 类型
* @param array $opts name(必填) / layer(frontend|backend|member) / kind(default|api|plain) / command(命令名)
* @return string 生成文件路径
* @throws \Exception
*/
public function generate(string $type, array $opts): string
{
$name = $opts['name'] ?? '';
if (!$name || !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
throw new \Exception('类名不合法(字母开头,仅含字母/数字/下划线)');
}
$base = 'addon\\' . $this->addon;
switch ($type) {
case 'controller':
$layer = $opts['layer'] ?? 'frontend';
$kind = $opts['kind'] ?? 'default';
$stubMap = ['api' => 'controller.api.stub', 'plain' => 'controller.plain.stub', 'default' => 'controller.stub'];
$stub = $stubMap[$kind] ?? 'controller.stub';
$relDir = $layer === 'backend' ? 'backend/' : ($layer === 'member' ? 'member/' : '');
$ns = $base . '\\controller' . ($relDir ? '\\' . rtrim($relDir, '/') : '');
$baseCls = $layer === 'backend' ? '\ywxapp\controller\BackendBase'
: ($layer === 'member' ? '\ywxapp\controller\AddonMember' : '\ywxapp\controller\FrontendBase');
$content = $this->renderStub($stub, [
'{%namespace%}' => $ns,
'{%className%}' => $name,
'{%actionSuffix%}' => '',
'{%app_namespace%}' => $base,
]);
$content = str_replace('extends FrontendBase', 'extends ' . $baseCls, $content);
$file = $this->addonDir . 'controller/' . $relDir . $name . '.php';
break;
case 'model':
$content = $this->renderStub('model.stub', ['{%namespace%}' => $base . '\\model', '{%className%}' => $name]);
$file = $this->addonDir . 'model/' . $name . '.php';
break;
case 'event':
$content = $this->renderStub('event.stub', ['{%namespace%}' => $base . '\\event', '{%className%}' => $name]);
$file = $this->addonDir . 'event/' . $name . '.php';
break;
case 'listener':
$content = $this->renderStub('listener.stub', ['{%namespace%}' => $base . '\\listener', '{%className%}' => $name]);
$file = $this->addonDir . 'listener/' . $name . '.php';
break;
case 'middleware':
$content = $this->renderStub('middleware.stub', ['{%namespace%}' => $base . '\\middleware', '{%className%}' => $name]);
$file = $this->addonDir . 'middleware/' . $name . '.php';
break;
case 'service':
$content = $this->renderStub('service.stub', ['{%namespace%}' => $base . '\\service', '{%className%}' => $name]);
$file = $this->addonDir . 'service/' . $name . '.php';
break;
case 'subscribe':
$content = $this->renderStub('subscribe.stub', ['{%namespace%}' => $base . '\\subscribe', '{%className%}' => $name]);
$file = $this->addonDir . 'subscribe/' . $name . '.php';
break;
case 'validate':
$content = $this->renderStub('validate.stub', ['{%namespace%}' => $base . '\\validate', '{%className%}' => $name]);
$file = $this->addonDir . 'validate/' . $name . '.php';
break;
case 'command':
$cmdName = $opts['command'] ?? ($this->addon . ':' . lcfirst($name));
$content = $this->renderStub('command.stub', [
'{%namespace%}' => $base . '\\command',
'{%className%}' => $name,
'{%commandName%}' => $cmdName,
]);
$file = $this->addonDir . 'command/' . $name . '.php';
break;
default:
throw new \Exception('未知的生成类型:' . $type);
}
$this->checkDirBuild(dirname($file));
if (is_file($file)) {
throw new \Exception('文件已存在:' . ltrim(str_replace($this->addonDir, '', $file), '/'));
}
file_put_contents($file, $content);
return $file;
}
/**
* 开发模式安装(原地建表/注入菜单/启用,免打包)
* @return array
* @throws \Exception
*/
public function developInstall(): array
{
return AddonService::instance($this->addon)->developInstall();
}
/**
* 删除插件目录(开发调试用)
*/
public function remove(): bool
{
if (!is_dir($this->addonDir)) {
return true;
}
return $this->deleteDirectory($this->addonDir);
}
// ==================== 私有辅助 ====================
/**
* 渲染 stub 模板(占位符替换)
*/
private function renderStub(string $stub, array $replace): string
{
$file = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR
. 'command' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . $stub;
if (!is_file($file)) {
throw new \Exception('stub 模板不存在:' . $stub);
}
$content = file_get_contents($file);
return str_replace(array_keys($replace), array_values($replace), $content);
}
/**
* 写文件(带目录创建)
*/
private function writeFile(string $relative, string $content): void
{
$file = $this->addonDir . $relative;
$this->checkDirBuild(dirname($file));
file_put_contents($file, $content);
}
/**
* 写回 info.php(短数组风格,递归导出保证 [] 配对正确)
*/
private function writeInfo(array $info): void
{
$string = "<?php\n\nreturn " . $this->exportArray($info) . ";\n";
file_put_contents($this->addonDir . 'info.php', $string);
}
/**
* 写回返回数组的 PHP 文件(config.php 等)
*/
private function writeReturnArray(string $file, array $data): void
{
$string = "<?php\n\nreturn " . $this->exportArray($data) . ";\n";
file_put_contents($file, $string);
}
/**
* 递归导出为合法的 PHP 短数组语法(保证 [ 与 ] 配对)
*/
private function exportArray(array $data, int $indent = 0): string
{
if (empty($data)) {
return '[]';
}
$isAssoc = array_keys($data) !== range(0, count($data) - 1);
$pad = str_repeat(' ', $indent + 1);
$padEnd = str_repeat(' ', $indent);
$lines = [];
foreach ($data as $k => $v) {
$key = $isAssoc ? var_export($k, true) . ' => ' : '';
$val = is_array($v) ? $this->exportArray($v, $indent + 1) : var_export($v, true);
$lines[] = $pad . $key . $val;
}
return "[\n" . implode(",\n", $lines) . "\n" . $padEnd . "]";
}
/**
* 创建目录
*/
private function checkDirBuild(string $dirname): void
{
if (!is_dir($dirname)) {
mkdir($dirname, 0755, true);
}
}
/**
* 递归删除目录
*/
private function deleteDirectory(string $dirPath): bool
{
if (!is_dir($dirPath)) {
return false;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dirPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
@rmdir($item->getPathname());
} else {
@unlink($item->getPathname());
}
}
return @rmdir($dirPath);
}
}