chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+462
View File
@@ -0,0 +1,462 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp;
use think\facade\Config;
use think\View;
use ywxapp\service\AddonService;
/**
* 插件基类
* Class addon
* @author Ywxapp <ywxapp@qq.com>
* @package think\addon
*/
abstract class AddonBase
{
protected $result;
// 视图实例对象
protected $view = null;
// 当前错误信息
protected $error;
// 插件目录
public $addon_path = '';
public $addonPath = '';
// 插件标识
protected $addonName = '';
// 插件配置作用域
protected $configRange = 'addonconfig';
// 插件信息作用域
protected $infoRange = 'addoninfo';
/**
* 插件菜单
*/
protected $AddonMenu = [
'frontend' => [[
'name' => 'mydemo', //权限规则标识,首个菜单标识必须和插件标识相同
'title' => 'Mydemo管理', //菜单标题
'icon' => 'fa fa-map-marker', //菜单按钮,可使用Font-Awesome的图标
'ismenu' => 1, //是否为菜单
'weigh' => 1, //权重,值越大越靠前
'remark' => 'Demo管理描述内容', //菜单描述内容,在列表处显示
'sublist' => [ //子菜单配置,子菜单name必须以 插件标识/ 开始,这里如 mydemo/
["name" => "mydemo/signin/index", "title" => "查看"],
["name" => "mydemo/signin/add", "title" => "添加"],
["name" => "mydemo/signin/edit", "title" => "编辑"],
["name" => "mydemo/signin/del", "title" => "删除"],
["name" => "mydemo/signin/multi", "title" => "批量更新"],
]
]],
'member' => [],
'backend' => [],
];
/**
* 架构函数
* @access public
*/
public function __construct($name = null)
{
$this->result = app()->result;
$name = is_null($name) ? $this->getName() : $name;
$this->addonName = $name;
$this->addonPath = ADDON_PATH . $name . DIRECTORY_SEPARATOR;
$this->addon_path = $this->addonPath;
$config = ['view_path' => $this->addonPath];
$config = array_merge(Config::get('template'), $config);
// 必须创建独立的 View 实例,绝不能复用全局单例 app()->view
// 否则会把全局视图的 view_path 改成 addon/<name>/,污染后续所有页面的模板解析。
$this->view = new View(app());
$this->view->config($config);
if (method_exists($this, 'initialize')) {
$this->initialize();
}
}
/**
* 读取基础配置信息
* @param string $name
* @return array
*/
final public function getInfo($name = '', $force = false)
{
if (empty($name)) {
$name = $this->getName();
}
if (! $force) {
$info = Config::get($name, $this->infoRange);
if ($info) {
return $info;
}
}
$info = [];
$infoFile = $this->addonPath . 'info.php';
if (is_file($infoFile)) {
$info = (array) include $infoFile;
$info['url'] = addon_url($name);
}
Config::set($info, $name, $this->infoRange);
return $info ? $info : [];
}
/**
* 获取插件的配置数组
* @param string $name 可选模块名
* @return array
*/
/**
* 读取插件合并配置(静态、无实例)。
* 供运行时 getConfig 与 AppInit 预热共用,避免在预热阶段为每个启用插件实例化 Addon
* (其构造函数会 new View)带来的每请求开销。
* 合并顺序与历史 getConfig 完全一致:静态 config/<name>.php → 表单 config.php → 后台保存值。
* @param string $name
* @return array
*/
public static function readConfig(string $name): array
{
$addonPath = (defined('ADDON_PATH') ? ADDON_PATH : app()->getRootPath() . 'addon' . DIRECTORY_SEPARATOR)
. $name . DIRECTORY_SEPARATOR;
$config = [];
// ① 静态代码级配置:addon/<name>/config/<name>.php(随插件分发、支持 env() 覆盖)。
// 与表单 config.php 分工:此处放「运行期/部署相关、需在代码中用 env() 取默认」的项
// (如令牌、远程开关、SSL 校验);表单 config.php 放「后台可编辑」的项。
$staticFile = $addonPath . 'config' . DIRECTORY_SEPARATOR . $name . '.php';
if (is_file($staticFile)) {
$staticArr = include $staticFile;
if (is_array($staticArr)) {
$config = array_merge($config, $staticArr);
}
}
// ② 表单配置:addon/<name>/config.php(后台可编辑字段,静态 value 为默认值)
$configFile = $addonPath . 'config.php';
if (is_file($configFile)) {
$configArr = include $configFile;
if (is_array($configArr)) {
foreach ($configArr as $key => $value) {
// 兼容两种 config.php 格式:
// ① 标准表单格式 [['name'=>'x','value'=>'y'], ...]
// ② 关联数组格式(代码级配置)['key'=>'val', ...]
if (is_array($value) && array_key_exists('name', $value) && array_key_exists('value', $value)) {
$config[$value['name']] = $value['value'];
} else {
$config[$key] = $value;
}
}
}
}
// 统一配置源:用后台保存的值覆盖上面的默认值,
// 避免插件运行期只能读到默认值、忽略后台配置保存(修复配置"双入口"不一致)
$saved = AddonService::config($name);
if (!empty($saved) && is_array($saved)) {
$config = array_merge($config, $saved);
}
return $config;
}
final public function getConfig($name = '', $force = false)
{
if (empty($name)) {
$name = $this->getName();
}
if (! $force) {
$config = Config::get($name);
if ($config) {
return $config;
}
}
$config = self::readConfig($name);
Config::set($config, $name);
return $config;
}
/**
* 设置配置数据
* @param $name
* @param array $value
* @return array
*/
final public function setConfig($name = '', $value = [])
{
if (empty($name)) {
$name = $this->getName();
}
$config = $this->getConfig($name);
$config = array_merge($config, $value);
Config::set($config, $name);
return $config;
}
/**
* 设置插件信息数据
* @param $name
* @param array $value
* @return array
*/
final public function setInfo($name = '', $value = [])
{
if (empty($name)) {
$name = $this->getName();
}
$info = $this->getInfo($name);
$info = array_merge($info, $value);
Config::set($info, $name, $this->infoRange);
return $info;
}
/**
* 获取完整配置列表
* @param string $name
* @return array
*/
final public function getFullConfig($name = '')
{
$fullConfigArr = [];
if (empty($name)) {
$name = $this->getName();
}
$configFile = $this->addonPath . 'info.php';
if (is_file($configFile)) {
$fullConfigArr = include $configFile;
}
return $fullConfigArr;
}
/**
* 获取当前模块名
* @return string
*/
final public function getName()
{
if ($this->addonName) {
return $this->addonName;
}
// 插件引导类固定命名为 addon\<标识>\Addon(如 addon\appmall\Addon)。
// 取「类名末段(Addon)之前」的命名空间末段作为插件标识,
// 不要直接取类名末段(会得到 'addon' 导致 addonPath/view_path 全部错乱)。
$data = explode('\\', get_class($this));
array_pop($data);
return strtolower(array_pop($data));
}
/**
* 设置插件标识
* @param $name
*/
final public function setName($name)
{
$this->addonName = $name;
}
/**
* 检查基础配置信息是否完整
* @return bool
*/
final public function checkInfo()
{
$info = $this->getInfo();
$info_check_keys = ['name', 'title', 'intro', 'author', 'version', 'state'];
foreach ($info_check_keys as $value) {
if (! array_key_exists($value, $info)) {
return false;
}
}
return true;
}
/**
* 加载模板和页面输出 可以返回输出内容
* @access public
* @param string $template 模板文件名或者内容
* @param array $vars 模板输出变量
* @param array $replace 替换内容
* @param array $config 模板参数
* @return mixed
* @throws \Exception
*/
public function fetch($template = '', $vars = [], $replace = [], $config = [])
{
if (! is_file($template)) {
$template = '/' . $template;
}
// 关闭模板布局
$this->view->engine->layout(false);
echo $this->view->fetch($template, $vars, $replace, $config);
}
/**
* 渲染内容输出
* @access public
* @param string $content 内容
* @param array $vars 模板输出变量
* @param array $replace 替换内容
* @param array $config 模板参数
* @return mixed
*/
public function display($content, $vars = [], $replace = [], $config = [])
{
// 关闭模板布局
$this->view->engine->layout(false);
echo $this->view->display($content, $vars, $replace, $config);
}
/**
* 渲染内容输出
* @access public
* @param string $content 内容
* @param array $vars 模板输出变量
* @return mixed
*/
public function show($content, $vars = [])
{
// 关闭模板布局
$this->view->engine->layout(false);
echo $this->view->fetch($content, $vars, [], [], true);
}
/**
* 模板变量赋值
* @access protected
* @param mixed $name 要显示的模板变量
* @param mixed $value 变量的值
* @return void
*/
public function assign($name, $value = '')
{
$this->view->assign($name, $value);
}
/**
* 获取当前错误信息
* @return mixed
*/
public function getError()
{
return $this->result->error;
}
/**
* 插件安装方法
*/
abstract public function install();
/**
* 插件卸载方法
*/
abstract public function uninstall();
/**
* 启用插件时回调(可重写,返回 false 表示阻断启用)
* 由 AddonService::enable() 通过 callAddonHook 调用
*/
public function enable()
{
return true;
}
/**
* 禁用插件时回调(可重写,返回 false 表示阻断禁用)
* 由 AddonService::disable() 通过 callAddonHook 调用
*/
public function disable()
{
return true;
}
/**
* 升级插件时回调(可重写)
* 由 AddonService::upgrade() 通过 callAddonHook 调用
* @param string $currentVersion 已安装版本号
*/
public function upgrade($currentVersion = '')
{
return true;
}
/**
* 注册钩子(在 loadAddonRelevant 时可被调用,默认空实现)
* @param mixed $hookService
* @return $this
*/
public function registerHooks($hookService = null)
{
return $this;
}
/**
* 注册路由(默认空实现)
*/
public function registerRoutes()
{
}
/**
* 注册服务(默认空实现)
*/
public function registerServices()
{
}
/**
* 声明本插件依赖的其它插件标识(用于安装前置校验)
* @return array
*/
public function getDependencies(): array
{
return [];
}
/**
* 声明本插件依赖的 PHP 扩展(用于安装前置校验)
* @return array
*/
public function getExtensions(): array
{
return [];
}
/**
* 要求的最低 ThinkPHP 版本
* @return string
*/
public function getThinkVersion(): string
{
return '>=8.0';
}
}
+972
View File
@@ -0,0 +1,972 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
/**
* 插件健康检查命令(合并版)
*
* 融合原 addon:health(人工排查 + --fix 自动修复)与 addon:health-check
* (系统环境 / 缓存系统检查 + JSON/HTML 输出 + 告警 + 报告存档)的能力。
*
* php think addon:health # 系统级 + 全部插件健康检查
* php think addon:health <name> # 单独检查某个插件(含详情)
* php think addon:health --detail # 显示详细信息
* php think addon:health --fix # 尝试自动修复问题(清缓存/修权限)
* php think addon:health --output=json # 输出 JSON(机器消费)
* php think addon:health --output=html # 输出 HTML 报告
* php think addon:health --send-alert # 异常时发送告警
* php think addon:health --webhook=URL # 告警 Webhook 地址
* php think addon:health --email=ADDR # 告警邮箱
* php think addon:health --save-report # 存档检查报告(runtime 保留 30 天)
*/
declare(strict_types=1);
namespace ywxapp\command;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
use think\facade\Log;
use think\facade\Config;
use think\facade\Cache;
use ywxapp\service\AddonPerformanceMonitor;
/**
* AddonHealth 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class AddonHealth extends Command
{
/**
* 配置命令
*/
protected function configure()
{
$this->setName('addon:health')
->addArgument('addon', Argument::OPTIONAL, '插件名称(留空则检查全部插件 + 系统环境)')
->addOption('detail', 'd', Option::VALUE_NONE, '显示详细信息')
->addOption('fix', 'f', Option::VALUE_NONE, '尝试自动修复问题(清缓存 / 修权限)')
->addOption('output', 'o', Option::VALUE_OPTIONAL, '输出格式 (text, json, html)', 'text')
->addOption('send-alert', 'a', Option::VALUE_NONE, '异常时发送告警通知')
->addOption('webhook', 'w', Option::VALUE_OPTIONAL, '告警 Webhook URL')
->addOption('email', 'e', Option::VALUE_OPTIONAL, '告警邮箱')
->addOption('save-report', 's', Option::VALUE_NONE, '保存检查报告')
->setDescription('插件健康检查(含系统环境 / 缓存 / 文件 / 性能 / 依赖 / 告警)');
}
/**
* 执行命令
*
* @param Input $input 输入
* @param Output $output 输出
* @return int 退出码(0 健康 / 1 异常)
*/
protected function execute(Input $input, Output $output): int
{
$addon = $input->getArgument('addon');
$detail = $input->getOption('detail');
$fix = $input->getOption('fix');
$outputFormat = $input->getOption('output') ?: 'text';
$sendAlert = $input->getOption('send-alert');
$webhookUrl = $input->getOption('webhook');
$email = $input->getOption('email');
$saveReport = $input->getOption('save-report');
// 仅文本模式打印标题横幅,避免污染 JSON/HTML 输出(JSON/HTML 模式下保持纯输出)
if ($outputFormat === 'text') {
$output->writeln('<info>=== 插件健康检查 ===</info>');
$output->writeln('');
}
// 构建检查报告(始终含系统环境 + 缓存系统检查)
$report = $this->buildReport($addon, $detail);
// 按格式输出
switch ($outputFormat) {
case 'json':
$this->outputJson($report, $output);
break;
case 'html':
$this->outputHtml($report, $output);
break;
default:
$this->outputText($report, $output);
// 单插件 + --detail 时,补充版本 / 作者 / 依赖等详细信息
if ($addon && $detail) {
$this->outputSingleDetail($addon, $report['addon'][$addon] ?? [], $output);
}
break;
}
// 自动修复
if ($fix) {
$this->runAutoFix($report, $output);
}
// 发送告警
if ($sendAlert && !$report['healthy']) {
$this->sendAlert($report, $webhookUrl, $email, $output);
}
// 保存报告
if ($saveReport) {
$this->saveReport($report, $output);
}
return $report['healthy'] ? 0 : 1;
}
/**
* 构建健康检查报告
*/
private function buildReport(?string $addon, bool $detail): array
{
$report = [
'healthy' => true,
'timestamp' => time(),
'checks' => [],
'issues' => [],
'warnings' => [],
'addon' => []
];
// 1/2. 系统环境 + 缓存系统(始终检查,属全局健康)
$report['checks']['system'] = $this->checkSystemEnvironment();
$report['checks']['cache'] = $this->checkCacheSystem();
if ($addon) {
// 单插件深检
$report['addon'][$addon] = $this->getAddonHealth($addon);
// --detail 时把性能采样并入报告,使 JSON/HTML 输出也包含详情
// version/author/dependencies 等已在 getAddonHealth 返回中,此处补性能)
if ($detail && !empty($report['addon'][$addon]['enabled'])) {
$stats = AddonPerformanceMonitor::getPerformanceStats($addon);
if ($stats['total_calls'] > 0) {
$report['addon'][$addon]['performance'] = $stats;
}
}
} else {
// 全量:补充文件 / 性能 / 依赖 / 状态聚合检查
$report['checks']['files'] = $this->checkAddonFiles();
$report['checks']['performance'] = $this->checkAddonPerformance();
$report['checks']['dependencies'] = $this->checkDependencies();
$report['checks']['status'] = $this->checkaddontatus();
$addon = Config::get('addon', []);
foreach ($addon as $a) {
if (!isset($a['name'])) {
continue;
}
$report['addon'][$a['name']] = $this->getAddonHealth($a['name']);
}
}
// 聚合 checks 中的问题 / 警告
foreach ($report['checks'] as $checkResult) {
if (!$checkResult['healthy']) {
$report['healthy'] = false;
$report['issues'] = array_merge($report['issues'], $checkResult['issues']);
}
if (!empty($checkResult['warnings'])) {
$report['warnings'] = array_merge($report['warnings'], $checkResult['warnings']);
}
}
// 插件个体健康状态影响整体
foreach ($report['addon'] as $health) {
if (!$health['healthy']) {
$report['healthy'] = false;
}
}
return $report;
}
/**
* 获取单个插件健康状态(丰富信息:目录 / 文件 / info / 依赖 / 扩展 / 主类 / 性能)
*/
private function getAddonHealth(string $addon): array
{
$health = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'enabled' => false,
'install_time' => null
];
$addonPath = ADDON_PATH . $addon;
// 检查插件目录
if (!is_dir($addonPath)) {
$health['healthy'] = false;
$health['issues'][] = "插件目录不存在: {$addonPath}";
return $health;
}
// 检查必需文件
$requiredFiles = ['info.php', 'Addon.php'];
foreach ($requiredFiles as $file) {
if (!is_file($addonPath . DIRECTORY_SEPARATOR . $file)) {
$health['healthy'] = false;
$health['issues'][] = "缺少必需文件: {$file}";
}
}
// 检查 info.php
$infoFile = $addonPath . DIRECTORY_SEPARATOR . 'info.php';
if (is_file($infoFile)) {
$info = include $infoFile;
$health['enabled'] = ($info['state'] ?? false) == 1;
$health['install_time'] = $info['install_time'] ?? null;
$health['version'] = $info['version'] ?? null;
$health['author'] = $info['author'] ?? null;
$health['description'] = $info['intro'] ?? null;
// 检查依赖插件
if (isset($info['dependencies']) && !empty($info['dependencies'])) {
$health['dependencies'] = $info['dependencies'];
foreach ($info['dependencies'] as $dep) {
if (!$this->isAddonInstalled($dep)) {
$health['healthy'] = false;
$health['issues'][] = "依赖插件 {$dep} 未安装";
} elseif (!$this->isAddonEnabled($dep)) {
$health['healthy'] = false;
$health['issues'][] = "依赖插件 {$dep} 未启用";
}
}
}
// 检查 PHP 扩展
if (isset($info['extensions']) && !empty($info['extensions'])) {
$health['extensions'] = $info['extensions'];
foreach ($info['extensions'] as $ext) {
if (!extension_loaded($ext)) {
$health['healthy'] = false;
$health['issues'][] = "缺少PHP扩展: {$ext}";
}
}
}
}
// 检查插件主类与必需方法
$addonClass = '\\addon\\' . $addon . '\\Addon';
if (class_exists($addonClass)) {
try {
$instance = app()->make($addonClass);
foreach (['install', 'uninstall'] as $method) {
if (!method_exists($instance, $method)) {
$health['healthy'] = false;
$health['issues'][] = "缺少必需方法: {$method}()";
}
}
} catch (\Exception $e) {
$health['healthy'] = false;
$health['issues'][] = "插件类初始化失败: " . $e->getMessage();
}
}
// 性能检查
if ($health['enabled']) {
$stats = AddonPerformanceMonitor::getPerformanceStats($addon);
if ($stats['total_calls'] > 10) {
if ($stats['avg_execution_time'] > AddonPerformanceMonitor::PERFORMANCE_THRESHOLD) {
$health['warnings'][] = "执行时间过长: " . number_format($stats['avg_execution_time'], 2) . "";
}
if ($stats['avg_memory_usage'] > AddonPerformanceMonitor::MEMORY_THRESHOLD) {
$health['warnings'][] = "内存使用过多: " . number_format($stats['avg_memory_usage'] / 1024 / 1024, 2) . "MB";
}
if ($stats['success_rate'] < 90) {
$health['healthy'] = false;
$health['issues'][] = "成功率过低: " . number_format($stats['success_rate'], 2) . "%";
}
}
}
return $health;
}
/**
* 检查系统环境(PHP 版本 / 必需扩展 / runtime 可写 / 磁盘空间)
*/
private function checkSystemEnvironment(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
// PHP 版本
$phpVersion = PHP_VERSION;
$result['details']['php_version'] = $phpVersion;
if (version_compare($phpVersion, '8.0.0', '<')) {
$result['healthy'] = false;
$result['issues'][] = "PHP版本过低 ({$phpVersion}),要求 >= 8.0.0";
}
// 必需扩展
foreach (['json', 'mbstring', 'curl', 'zip'] as $ext) {
$loaded = extension_loaded($ext);
$result['details'][$ext] = $loaded;
if (!$loaded) {
$result['healthy'] = false;
$result['issues'][] = "缺少PHP扩展: {$ext}";
}
}
// runtime 目录可写
$runtimePath = root_path() . 'runtime';
if (!is_writable($runtimePath)) {
$result['healthy'] = false;
$result['issues'][] = "runtime目录不可写: {$runtimePath}";
}
// 磁盘空间
$freeSpace = disk_free_space(root_path());
$result['details']['disk_free_space'] = $this->formatBytes((int) $freeSpace);
if ($freeSpace < 104857600) { // 小于 100MB
$result['warnings'][] = "磁盘空间不足,剩余: " . $this->formatBytes((int) $freeSpace);
}
return $result;
}
/**
* 检查缓存系统
*/
private function checkCacheSystem(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
try {
$testKey = 'health_check_test_' . time();
$testValue = ['test' => true, 'timestamp' => time()];
Cache::set($testKey, $testValue, 60);
$retrieved = Cache::get($testKey);
if ($retrieved === false) {
$result['healthy'] = false;
$result['issues'][] = "缓存写入/读取失败";
} else {
$result['details']['cache_test'] = 'passed';
}
Cache::delete($testKey);
// 插件缓存命中情况(仅警告,不影响健康)
$addon = Config::get('addon', []);
foreach ($addon as $addon) {
if (!isset($addon['name']) || !($addon['state'] ?? false)) {
continue;
}
if (Cache::get('addon_config_' . $addon['name']) === false) {
$result['warnings'][] = "插件 {$addon['name']} 缓存未命中,可能影响性能";
}
}
} catch (\Exception $e) {
$result['healthy'] = false;
$result['issues'][] = "缓存系统异常: " . $e->getMessage();
}
return $result;
}
/**
* 检查插件文件完整性(全量)
*/
private function checkAddonFiles(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$addon = Config::get('addon', []);
foreach ($addon as $addon) {
if (!isset($addon['name'])) {
continue;
}
$addonPath = ADDON_PATH . $addon['name'];
$result['details'][$addon['name']] = [];
if (!is_dir($addonPath)) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addon['name']} 目录不存在";
continue;
}
foreach (['info.php', 'Addon.php'] as $file) {
$filePath = $addonPath . DIRECTORY_SEPARATOR . $file;
$exists = is_file($filePath);
$result['details'][$addon['name']][$file] = $exists;
if (!$exists) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addon['name']} 缺少必需文件: {$file}";
}
}
// 文件权限(不可读即警告)
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($addonPath),
\RecursiveIteratorIterator::SELF_FIRST
);
$permissionIssues = [];
foreach ($iterator as $item) {
if ($item->isFile() && ($item->getPerms() & 0x0004) === 0) {
$permissionIssues[] = $item->getPathname();
}
}
if (!empty($permissionIssues)) {
$result['warnings'][] = "插件 {$addon['name']} 有文件权限问题";
$result['details'][$addon['name']]['permission_issues'] = count($permissionIssues);
}
}
return $result;
}
/**
* 检查插件性能(全量)
*/
private function checkAddonPerformance(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$problematicaddon = AddonPerformanceMonitor::getProblematicaddon();
if (!empty($problematicaddon)) {
$result['healthy'] = false;
foreach ($problematicaddon as $addon => $info) {
$result['issues'][] = "插件 {$addon} 性能异常: " . implode(', ', $info['issues']);
$result['details'][$addon] = $info['issues'];
}
}
$overview = AddonPerformanceMonitor::getAllPerformanceOverview();
$totalCalls = $totalTime = $totalSuccess = 0;
foreach ($overview as $stats) {
$totalCalls += $stats['total_calls'];
$totalTime += $stats['avg_execution_time'] * $stats['total_calls'];
$totalSuccess += ($stats['success_rate'] / 100) * $stats['total_calls'];
}
if ($totalCalls > 0) {
$avgTime = $totalTime / $totalCalls;
$successRate = ($totalSuccess / $totalCalls) * 100;
$result['details']['total_calls'] = $totalCalls;
$result['details']['avg_execution_time'] = $avgTime;
$result['details']['overall_success_rate'] = $successRate;
if ($avgTime > 1.0) {
$result['warnings'][] = "总体平均执行时间过长: " . number_format($avgTime * 1000, 2) . " ms";
}
if ($successRate < 95) {
$result['healthy'] = false;
$result['issues'][] = "总体成功率过低: " . number_format($successRate, 2) . "%";
}
}
return $result;
}
/**
* 检查依赖关系(全量)
*/
private function checkDependencies(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$addon = Config::get('addon', []);
$installedaddon = [];
foreach ($addon as $addon) {
if (isset($addon['name'])) {
$installedaddon[$addon['name']] = $addon['state'] ?? false;
}
}
foreach ($addon as $addon) {
if (!isset($addon['name'])) {
continue;
}
$addonName = $addon['name'];
$result['details'][$addonName] = [];
$addonClass = '\\addon\\' . $addonName . '\\Addon';
if (!class_exists($addonClass)) {
continue;
}
try {
$instance = app()->make($addonClass);
if (method_exists($instance, 'getDependencies')) {
$dependencies = $instance->getDependencies();
$result['details'][$addonName]['dependencies'] = $dependencies;
foreach ($dependencies as $dep) {
if (!isset($installedaddon[$dep])) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 依赖插件 {$dep} 未安装";
} elseif (!$installedaddon[$dep]) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 依赖插件 {$dep} 未启用";
}
}
}
if (method_exists($instance, 'getExtensions')) {
$extensions = $instance->getExtensions();
$result['details'][$addonName]['extensions'] = $extensions;
foreach ($extensions as $ext) {
if (!extension_loaded($ext)) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 需要PHP扩展: {$ext}";
}
}
}
} catch (\Exception $e) {
$result['warnings'][] = "无法检查插件 {$addonName} 的依赖: " . $e->getMessage();
}
}
return $result;
}
/**
* 检查插件状态(全量)
*/
private function checkaddontatus(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$addon = Config::get('addon', []);
$enabledCount = $disabledCount = $errorCount = 0;
foreach ($addon as $addon) {
if (!isset($addon['name'])) {
continue;
}
$addonName = $addon['name'];
$isEnabled = ($addon['state'] ?? false) == 1;
if ($isEnabled) {
$enabledCount++;
$addonClass = '\\addon\\' . $addonName . '\\Addon';
if (class_exists($addonClass)) {
try {
app()->make($addonClass);
$result['details'][$addonName] = 'healthy';
} catch (\Exception $e) {
$errorCount++;
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 加载失败: " . $e->getMessage();
$result['details'][$addonName] = 'error';
}
} else {
$errorCount++;
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 主类不存在";
$result['details'][$addonName] = 'missing';
}
} else {
$disabledCount++;
$result['details'][$addonName] = 'disabled';
}
}
$result['details']['enabled_count'] = $enabledCount;
$result['details']['disabled_count'] = $disabledCount;
$result['details']['error_count'] = $errorCount;
return $result;
}
/**
* 输出文本格式
*/
private function outputText(array $report, Output $output): void
{
$output->writeln('检查时间: ' . date('Y-m-d H:i:s', $report['timestamp']));
$output->writeln('整体状态: ' . ($report['healthy'] ? '<info>健康</info>' : '<error>异常</error>'));
$output->writeln('');
// 检查项
foreach ($report['checks'] as $checkName => $checkResult) {
$status = $checkResult['healthy'] ? '<info>✓</info>' : '<error>✗</error>';
$output->writeln("{$status} {$this->getCheckName($checkName)}");
foreach ($checkResult['issues'] ?? [] as $issue) {
$output->writeln(" <error>✗</error> {$issue}");
}
foreach ($checkResult['warnings'] ?? [] as $warning) {
$output->writeln(" <comment>⚠</comment> {$warning}");
}
}
// 插件状态
if (!empty($report['addon'])) {
$output->writeln('');
$output->writeln('<comment>插件状态:</comment>');
foreach ($report['addon'] as $addon => $health) {
$status = $health['healthy'] ? '<info>✓</info>' : '<error>✗</error>';
$output->writeln("{$status} {$addon}" .
($health['enabled'] ? ' <comment>(已启用)</comment>' : ' <comment>(已禁用)</comment>'));
foreach ($health['issues'] ?? [] as $issue) {
$output->writeln(" <error>✗</error> {$issue}");
}
foreach ($health['warnings'] ?? [] as $warning) {
$output->writeln(" <comment>⚠</comment> {$warning}");
}
}
}
// 汇总
$output->writeln('');
$output->writeln('<info>=== 检查汇总 ===</info>');
$output->writeln('总问题数: ' . count($report['issues']));
$output->writeln('总警告数: ' . count($report['warnings']));
$output->writeln('插件总数: ' . count($report['addon']));
$output->writeln('健康插件: ' . count(array_filter($report['addon'], fn($a) => $a['healthy'])));
}
/**
* 单插件详细信息(--detail 时补充)
*/
private function outputSingleDetail(string $addon, array $health, Output $output): void
{
if (empty($health)) {
return;
}
$output->writeln('');
$output->writeln('<info>详细信息:</info>');
$output->writeln("版本: " . ($health['version'] ?? '未知'));
$output->writeln("作者: " . ($health['author'] ?? '未知'));
$output->writeln("描述: " . ($health['description'] ?? '无'));
$output->writeln("启用状态: " . ($health['enabled'] ? '已启用' : '已禁用'));
$output->writeln("安装时间: " . (isset($health['install_time']) ? date('Y-m-d H:i:s', $health['install_time']) : '未知'));
if (isset($health['dependencies'])) {
$output->writeln("依赖插件: " . implode(', ', $health['dependencies']));
}
if (isset($health['extensions'])) {
$output->writeln("PHP扩展: " . implode(', ', $health['extensions']));
}
if ($health['enabled']) {
$stats = $health['performance'] ?? AddonPerformanceMonitor::getPerformanceStats($addon);
if (!empty($stats) && $stats['total_calls'] > 0) {
$output->writeln('');
$output->writeln('<info>性能状态:</info>');
$output->writeln(" 总调用次数: {$stats['total_calls']}");
$output->writeln(" 平均执行时间: " . number_format($stats['avg_execution_time'] * 1000, 2) . " ms");
$output->writeln(" 成功率: " . number_format($stats['success_rate'], 2) . "%");
if ($stats['avg_execution_time'] > AddonPerformanceMonitor::PERFORMANCE_THRESHOLD) {
$output->writeln(' <error>⚠ 执行时间过长</error>');
}
if ($stats['success_rate'] < 90) {
$output->writeln(' <error>⚠ 成功率过低</error>');
}
}
}
}
/**
* 输出 JSON 格式
*/
private function outputJson(array $report, Output $output): void
{
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* 输出 HTML 格式
*/
private function outputHtml(array $report, Output $output): void
{
$html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>插件健康检查报告</title>';
$html .= '<style>body{font-family:Arial,sans-serif;margin:20px;}';
$html .= '.healthy{color:#5fb878;}.error{color:#ff5722;}.warning{color:#ffb800;}';
$html .= '.section{margin:20px 0;padding:15px;background:#f9f9f9;border-radius:4px;}';
$html .= '.check{margin:10px 0;padding:10px;background:#fff;border-radius:4px;}';
$html .= '</style></head><body>';
$html .= '<h1>插件健康检查报告</h1>';
$html .= '<p>检查时间: ' . date('Y-m-d H:i:s', $report['timestamp']) . '</p>';
$html .= '<p>整体状态: <span class="' . ($report['healthy'] ? 'healthy' : 'error') . '">' . ($report['healthy'] ? '健康' : '异常') . '</span></p>';
foreach ($report['checks'] as $checkName => $checkResult) {
$html .= '<div class="section">';
$html .= '<h2>' . $this->getCheckName($checkName) . '</h2>';
foreach ($checkResult['issues'] ?? [] as $issue) {
$html .= '<div class="check error">✗ ' . htmlspecialchars($issue) . '</div>';
}
foreach ($checkResult['warnings'] ?? [] as $warning) {
$html .= '<div class="check warning">⚠ ' . htmlspecialchars($warning) . '</div>';
}
$html .= '</div>';
}
$html .= '</body></html>';
echo $html;
}
/**
* 获取检查项中文名
*/
private function getCheckName(string $checkKey): string
{
$names = [
'system' => '系统环境检查',
'cache' => '缓存系统检查',
'files' => '插件文件检查',
'performance' => '插件性能检查',
'dependencies' => '依赖关系检查',
'status' => '插件状态检查'
];
return $names[$checkKey] ?? $checkKey;
}
/**
* 尝试自动修复所有问题(清缓存 / 修权限)
*/
private function runAutoFix(array $report, Output $output): void
{
// 遍历插件问题(缓存测试键已由 checkCacheSystem 自身清理,无需此处重复)
foreach ($report['addon'] as $addon => $health) {
$this->tryAutoFix($addon, $health['issues'] ?? [], $output);
}
}
/**
* 尝试自动修复单个插件问题
*/
private function tryAutoFix(string $addon, array $issues, Output $output): void
{
$fixedCount = 0;
foreach ($issues as $issue) {
// 缓存类问题
if (strpos($issue, '缓存') !== false) {
try {
Cache::delete('addon_config_' . $addon);
Cache::delete('addon_config_version_' . $addon);
$output->writeln("<info> ✓ 已清除插件 {$addon} 缓存</info>");
$fixedCount++;
} catch (\Exception $e) {
$output->writeln("<comment> ⚠ 清除缓存失败: {$e->getMessage()}</comment>");
}
}
// 权限类问题(含 runtime 不可写)
if (strpos($issue, '权限') !== false || strpos($issue, 'runtime') !== false) {
try {
if (strpos($issue, 'runtime') !== false) {
$this->fixPermissions(root_path() . 'runtime');
}
$this->fixPermissions(ADDON_PATH . $addon);
$output->writeln("<info> ✓ 已修复文件权限 ({$addon})</info>");
$fixedCount++;
} catch (\Exception $e) {
$output->writeln("<comment> ⚠ 修复权限失败: {$e->getMessage()}</comment>");
}
}
}
if ($fixedCount > 0) {
$output->writeln("<info>已自动修复 {$fixedCount} 个问题({$addon}</info>");
} else {
$output->writeln("<comment>插件 {$addon} 无可通过 --fix 自动修复的问题</comment>");
}
}
/**
* 修复文件权限(递归 chmod)
*/
private function fixPermissions(string $path): void
{
if (!is_dir($path)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
chmod($item->getPathname(), $item->isDir() ? 0755 : 0644);
}
chmod($path, 0755);
}
/**
* 发送告警
*/
private function sendAlert(array $report, ?string $webhookUrl, ?string $email, Output $output): void
{
$message = "插件系统健康检查发现异常!\n\n";
$message .= "检查时间: " . date('Y-m-d H:i:s', $report['timestamp']) . "\n";
$message .= "总问题数: " . count($report['issues']) . "\n";
$message .= "总警告数: " . count($report['warnings']) . "\n\n";
if (!empty($report['issues'])) {
$message .= "问题列表:\n";
foreach ($report['issues'] as $issue) {
$message .= "- {$issue}\n";
}
$message .= "\n";
}
Log::error('插件健康检查异常', $report);
if ($webhookUrl) {
try {
$this->sendWebhook($webhookUrl, $report);
$output->writeln('<info>已发送Webhook告警</info>');
} catch (\Exception $e) {
$output->writeln('<error>Webhook发送失败: ' . $e->getMessage() . '</error>');
}
}
if ($email) {
try {
$this->sendEmail($email, '插件系统健康检查告警', $message);
$output->writeln('<info>已发送邮件告警</info>');
} catch (\Exception $e) {
$output->writeln('<error>邮件发送失败: ' . $e->getMessage() . '</error>');
}
}
$output->writeln('<error>告警消息: ' . str_replace("\n", " ", $message) . '</error>');
}
/**
* 发送 Webhook
*/
private function sendWebhook(string $url, array $data): void
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
throw new \Exception("Webhook返回错误: HTTP {$httpCode}");
}
}
/**
* 发送邮件
*/
private function sendEmail(string $to, string $subject, string $message): void
{
$headers = "From: noreply@yourdomain.com\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
if (!mail($to, $subject, $message, $headers)) {
throw new \Exception("邮件发送失败");
}
}
/**
* 保存检查报告(runtime/addon_health_reports,保留 30 天)
*/
private function saveReport(array $report, Output $output): void
{
$reportDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon_health_reports';
if (!is_dir($reportDir)) {
mkdir($reportDir, 0755, true);
}
$reportFile = $reportDir . DIRECTORY_SEPARATOR . 'health_report_' . date('YmdHis') . '.json';
file_put_contents($reportFile, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$output->writeln("<info>检查报告已保存: {$reportFile}</info>");
$this->cleanupOldReports($reportDir, 30);
}
/**
* 清理旧报告
*/
private function cleanupOldReports(string $dir, int $days): void
{
$files = glob($dir . DIRECTORY_SEPARATOR . 'health_report_*.json');
$cutoffTime = time() - ($days * 86400);
foreach ($files as $file) {
if (filemtime($file) < $cutoffTime) {
unlink($file);
}
}
}
/**
* 格式化字节
*/
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
/**
* 检查插件是否已安装
*/
private function isAddonInstalled(string $addon): bool
{
return is_dir(ADDON_PATH . $addon) && is_file(ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php');
}
/**
* 检查插件是否已启用
*/
private function isAddonEnabled(string $addon): bool
{
$infoFile = ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php';
if (!is_file($infoFile)) {
return false;
}
$info = include $infoFile;
return isset($info['state']) && $info['state'] == 1;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use ywxapp\service\AddonService;
/**
* 运行期授权巡检命令(Discuz 式:已启用付费插件定期复查授权,过期自动禁用)。
* 用法:php think addon:license-check
*/
class AddonLicenseCheck extends Command
{
protected $name = 'addon:license-check';
protected function configure()
{
$this->setName($this->name)
->setDescription('巡检已启用付费插件的授权状态,过期自动禁用');
}
protected function execute(Input $input, Output $output)
{
$disabled = AddonService::checkLicenses();
if (empty($disabled)) {
$output->info('授权巡检完成:未发现需要禁用的插件。');
return 0;
}
$output->warning('以下插件授权未通过,已自动禁用:');
foreach ($disabled as $name) {
$output->writeln(' - ' . $name);
}
return 0;
}
}
+435
View File
@@ -0,0 +1,435 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\command;
use think\console\command\Make;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
/**
* Addon 统一脚手架 / 生成器命令
*
* 合并原 addon:build / addon:clear / addon:controller / addon:model /
* addon:middleware / addon:validate / addon:event / addon:listener /
* addon:service / addon:subscribe 十条命令为单入口:
* php think addon:make <插件名> -a <action> [--name=<类>] [动作选项]
*
* @author ywxapp <admin@ywxapp.cn>
*/
class AddonMake extends Make
{
/**
* 当前生成动作对应的类类型(首字母大写),供 getStub/buildClass 使用
* @var string
*/
protected $currentType = '';
/**
* 当前生成动作对应的 stub 关键字
* @var string
*/
protected $currentStub = '';
/**
* 应用基础目录
* @var string
*/
protected $basePath;
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->setName('addon:make')
->addArgument('addon', Argument::OPTIONAL, 'addon name, e.g. demo (required for build & generators)')
->addOption('action', 'a', Option::VALUE_REQUIRED, 'build|clear|controller|model|middleware|validate|event|listener|service|subscribe')
->addOption('name', 'n', Option::VALUE_OPTIONAL, 'class name for generator actions, e.g. User')
->addOption('api', null, Option::VALUE_NONE, 'controller: generate an api controller class.')
->addOption('plain', null, Option::VALUE_NONE, 'controller: generate an empty controller class.')
->addOption('cache', 'c', Option::VALUE_NONE, 'clear: clear cache file')
->addOption('log', 'l', Option::VALUE_NONE, 'clear: clear log file')
->addOption('dir', 'r', Option::VALUE_NONE, 'clear: clear empty dir')
->setDescription('Unified addon scaffold & class generator (build / clear / controller / model / middleware / validate / event / listener / service / subscribe)');
}
protected function execute(Input $input, Output $output): int
{
$addon = trim((string) $input->getArgument('addon'));
$action = strtolower(trim((string) $input->getOption('action')));
if ($action === '') {
$output->writeln('<error>Please specify an action via -a/--action, e.g. -a build, -a controller.</error>');
return 1;
}
// clear 允许插件名为空(清理根 runtime)
if ($action === 'clear') {
return $this->doClear($addon, $input, $output);
}
// build 与生成器均要求合法的插件名
if ($addon === '' || ! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $addon)) {
$output->writeln('<error>Please provide a valid addon name, e.g. php think addon demo -a ' . $action . '</error>');
return 1;
}
switch ($action) {
case 'build':
return $this->doBuild($addon, $output);
case 'controller':
case 'model':
case 'middleware':
case 'validate':
case 'event':
case 'listener':
case 'service':
case 'subscribe':
return $this->doGenerate($action, $addon, $input, $output);
default:
$output->writeln('<error>Unknown action: ' . $action . '</error>');
return 1;
}
}
/* ----------------------------- 生成器动作 ----------------------------- */
/**
* 生成各类插件类文件(controller/model/middleware/validate/event/listener/service/subscribe
*
* @return int
*/
protected function doGenerate(string $action, string $addon, Input $input, Output $output): int
{
$this->currentType = ucfirst($action);
$this->currentStub = $action;
$class = trim((string) ($input->getOption('name') ?: ''));
if ($class === '' || ! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $class)) {
$output->writeln('<error>Please provide a valid class name via --name, e.g. --name=User</error>');
return 1;
}
$name = 'addon' . '\\' . $addon . '\\' . $action . '\\' . $class;
$pathname = $this->app->getRootPath() . ltrim(str_replace('\\', '/', $name), '/') . '.php';
if (is_file($pathname)) {
$output->writeln('<error>' . $this->currentType . ':' . $name . ' already exists!</error>');
return 1;
}
if (! is_dir(dirname($pathname))) {
mkdir(dirname($pathname), 0755, true);
}
file_put_contents($pathname, $this->buildClass($name));
$output->writeln('<info>' . $this->currentType . ':' . $name . ' created successfully.</info>');
return 0;
}
/**
* 根据 stub 构建类内容(覆盖 Make::buildClass 以使用插件命名空间)
*
* @param string $name 完整类名 addon\<addon>\<type>\<Class>
* @return string
*/
protected function buildClass(string $name): string
{
$stub = file_get_contents($this->getStub());
$namespace = trim(implode('\\', array_slice(explode('\\', $name), 0, -1)), '\\');
$class = str_replace($namespace . '\\', '', $name);
return str_replace(
['{%className%}', '{%actionSuffix%}', '{%namespace%}', '{%app_namespace%}'],
[
$class,
$this->app->config->get('route.action_suffix'),
$namespace,
$this->app->getNamespace(),
],
$stub
);
}
/**
* 根据当前动作与 --api/--plain 选项返回 stub 路径
*
* @return string
*/
protected function getStub(): string
{
$stubPath = __DIR__ . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR;
if ($this->currentStub === 'controller') {
if ($this->input->getOption('api')) {
return $stubPath . 'controller.api.stub';
}
if ($this->input->getOption('plain')) {
return $stubPath . 'controller.plain.stub';
}
return $stubPath . 'controller.stub';
}
return $stubPath . $this->currentStub . '.stub';
}
/* ------------------------------- build -------------------------------- */
/**
* 脚手架生成插件骨架
*
* @return int
*/
protected function doBuild(string $app, Output $output): int
{
$this->basePath = $this->app->getRootPath() . 'addon' . DIRECTORY_SEPARATOR;
if (is_dir($this->basePath . $app)) {
$output->writeln("<comment>Addon '{$app}' already exists, ensuring skeleton only.</comment>");
}
$this->buildAddon($app, []);
$output->writeln("<info>Addon '{$app}' scaffold ready at addon/{$app}/</info>");
return 0;
}
/**
* 创建应用
*
* @param string $app 应用名
* @param array $list 目录结构
* @return void
*/
protected function buildAddon(string $app, array $list = []): void
{
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
$namespace = 'addon' . ($app ? '\\' . $app : '');
if (! is_dir($appPath)) {
mkdir($appPath);
}
$this->buildAddonFile($app, $namespace);
$this->buildInfoFile($app);
$this->buildCommonFile($app);
$this->buildConfigFile($app);
if (! is_dir($appPath . 'controller')) {
mkdir($appPath . 'controller');
mkdir($appPath . 'controller/backend');
mkdir($appPath . 'controller/member');
}
$this->buildIndexController($app, $namespace);
if (! is_dir($appPath . 'lang')) {
mkdir($appPath . 'lang');
}
if (! is_dir($appPath . 'model')) {
mkdir($appPath . 'model');
}
if (! is_dir($appPath . 'view')) {
mkdir($appPath . 'view');
mkdir($appPath . 'view/frontend');
mkdir($appPath . 'view/backend');
mkdir($appPath . 'view/member');
}
if (! is_dir($appPath . 'route')) {
mkdir($appPath . 'route');
}
$this->buildRouteFile($app, $namespace);
$this->buildMenuJson($app);
$appPublicPath = public_path() . 'static' . DIRECTORY_SEPARATOR . ($app ? $app . DIRECTORY_SEPARATOR : '');
if (! is_dir($appPublicPath)) {
mkdir($appPublicPath, 0755, true);
mkdir($appPublicPath . 'img', 0755, true);
mkdir($appPublicPath . 'css', 0755, true);
mkdir($appPublicPath . 'js', 0755, true);
}
}
/**
* 创建插件主类文件
*
* @param string $app 应用名
* @param string $namespace 类库命名空间
* @return void
*/
protected function buildAddonFile(string $app, string $namespace): void
{
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
if (! is_file($appPath . 'Addon.php')) {
$content = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'addon.stub');
$content = str_replace(['{%namespace%}', '{%addon%}', '{%className%}'], [$namespace, $app, 'Addon'], $content);
file_put_contents($appPath . 'Addon.php', $content);
}
}
/**
* 创建插件信息文件
*
* @param string $app 目录
* @return void
*/
protected function buildInfoFile(string $app): void
{
$filename = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '') . 'info.php';
if (! is_file($filename)) {
$content = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'info.stub');
$content = str_replace(['{%app%}'], [$app], $content);
$this->checkDirBuild(dirname($filename));
file_put_contents($filename, $content);
}
}
/**
* 创建菜单 JSON
*
* @param string $app 应用名
* @return void
*/
protected function buildMenuJson(string $app): void
{
$filename = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '') . 'menu.json';
if (! is_file($filename)) {
$menu = ["frontend" => [], "member" => [], "backend" => []];
$content = json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
file_put_contents($filename, $content);
}
}
/**
* 创建公共文件
*
* @param string $app 目录
* @return void
*/
public function buildCommonFile(string $app): void
{
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
if (! is_file($appPath . 'common.php')) {
file_put_contents($appPath . 'common.php', "<?php" . PHP_EOL . "// 这是系统自动生成的公共文件" . PHP_EOL);
}
}
/**
* 创建插件配置文件(后台「配置」表单数据源)
*
* @param string $app 应用名
* @return void
*/
protected function buildConfigFile(string $app): void
{
$filename = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '') . 'config.php';
if (! is_file($filename)) {
file_put_contents($filename, "<?php" . PHP_EOL
. "// 插件配置项(后台「配置」表单数据源)" . PHP_EOL
. "return [];" . PHP_EOL);
}
}
/**
* 创建首页控制器
*
* @param string $app 应用名
* @param string $namespace 类库命名空间
* @return void
*/
public function buildIndexController(string $app, string $namespace): void
{
$suffix = $this->app->config->get('route.controller_suffix') ? 'Controller' : '';
$filename = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '') . 'controller' . DIRECTORY_SEPARATOR . 'Index' . $suffix . '.php';
if (! is_file($filename)) {
$content = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'controller.stub');
$content = str_replace(['{%className%}', '{%actionSuffix%}', '{%namespace%}', '{%app_namespace%}'], ['Index', $suffix, $namespace . '\\controller', $namespace], $content);
$this->checkDirBuild(dirname($filename));
file_put_contents($filename, $content);
}
}
/**
* 创建路由文件
*
* @param string $app 应用名
* @param string $namespace 类库命名空间
* @return void
*/
public function buildRouteFile(string $app, string $namespace): void
{
$filename = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '') . 'route' . DIRECTORY_SEPARATOR . 'app.php';
if (! is_file($filename)) {
$this->checkDirBuild(dirname($filename));
file_put_contents($filename, "<?php" . PHP_EOL . "use think\\facade\\Route;" . PHP_EOL . PHP_EOL);
}
}
/**
* 创建目录
*
* @param string $dirname 目录名称
* @return void
*/
public function checkDirBuild(string $dirname): void
{
if (! is_dir($dirname)) {
mkdir($dirname, 0755, true);
}
}
/* ------------------------------- clear -------------------------------- */
/**
* 清理 runtime 文件
*
* @return int
*/
protected function doClear(string $app, Input $input, Output $output): int
{
$runtimePath = $this->app->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . ($app ? $app . DIRECTORY_SEPARATOR : '');
if ($input->getOption('cache')) {
$path = $runtimePath . 'cache';
} elseif ($input->getOption('log')) {
$path = $runtimePath . 'log';
} else {
$path = $runtimePath;
}
$rmdir = $input->getOption('dir') ? true : false;
$this->clear(rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, $rmdir);
$output->writeln("<info>Clear Successed</info>");
return 0;
}
/**
* 递归清理目录
*
* @param string $path 目录
* @param bool $rmdir 是否删除空目录
* @return void
*/
protected function clear(string $path, bool $rmdir): void
{
$files = is_dir($path) ? scandir($path) : [];
foreach ($files as $file) {
if ('.' != $file && '..' != $file && is_dir($path . $file)) {
array_map('unlink', glob($path . $file . DIRECTORY_SEPARATOR . '*.*'));
if ($rmdir) {
rmdir($path . $file);
}
} elseif ('.gitignore' != $file && is_file($path . $file)) {
unlink($path . $file);
}
}
}
}
+265
View File
@@ -0,0 +1,265 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\command;
use Exception;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
use think\File;
use ywxapp\exception\AddonException;
use ywxapp\service\AddonService;
use ywxapp\service\AddonHotReload;
/**
* 插件统一管理命令(合并 develop / install / reload
*
* 用法:
* php think addon:manage <插件名> -a develop
* php think addon:manage <插件名> -a install [--local=<zip>] [--force]
* php think addon:manage [<插件名>] -a reload [--force] [--status]
*
* 说明:
* - develop / install / reload 逻辑内联于此;
* - 菜单重载 / 数据库自愈 / 配置补全已由 addon:repair 统一负责(含 --menu 单项),
* 故本命令不再保留 refresh-menu
* - health 逻辑体量大且复用 AddonHealth,故委托 ywxapp\command\AddonHealth 执行(长选项形式传参,规避短选项冲突)。
*/
class AddonManage extends Command
{
/**
* 配置命令
*/
protected function configure()
{
$this->setName('addon:manage')
->addArgument('addon', Argument::OPTIONAL, '插件名(部分动作需要;留空语义见各动作)')
->addOption('action', 'a', Option::VALUE_REQUIRED, '动作: develop|install|reload')
// install
->addOption('local', 'l', Option::VALUE_REQUIRED, '离线 zip 路径(install 离线安装)')
->addOption('force', 'f', Option::VALUE_NONE, '强制(install 在线强制覆盖 / reload 强制重载)')
// reload
->addOption('status', 's', Option::VALUE_NONE, '查看重载状态(reload')
->setDescription('插件统一管理:开发安装 / 安装 / 重导菜单 / 热重载');
}
/**
* 执行命令:按 -a 分发
*/
protected function execute(Input $input, Output $output): int
{
$action = strtolower(trim((string) $input->getOption('action')));
if ($action === '') {
$output->writeln('<error>请通过 -a/--action 指定动作:develop|install|reload</error>');
return 1;
}
switch ($action) {
case 'develop':
return $this->doDevelop($input, $output);
case 'install':
return $this->doInstall($input, $output);
case 'reload':
return $this->doReload($input, $output);
default:
$output->writeln('<error>未知动作: ' . $action . '(支持 develop|install|reload;菜单重载请用 addon:repair --menu</error>');
return 1;
}
}
/**
* 开发者模式安装(源码已在 addon/<name>,原地建表/注入菜单/启用,免打包)
*/
private function doDevelop(Input $input, Output $output): int
{
$name = trim((string) $input->getArgument('addon'));
if ($name === '') {
$output->writeln('<error>请指定插件名,例如:php think addon:manage demo -a develop</error>');
return 1;
}
try {
$info = AddonService::instance($name)->developInstall();
$version = $info['version'] ?? '';
$output->writeln("<info>开发模式安装成功:{$name} v{$version}</info>");
$output->writeln('<info>已建表、注入菜单并启用,可直接测试。</info>');
} catch (AddonException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
} catch (\Exception $e) {
$output->writeln('<error>开发模式安装失败:' . $e->getMessage() . '</error>');
return 1;
}
return 0;
}
/**
* 安装插件:在线下载安装(默认)或离线 zip 安装
*/
private function doInstall(Input $input, Output $output): int
{
$name = trim((string) $input->getArgument('addon'));
if ($name === '' || !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
$output->writeln('<error>请提供合法的插件名,例如:php think addon:manage demo -a install</error>');
return 1;
}
$extend = [
'install_user' => 0,
'install_ip' => 'cli',
'install_time' => time(),
];
try {
$localOpt = (string) $input->getOption('local');
if ($localOpt !== '') {
$local = realpath($localOpt);
if ($local === false || !is_file($local)) {
$output->writeln("<error>Local zip not found: {$localOpt}</error>");
return 1;
}
$output->writeln("<comment>Offline install from '{$local}' (addon='{$name}')...</comment>");
$file = new File($local);
AddonService::instance($name)->local($file, $extend);
$output->writeln("<info>Addon '{$name}' offline-installed successfully.</info>");
return 0;
}
$force = (bool) $input->getOption('force');
$output->writeln("<comment>Online install '{$name}' (force=" . ($force ? 'yes' : 'no') . ")...</comment>");
AddonService::instance($name)->install($force, $extend);
$output->writeln("<info>Addon '{$name}' online-installed successfully.</info>");
return 0;
} catch (AddonException $e) {
$output->writeln('<error>Install failed: ' . $e->getMessage() . '</error>');
return 1;
} catch (Exception $e) {
$output->writeln('<error>Install failed: ' . $e->getMessage() . '</error>');
return 1;
}
}
/**
* 插件热重载(需 app_debug
*/
private function doReload(Input $input, Output $output): int
{
if (!config('app.app_debug')) {
$output->error('热重载功能仅在开发环境可用');
return 1;
}
$addon = trim((string) $input->getArgument('addon'));
$force = (bool) $input->getOption('force');
$showStatus = (bool) $input->getOption('status');
if ($showStatus) {
return $this->reloadStatus($addon, $output);
}
if ($addon) {
return $this->reloadSingle($addon, $force, $output);
}
return $this->reloadAll($output);
}
private function reloadSingle(string $addon, bool $force, Output $output): int
{
$output->writeln("<info>正在重载插件: {$addon}</info>");
try {
$reloaded = AddonHotReload::reloadAddon($addon, $force);
if ($reloaded) {
$output->writeln("<info>插件 {$addon} 重载成功</info>");
$status = AddonHotReload::getReloadStatus($addon);
if (isset($status['files_changed'])) {
$output->writeln('变更文件: ' . implode(', ', $status['files_changed']));
}
} else {
$output->writeln("<comment>插件 {$addon} 无文件变更,无需重载</comment>");
}
return 0;
} catch (\Exception $e) {
$output->error('重载失败: ' . $e->getMessage());
return 1;
}
}
private function reloadAll(Output $output): int
{
$output->writeln('<info>正在重载所有已启用插件...</info>');
try {
$results = AddonHotReload::reloadAlladdon();
$successCount = 0;
$reloadedCount = 0;
$failedCount = 0;
foreach ($results as $addon => $result) {
if ($result['success']) {
$successCount++;
if ($result['reloaded']) {
$reloadedCount++;
$output->writeln("<info>✓ {$addon} - 已重载</info>");
} else {
$output->writeln("<comment>- {$addon} - 无变更</comment>");
}
} else {
$failedCount++;
$output->writeln("<error>✗ {$addon} - {$result['error']}</error>");
}
}
$output->writeln('');
$output->writeln('<info>重载完成:</info>');
$output->writeln(" 成功: {$successCount}");
$output->writeln(" 实际重载: {$reloadedCount}");
$output->writeln(" 失败: {$failedCount}");
return $failedCount > 0 ? 1 : 0;
} catch (\Exception $e) {
$output->error('重载失败: ' . $e->getMessage());
return 1;
}
}
private function reloadStatus(string $addon, Output $output): int
{
if ($addon) {
$status = AddonHotReload::getReloadStatus($addon);
$output->writeln("<info>插件 {$addon} 重载状态:</info>");
$output->writeln(' 状态: ' . ($status['status'] ?? 'unknown'));
if (isset($status['reload_time'])) {
$output->writeln(' 最后重载时间: ' . date('Y-m-d H:i:s', $status['reload_time']));
}
if (isset($status['error'])) {
$output->writeln(' 错误信息: ' . $status['error']);
}
if (isset($status['files_changed'])) {
$output->writeln(' 变更文件: ' . implode(', ', $status['files_changed']));
}
} else {
$stats = AddonHotReload::getReloadStatistics();
$output->writeln('<info>插件热重载统计:</info>');
$output->writeln(' 总重载次数: ' . ($stats['total_reloads'] ?? 0));
$output->writeln(' 成功次数: ' . ($stats['successful_reloads'] ?? 0));
$output->writeln(' 失败次数: ' . ($stats['failed_reloads'] ?? 0));
if (!empty($stats['addon'])) {
$output->writeln('');
$output->writeln('<info>各插件重载状态:</info>');
foreach ($stats['addon'] as $name => $status) {
$icon = ($status['status'] ?? '') === 'success' ? '✓' : '✗';
$color = ($status['status'] ?? '') === 'success' ? 'info' : 'error';
$output->writeln("<{$color}>{$icon} {$name}</{$color}>");
if (isset($status['reload_time'])) {
$output->writeln(' 最后重载: ' . date('Y-m-d H:i:s', $status['reload_time']));
}
}
}
}
return 0;
}
}
+322
View File
@@ -0,0 +1,322 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\command;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
use think\facade\Db;
use think\facade\Config;
use think\facade\Log;
use ywxapp\service\AddonService;
use ywxapp\model\BaseModel;
/**
* 插件一键修复命令("插件坏什么修什么")
*
* 整合以下自愈能力,使一个出问题的插件恢复到「菜单正确 + 表结构完整 + 配置齐全」的状态:
* 1. 重载菜单:refreshMenu()(清旧 admin_power/user_rule + 重建,顺带修复菜单路由前缀)
* 2. 数据库自愈:解析 install.sql 中该插件全部 CREATE TABLE,缺表建表、缺列补列
* 3. 配置行补全:确保 addon_config 表存在该插件配置行(无则按默认 info 写入)
*
* 用法:
* php think addon:repair <插件名> # 修复指定插件(菜单 + 表 + 配置)
* php think addon:repair <插件名> --menu # 仅重载菜单
* php think addon:repair <插件名> --db # 仅自愈数据库表/列
* php think addon:repair <插件名> --config # 仅补全配置行
* php think addon:repair all # 逐个修复全部已安装插件
*
* php think addon:repair appmall --menu
*/
class AddonRepair extends Command
{
protected function configure()
{
$this->setName('addon:repair')
->addArgument('addon', Argument::REQUIRED, '插件名(或 all 修复全部)')
->addOption('menu', 'm', Option::VALUE_NONE, '仅重载菜单')
->addOption('db', 'b', Option::VALUE_NONE, '仅自愈数据库表/列')
->addOption('config', 'c', Option::VALUE_NONE, '仅补全配置行')
->addOption('force', 'f', Option::VALUE_NONE, '执行破坏性修复(如重命名无前缀孤儿表)')
->setDescription('插件一键修复:重载菜单 / 自愈表结构 / 补全配置');
}
protected function execute(Input $input, Output $output): int
{
$name = strtolower(trim((string) $input->getArgument('addon')));
// 指定了单一 --xxx 则只跑对应步骤;否则全跑
$onlyMenu = (bool) $input->getOption('menu');
$onlyDb = (bool) $input->getOption('db');
$onlyConfig = (bool) $input->getOption('config');
$all = !$onlyMenu && !$onlyDb && !$onlyConfig;
if ($name === 'all') {
$names = $this->installedaddon();
if (empty($names)) {
$output->writeln('<comment>没有已安装的插件。</comment>');
return 0;
}
$output->writeln('<info>开始逐个修复 ' . count($names) . ' 个插件...</info>');
$failed = [];
foreach ($names as $n) {
$output->writeln("\n========== 修复插件: {$n} ==========");
$code = $this->repairOne($n, $all, $onlyMenu, $onlyDb, $onlyConfig, $output);
if ($code !== 0) {
$failed[] = $n;
}
}
$output->writeln("\n=== 修复汇总 ===");
$output->writeln('总数: ' . count($names) . ' 失败: ' . count($failed));
if ($failed) {
$output->writeln('<error>失败: ' . implode(', ', $failed) . '</error>');
return 1;
}
return 0;
}
return $this->repairOne($name, $all, $onlyMenu, $onlyDb, $onlyConfig, $output);
}
private function repairOne(string $name, bool $all, bool $onlyMenu, bool $onlyDb, bool $onlyConfig, Output $output): int
{
try {
$svc = AddonService::instance($name);
} catch (\Throwable $e) {
$output->writeln('<error>初始化插件失败: ' . $e->getMessage() . '</error>');
return 1;
}
$ok = true;
if ($all || $onlyMenu) {
try {
$svc->refreshMenu();
$output->writeln('<info>[菜单] 已重载(admin_power/user_rule 已重建)</info>');
} catch (\Throwable $e) {
$output->writeln('<error>[菜单] 重载失败: ' . $e->getMessage() . '</error>');
$ok = false;
}
}
if ($all || $onlyDb) {
$ok = $this->repairDb($svc, $name, $output) && $ok;
}
if ($all || $onlyConfig) {
$ok = $this->repairConfig($svc, $name, $output) && $ok;
}
if ($ok) {
$output->writeln("<info>[完成] 插件 {$name} 修复成功</info>");
} else {
$output->writeln("<error>[完成] 插件 {$name} 修复存在失败项</error>");
}
return $ok ? 0 : 1;
}
/**
* 数据库自愈:解析 install.sql 的 CREATE TABLE,缺表建表,并尽量补列。
*/
private function repairDb(AddonService $svc, string $name, Output $output): bool
{
$addonDir = $this->addonDir($name);
$installSql = $addonDir . 'install.sql';
if (!is_file($installSql)) {
$output->writeln('<comment>[数据库] 无 install.sql,跳过</comment>');
return true;
}
$content = file_get_contents($installSql);
if ($content === false || $content === '') {
$output->writeln('<comment>[数据库] install.sql 为空,跳过</comment>');
return true;
}
// 必须从 Config 取前缀(与 AddonService::importsql 一致),CLI 下 Db::getConfig 可能为空
$default = Config::get('database.default');
$prefix = Config::get("database.connections.{$default}.prefix");
if ($prefix === '' || $prefix === null) {
// 兜底:框架默认前缀,避免建出无前缀的孤儿表
$prefix = 'wxapp_';
$output->writeln('<comment>[数据库] 未检测到库前缀,按默认 wxapp_ 处理</comment>');
}
// 还原框架在 importsql 时做的 __PREFIX__ 替换,得到运行时完整建表语句
$content = str_ireplace('__PREFIX__', $prefix, $content);
// 去掉注释行,避免误执行
$content = preg_replace('/--.*$/m', '', $content);
// 按语句拆分(以分号结尾)
$statements = array_filter(array_map('trim', explode(';', $content)));
$created = 0;
$exists = 0;
$failed = 0;
foreach ($statements as $stmt) {
if (!preg_match('/^\s*CREATE\s+TABLE/i', $stmt)) {
continue;
}
// 提取表名(已带前缀)
if (!preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([a-zA-Z0-9_]+)[`"]?/i', $stmt, $m)) {
continue;
}
$table = $m[1];
try {
if (BaseModel::tableExists($table)) {
$exists++;
// 表存在也尝试补列(基于 install.sql 的后续 ALTER/列定义无法可靠解析,
// 这里只保证表存在;列级自愈由业务层 ensureColumn 负责)
continue;
}
Db::execute($stmt);
$created++;
$output->writeln("<info>[数据库] 已建表: {$table}</info>");
} catch (\Throwable $e) {
$failed++;
$output->writeln('<error>[数据库] 建表失败 ' . $table . ': ' . $e->getMessage() . '</error>');
Log::error("[AddonRepair] create table {$table} failed: " . $e->getMessage());
}
}
$output->writeln("[数据库] 已建表 {$created} / 已存在 {$exists} / 失败 {$failed}");
// 孤儿无前缀表检测与修复:
// 某些插件经 importsql 在前缀未就绪时被建为无前缀表(如 appmarket_addon_submissions),
// 而运行时 Db::name() 拼出的表名带前缀,导致业务读不到数据。这里扫描并提示/修复。
$orphans = $this->detectOrphanTables($name, $prefix);
if (!empty($orphans)) {
$force = (bool) $input->getOption('force');
foreach ($orphans as $plain => $prefixed) {
if ($force) {
try {
if (!BaseModel::tableExists($prefixed)) {
Db::execute("RENAME TABLE `{$plain}` TO `{$prefixed}`");
$output->writeln("<info>[数据库] 已修复孤儿表: {$plain} -> {$prefixed}</info>");
} else {
$output->writeln("<comment>[数据库] 已存在带前缀表,跳过重命名: {$prefixed}(无前缀表 {$plain} 需手动清理)</comment>");
}
} catch (\Throwable $e) {
$output->writeln('<error>[数据库] 重命名失败 ' . $plain . ': ' . $e->getMessage() . '</error>');
$failed++;
}
} else {
$output->writeln("<error>[数据库] 发现无前缀孤儿表: {$plain}(应命名为 {$prefixed},加 --force 自动重命名修复)</error>");
$failed++;
}
}
}
return $failed === 0;
}
/**
* 扫描本插件是否存在「无前缀的孤儿表」:install.sql 里 __PREFIX__xxx 对应的运行时表名带前缀,
* 但库中却存在无前缀同名表(导入时前缀为空导致)。返回 [无前缀表 => 正确带前缀表] 映射。
*/
private function detectOrphanTables(string $name, string $prefix): array
{
$map = [];
$base = defined('ADDON_PATH') ? ADDON_PATH : (root_path() . 'addon' . DIRECTORY_SEPARATOR);
$installSql = $base . $name . DIRECTORY_SEPARATOR . 'install.sql';
if (!is_file($installSql)) {
return $map;
}
$content = preg_replace('/--.*$/m', '', (string) file_get_contents($installSql));
if (!preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?__PREFIX__([a-zA-Z0-9_]+)[`"]?/i', $content, $matches)) {
return $map;
}
foreach (array_unique($matches[1]) as $suffix) {
$plain = $name . '_' . ltrim($suffix, '_');
$prefixed = $prefix . $plain;
if (BaseModel::tableExists($plain) && !BaseModel::tableExists($prefixed)) {
$map[$plain] = $prefixed;
}
}
return $map;
}
/**
* 配置行补全:确保 addon_config 表有该插件配置行。
*/
private function repairConfig(AddonService $svc, string $name, Output $output): bool
{
$default = Config::get('database.default');
$prefix = Config::get("database.connections.{$default}.prefix") ?: '';
$configTable = $prefix . 'addon_config';
try {
if (!BaseModel::tableExists($configTable)) {
$output->writeln('<comment>[配置] addon_config 表不存在,跳过</comment>');
return true;
}
$row = Db::table($configTable)->where('name', $name)->find();
if (!empty($row)) {
$output->writeln('<comment>[配置] 配置行已存在,跳过</comment>');
return true;
}
// 读取插件默认配置(config.php 返回数组)
$configFile = $this->addonDir($name) . 'config.php';
$value = [];
if (is_file($configFile)) {
$cfg = include $configFile;
if (is_array($cfg)) {
$value = $cfg;
}
}
$data = [
'name' => $name,
'value' => json_encode($value, JSON_UNESCAPED_UNICODE),
];
// 兼容不同时间戳字段名(createtime/updatetime 或 create_time/update_time
$cols = Db::query("SHOW COLUMNS FROM `{$configTable}`");
$colNames = array_column($cols, 'Field');
$now = time();
if (in_array('createtime', $colNames, true)) {
$data['createtime'] = $now;
} elseif (in_array('create_time', $colNames, true)) {
$data['create_time'] = $now;
}
if (in_array('updatetime', $colNames, true)) {
$data['updatetime'] = $now;
} elseif (in_array('update_time', $colNames, true)) {
$data['update_time'] = $now;
}
Db::table($configTable)->insert($data);
$output->writeln("<info>[配置] 已补全配置行: {$name}</info>");
return true;
} catch (\Throwable $e) {
$output->writeln('<error>[配置] 补全失败: ' . $e->getMessage() . '</error>');
return false;
}
}
private function addonDir(string $name): string
{
$base = defined('ADDON_PATH') ? ADDON_PATH : (root_path() . 'addon' . DIRECTORY_SEPARATOR);
return $base . $name . DIRECTORY_SEPARATOR;
}
private function installedaddon(): array
{
$base = defined('ADDON_PATH') ? ADDON_PATH : (root_path() . 'addon' . DIRECTORY_SEPARATOR);
if (!is_dir($base)) {
return [];
}
$names = [];
foreach (array_diff(scandir($base), ['.', '..']) as $cand) {
$dir = $base . $cand . DIRECTORY_SEPARATOR;
if (is_dir($dir) && is_file($dir . 'info.php')) {
$names[] = $cand;
}
}
return $names;
}
}
+471
View File
@@ -0,0 +1,471 @@
<?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\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Argument;
use think\console\input\Option;
/**
* AppCommand 应用命令统一入口
*
* 将 ywxapp/command/app/ 目录下的全部命令(app 脚手架 / 生成器)逻辑内联到本类,
* 绑定为单一入口 `php think app <子命令> [参数]`,不再保留独立的命令类文件。
* 插件等其它命令(addon:* 系列)由各自独立注册,不在此处理。
*
* 子命令:
* build 构建应用目录(原 build:app
* clear 清理运行时文件(原 clear)
* controller 生成控制器(原 app:controller,支持 --api/--plain
* model 生成模型(原 app:model
* event 生成事件(原 app:event
* listener 生成监听器(原 make:listener
* middleware 生成中间件(原 make:middleware
* service 生成服务(原 make:service
* subscribe 生成订阅器(原 make:subscribe
* validate 生成验证器(原 make:validate
*
* 用法:
* php think app # 列出所有可用子命令
* php think app controller app\index # 生成控制器
* php think app controller app\index --api # 生成 api 风格控制器
* php think app model app\demo # 生成模型
* php think app clear --cache # 清理缓存
* php think app build myapp # 构建应用目录
*/
class AppCommand extends Command
{
/**
* Make 型子命令配置
* type : 类型标签(用于成功/存在提示)
* stub : stub 文件名(不含 .stub 扩展)
* ns : 命名空间末尾段(如 controller/model/event ...
* suffix : 类名后缀(仅 controller 在开启 controller_suffix 时追加 Controller
*/
private const MAKE_TYPES = [
'controller' => ['type' => 'Controller', 'stub' => 'controller', 'ns' => 'controller', 'suffix' => 'Controller'],
'model' => ['type' => 'Model', 'stub' => 'model', 'ns' => 'model', 'suffix' => ''],
'event' => ['type' => 'Event', 'stub' => 'event', 'ns' => 'event', 'suffix' => ''],
'listener' => ['type' => 'Listener', 'stub' => 'listener', 'ns' => 'listener', 'suffix' => ''],
'middleware' => ['type' => 'Middleware', 'stub' => 'middleware', 'ns' => 'middleware', 'suffix' => ''],
'service' => ['type' => 'Service', 'stub' => 'service', 'ns' => 'service', 'suffix' => ''],
'subscribe' => ['type' => 'Subscribe', 'stub' => 'subscribe', 'ns' => 'subscribe', 'suffix' => ''],
'validate' => ['type' => 'Validate', 'stub' => 'validate', 'ns' => 'validate', 'suffix' => ''],
];
/**
* 应用基础目录(Build 使用)
* @var string
*/
private $basePath = '';
/**
* stub 模板目录(已从 ywxapp/command/app/stubs 上移至此)
*/
private function stubDir(): string
{
return __DIR__ . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR;
}
/**
* 配置命令
*/
protected function configure()
{
$this->setName('app')
->addOption('list', 'l', Option::VALUE_NONE, '列出所有可用子命令')
->addOption('api', null, Option::VALUE_NONE, '生成 api 风格控制器(仅 controller')
->addOption('plain', null, Option::VALUE_NONE, '生成空控制器(仅 controller')
->addOption('cache', 'c', Option::VALUE_NONE, '清理缓存目录(仅 clear')
->addOption('log', null, Option::VALUE_NONE, '清理日志目录(仅 clear')
->addOption('dir', 'r', Option::VALUE_NONE, '清理空目录(仅 clear')
->addArgument('subcommand', Argument::OPTIONAL, '子命令名(build/clear/controller/model/event/listener/middleware/service/subscribe/validate')
->addArgument('name', Argument::OPTIONAL, '类名或 app 名(如 app\\index 或 myapp')
->setDescription('YwxApp 应用命令入口:聚合 ywxapp/command/app 下的命令,php think app <子命令> [参数]');
}
/**
* 执行命令
*/
protected function execute(Input $input, Output $output): int
{
if ($input->getOption('list') || empty($input->getArgument('subcommand'))) {
$this->showList($output);
return 0;
}
$sub = $input->getArgument('subcommand');
$name = (string) $input->getArgument('name');
if (isset(self::MAKE_TYPES[$sub])) {
return $this->runMake($sub, $name, $input, $output);
}
if ($sub === 'build') {
return $this->runBuild($name, $output);
}
if ($sub === 'clear') {
return $this->runClear($name, $input, $output);
}
$output->writeln('<error>未知子命令: ' . $sub . '</error>');
$this->showList($output);
return 1;
}
/**
* 列出所有可用子命令
*/
private function showList(Output $output): void
{
$output->writeln('<info>YwxApp 应用命令(`php think app <子命令> [参数]`):</info>');
$output->writeln('');
$width = max(array_map('strlen', array_keys(self::MAKE_TYPES + ['build' => 1, 'clear' => 1])));
foreach (array_keys(['build' => 1, 'clear' => 1] + self::MAKE_TYPES) as $name) {
$desc = [
'build' => '构建应用目录',
'clear' => '清理运行时文件',
'controller' => '生成控制器(--api/--plain',
'model' => '生成模型',
'event' => '生成事件',
'listener' => '生成监听器',
'middleware' => '生成中间件',
'service' => '生成服务',
'subscribe' => '生成订阅器',
'validate' => '生成验证器',
][$name];
$output->writeln(' ' . str_pad($name, $width) . ' ' . $desc);
}
$output->writeln('');
$output->writeln('示例:php think app controller app\\index');
$output->writeln(' php think app clear --cache');
}
/* ===================== Make 型生成器 ===================== */
/**
* 运行 Make 型子命令
*/
private function runMake(string $type, string $name, Input $input, Output $output): int
{
if ($name === '') {
$output->writeln('<error>缺少类名参数,用法:php think app ' . $type . ' &lt;类名&gt;</error>');
return 1;
}
$cfg = self::MAKE_TYPES[$type];
$classname = $this->makeClassName($type, $name);
$pathname = $this->makePathName($classname);
if (is_file($pathname)) {
$output->writeln('<error>' . $cfg['type'] . ':' . $classname . ' already exists!</error>');
return 1;
}
if (!is_dir(dirname($pathname))) {
mkdir(dirname($pathname), 0755, true);
}
file_put_contents($pathname, $this->buildClass($classname, $type, $input));
$output->writeln('<info>' . $cfg['type'] . ':' . $classname . ' created successfully.</info>');
return 0;
}
/**
* 解析 stub 路径(controller 支持 --api/--plain
*/
private function makeStub(string $type, Input $input): string
{
if ($type === 'controller') {
if ($input->getOption('api')) {
return $this->stubDir() . 'controller.api.stub';
}
if ($input->getOption('plain')) {
return $this->stubDir() . 'controller.plain.stub';
}
return $this->stubDir() . 'controller.stub';
}
return $this->stubDir() . self::MAKE_TYPES[$type]['stub'] . '.stub';
}
/**
* 解析命名空间
*/
private function makeNamespace(string $type, string $app): string
{
return 'app' . ($app ? '\\' . $app : '') . '\\' . self::MAKE_TYPES[$type]['ns'];
}
/**
* 解析完整类名(等价于 think\console\command\Make::getClassName + Controller 后缀)
*/
private function makeClassName(string $type, string $name): string
{
if (str_contains($name, '\\')) {
$class = $name;
} else {
$app = '';
if (str_contains($name, '@')) {
[$app, $name] = explode('@', $name);
}
if (str_contains($name, '/')) {
$name = str_replace('/', '\\', $name);
}
$class = $this->makeNamespace($type, $app) . '\\' . $name;
}
$suffix = self::MAKE_TYPES[$type]['suffix'];
if ($suffix !== '' && $this->app->config->get('route.controller_suffix')) {
$class .= $suffix;
}
return $class;
}
/**
* 解析文件路径(等价于各 Make 命令重写的 getPathName
*/
private function makePathName(string $name): string
{
return $this->app->getRootPath() . ltrim(str_replace('\\', '/', $name), '/') . '.php';
}
/**
* 用 stub 渲染类文件(等价于 think\console\command\Make::buildClass
*/
private function buildClass(string $name, string $type, Input $input): string
{
$stub = file_get_contents($this->makeStub($type, $input));
$namespace = trim(implode('\\', array_slice(explode('\\', $name), 0, -1)), '\\');
$class = str_replace($namespace . '\\', '', $name);
return str_replace(
['{%className%}', '{%actionSuffix%}', '{%namespace%}', '{%app_namespace%}'],
[
$class,
$this->app->config->get('route.action_suffix'),
$namespace,
$this->app->getNamespace(),
],
$stub
);
}
/* ===================== Build 命令 ===================== */
/**
* 运行 build 子命令(原 build:app
*/
private function runBuild(string $app, Output $output): int
{
$this->basePath = $this->app->getRootPath() . 'app' . DIRECTORY_SEPARATOR;
$app = $app ?: '';
if (is_file($this->basePath . 'build.php')) {
$list = include $this->basePath . 'build.php';
} else {
$list = [
'__dir__' => ['controller', 'model', 'view'],
];
}
$this->buildApp($app, $list);
$output->writeln('<info>Successed</info>');
return 0;
}
/**
* 创建应用
*/
private function buildApp(string $app, array $list = []): void
{
if (!is_dir($this->basePath . $app)) {
mkdir($this->basePath . $app);
}
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
$namespace = 'app' . ($app ? '\\' . $app : '');
$this->buildCommon($app);
$this->buildHello($app, $namespace);
$this->buildapp1($app, $namespace);
$this->buildInfo($app);
foreach ($list as $path => $file) {
if ('__dir__' == $path) {
foreach ($file as $dir) {
$this->checkDirBuild($appPath . $dir);
}
} elseif ('__file__' == $path) {
foreach ($file as $fname) {
if (!is_file($appPath . $fname)) {
file_put_contents($appPath . $fname, 'php' == pathinfo($fname, PATHINFO_EXTENSION) ? '<?php' . PHP_EOL : '');
}
}
} else {
foreach ($file as $val) {
$val = trim($val);
$filename = $appPath . $path . DIRECTORY_SEPARATOR . $val . '.php';
$space = $namespace . '\\' . $path;
$class = $val;
switch ($path) {
case 'controller':
if ($this->app->config->get('route.controller_suffix')) {
$filename = $appPath . $path . DIRECTORY_SEPARATOR . $val . 'Controller.php';
$class = $val . 'Controller';
}
$content = '<?php' . PHP_EOL . 'namespace ' . $space . ';' . PHP_EOL . PHP_EOL . 'class ' . $class . PHP_EOL . '{' . PHP_EOL . PHP_EOL . '}';
break;
case 'model':
$content = '<?php' . PHP_EOL . 'namespace ' . $space . ';' . PHP_EOL . PHP_EOL . 'use think\Model;' . PHP_EOL . PHP_EOL . 'class ' . $class . ' extends Model' . PHP_EOL . '{' . PHP_EOL . PHP_EOL . '}';
break;
case 'view':
$filename = $appPath . $path . DIRECTORY_SEPARATOR . $val . '.html';
$this->checkDirBuild(dirname($filename));
$content = '';
break;
default:
$content = '<?php' . PHP_EOL . 'namespace ' . $space . ';' . PHP_EOL . PHP_EOL . 'class ' . $class . PHP_EOL . '{' . PHP_EOL . PHP_EOL . '}';
}
if (!is_file($filename)) {
file_put_contents($filename, $content);
}
}
}
}
}
/**
* 创建应用的欢迎页面
*/
private function buildHello(string $app, string $namespace): void
{
$suffix = $this->app->config->get('route.controller_suffix') ? 'Controller' : '';
$filename = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '') . 'controller' . DIRECTORY_SEPARATOR . 'Index' . $suffix . '.php';
if (!is_file($filename)) {
$content = file_get_contents($this->stubDir() . 'controller.stub');
$content = str_replace(
['{%className%}', '{%actionSuffix%}', '{%namespace%}', '{%app_namespace%}'],
['Index', $suffix, $namespace . '\\controller', $this->app->getNamespace()],
$content
);
$this->checkDirBuild(dirname($filename));
file_put_contents($filename, $content);
}
}
private function buildapp1(string $app, string $namespace): void
{
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
if (!is_file($appPath . 'app.php')) {
$content = file_get_contents($this->stubDir() . 'app.stub');
$content = str_replace(['{%namespace%}', '{%app%}', '{%className%}'], [$namespace, $app, 'app'], $content);
file_put_contents($appPath . 'app.php', $content);
}
}
private function buildInfo(string $app): void
{
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
if (!is_file($appPath . 'info.php')) {
$array = [
'name' => '',
'title' => '',
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.0',
'state' => 1,
'url' => '',
'license' => '',
'licenseto' => 0,
];
$array = array_merge($array, ['name' => $app, 'url' => '/' . $app]);
$output = "<?php\n return " . var_export($array, true) . ";\n?>";
file_put_contents($appPath . 'info.php', $output);
}
}
/**
* 创建应用的公共文件
*/
private function buildCommon(string $app): void
{
$appPath = $this->basePath . ($app ? $app . DIRECTORY_SEPARATOR : '');
foreach (['event', 'middleware', 'common', 'config'] as $nm) {
if (!is_file($appPath . $nm . '.php')) {
file_put_contents($appPath . $nm . '.php', '<?php' . PHP_EOL . '// 这是系统自动生成的' . $nm . '定义文件' . PHP_EOL . 'return [' . PHP_EOL . PHP_EOL . '];' . PHP_EOL);
}
}
}
/**
* 创建目录
*/
private function checkDirBuild(string $dirname): void
{
if (!is_dir($dirname)) {
mkdir($dirname, 0755, true);
}
}
/* ===================== Clear 命令 ===================== */
/**
* 运行 clear 子命令(原 clear
*/
private function runClear(string $app, Input $input, Output $output): int
{
$runtimePath = $this->app->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . ($app ? $app . DIRECTORY_SEPARATOR : '');
if ($input->getOption('cache')) {
$path = $runtimePath . 'cache';
} elseif ($input->getOption('log')) {
$path = $runtimePath . 'log';
} else {
$path = $runtimePath;
}
$rmdir = $input->getOption('dir') ? true : false;
$this->clearRuntime(rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, $rmdir);
$output->writeln('<info>Clear Successed</info>');
return 0;
}
/**
* 递归清理目录
*/
private function clearRuntime(string $path, bool $rmdir): void
{
$files = is_dir($path) ? scandir($path) : [];
foreach ($files as $file) {
if ('.' != $file && '..' != $file && is_dir($path . $file)) {
array_map('unlink', glob($path . $file . DIRECTORY_SEPARATOR . '*.*'));
if ($rmdir) {
rmdir($path . $file);
}
} elseif ('.gitignore' != $file && is_file($path . $file)) {
unlink($path . $file);
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
// app/command/StartWorkerman.php
namespace ywxapp\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use GatewayWorker\Gateway;
use GatewayWorker\BusinessWorker;
use GatewayWorker\Register;
use think\facade\App;
use think\facade\Config;
/**
* GatewayWorker 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class GatewayWorker extends Command
{
protected function configure()
{
$this->setName('workerman:gateway')
->addOption('acthon', 'a', Option::VALUE_OPTIONAL, '操作')
->setDescription('Start the Workerman server');
}
protected function execute(Input $input, Output $output)
{
// register 必须是text协议,切记不能将register端口开放给外网
$register = new Register('text://127.0.0.1:1238');
// bussinessWorker 进程
$worker = new BusinessWorker();
$worker->name = 'ChatBusinessWorker';
$worker->count = 4;
$worker->registerAddress = '127.0.0.1:1238';
$worker->eventHandler = '\\ywxapp\handler\BusinessHandler';
$context = array(
'ssl' => array(
'local_cert' => __DIR__ . '/fullchain.pem', // 也可以是crt文件
'local_pk' => __DIR__ . '/privkey.pem',
'verify_peer' => false,
'allow_self_signed' => true, //如果是自签名证书需要开启此选项
)
);
$gateway = new Gateway("websocket://0.0.0.0:8282", $context);
$gateway->name = 'AppGateway';
$gateway->count = 2;
$gateway->lanIp = '127.0.0.1';
$gateway->startPort = 2900;
$gateway->registerAddress = '127.0.0.1:1238';
//$gateway->onWorkerStart
//$gateway->onWorkerStop
//$gateway->onConnect
//$gateway->onMessage
//$gateway->onClose
$gateway->transport = 'ssl';
// 初始化 ThinkPHP 应用
$app = App::getInstance();
$gateway->statusCallback = function ($status) use ($app) {
// 在这里可以使用 ThinkPHP 容器
$app->make(\ywxapp\services\AppService::class);
// print_r($app->user);
// 你可以在这里调用 ThinkPHP 的服务
// $service = $app->make(\app\service\MyService::class);
// $service->doSomething();
// 处理工作进程状态变化
// 例如:记录日志,发送通知等
file_put_contents('status.log', json_encode($status) . PHP_EOL, FILE_APPEND);
};
// $gateway->pingInterval = 10;
// $gateway->pingNotResponseLimit = 1;
// $gateway->pingData = '{"type":"ping"}';
Gateway::runAll();
// 输出启动信息
$output->writeln("Workerman server started on http://0.0.0.0:2345");
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\command;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\App;
use ywxapp\service\FrameworkService;
use ywxapp\service\AddonService;
/**
* 一键离线升级命令(手动执行升级)
*
* 用于绕过在线下载,直接对本地打包产物执行升级:
* - 框架核心包 / 框架整站包 → 复用 FrameworkService::apply(解压、备份、跑 upgrade.sql、写版本号)
* - 插件包 → 复用 AddonService::local(解压覆盖、导入 install.sql、重导菜单)
*
* 用法:
* # 框架核心补丁包(剥离 ywxapp/ 前缀,解压到 ywxapp/
* php think ywxapp:upgrade --file=runtime/framework/ywxapp-1.0.9.zip --ver=1.0.9 --type=framework --patch
* # 框架整站包(不解前缀,解压到项目根)
* php think ywxapp:upgrade --file=runtime/framework/ywxapp-1.0.9.zip --ver=1.0.9 --type=framework
* # 插件升级(zip 根已是插件内容)
* php think ywxapp:upgrade --file=runtime/market/forum-1.0.1.zip --type=addon
*
* 说明:
* - 框架 --ver 必填(写入 config/ywxapp.php);插件版本从 zip 内 info.php 自动读取。
* - 命令执行前会自动备份(FrameworkService 备份到 runtime/framework/backupAddonService 备份到 runtime/addon)。
*/
class Upgrade extends Command
{
protected function configure()
{
$this->setName('ywxapp:upgrade')
->addOption('file', 'f', Option::VALUE_REQUIRED, '升级包本地路径(zip')
->addOption('type', 't', Option::VALUE_REQUIRED, '类型: framework | addon')
->addOption('ver', 'r', Option::VALUE_OPTIONAL, '目标版本号(framework 必填;addon 自动读取)')
->addOption('patch', 'p', Option::VALUE_NONE, '框架补丁包(核心包,剥离 ywxapp/ 前缀;整站包勿加)')
->setDescription('本地离线升级:框架核心/整站包或插件包一键手动执行');
}
protected function execute(Input $input, Output $output): int
{
$file = trim((string) $input->getOption('file'));
$type = strtolower(trim((string) $input->getOption('type')));
$version = trim((string) $input->getOption('ver'));
$isPatch = (bool) $input->getOption('patch');
if ($file === '' || !is_file($file)) {
$output->writeln('<error>请通过 --file 指定存在的 zip 路径</error>');
return 1;
}
if (!in_array($type, ['framework', 'addon'], true)) {
$output->writeln('<error>--type 必须是 framework 或 addon</error>');
return 1;
}
try {
if ($type === 'framework') {
if ($version === '') {
$output->writeln('<error>framework 升级需通过 --version 指定目标版本号</error>');
return 1;
}
$output->writeln("<info>开始升级框架到 {$version}" . ($isPatch ? '补丁包' : '整站包') . "...</info>");
$svc = App::getInstance()->make(FrameworkService::class);
$res = $svc->apply($file, $version, $isPatch ? 1 : 0);
$output->writeln('<info>框架升级完成:' . ($res['msg'] ?? 'ok') . '</info>');
} else {
$output->writeln("<info>开始升级插件({$file}...</info>");
// 从 zip 内 info.php 解析插件名,用于实例化 AddonService(需先确定 addonDir
$addonName = $this->readAddonNameFromZip($file);
if ($addonName === '') {
$output->writeln('<error>无法从升级包内 info.php 解析插件名</error>');
return 1;
}
// 构造 think\File 对象(real 路径,足够 validate/upload 使用)
$fileObj = new \think\File($file);
$svc = new AddonService($addonName);
$res = $svc->localUpgrade($fileObj);
$output->writeln('<info>插件升级完成:' . $addonName
. ' ' . ($res['from'] ?? '') . ' -> ' . ($res['to'] ?? '') . '</info>');
}
} catch (\Throwable $e) {
$output->writeln('<error>升级失败:' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('<comment>建议随后清理缓存:删除 runtime/cache、runtime/temp</comment>');
return 0;
}
/**
* 从升级包 zip 内 info.php 解析插件名
*
* @param string $zipFile
* @return string
*/
private function readAddonNameFromZip(string $zipFile): string
{
$zip = new \ZipArchive();
if ($zip->open($zipFile) !== true) {
return '';
}
try {
// 寻找包内 info.php(可能位于根目录或 <addon>/info.php
$name = '';
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if (basename($entry) === 'info.php' && substr($entry, -strlen('info.php')) === 'info.php') {
$content = $zip->getFromIndex($i);
if ($content !== false && preg_match("/'name'\s*=>\s*'([^']+)'/", $content, $m)) {
$name = $m[1];
break;
}
}
}
return $name;
} finally {
$zip->close();
}
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use ywxapp\library\Menu;
use ywxapp\AddonBase;
use think\Request;
class {%className%} extends addon
{
/**
* 插件安装方法
* @return bool
*/
public function install()
{
$menu = [];
Menu::create($menu);
return true;
}
/**
* 插件卸载方法
* @return bool
*/
public function uninstall()
{
Menu::delete('{%addon%}');
return true;
}
/**
* 插件启用方法
*/
public function enable()
{
Menu::enable('{%addon%}');
}
/**
* 插件禁用方法
*/
public function disable()
{
Menu::disable('{%addon%}');
}
/**
* 插件升级方法(覆盖升级时的数据/配置迁移)
* @param string $currentVersion 已安装版本号
*/
public function upgrade($currentVersion = '')
{
return true;
}
/**
* 前端菜单方法
*/
public function frontMenu(){}
/**
* 会员菜单方法
*/
public function memberMenu(){}
/**
* 后台菜单方法
*/
public function backMenu(){}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
class {%className%} extends Command
{
protected function configure()
{
// 指令配置
$this->setName('{%commandName%}')
->setDescription('the {%commandName%} command');
}
protected function execute(Input $input, Output $output)
{
// 指令输出
$output->writeln('{%commandName%}');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\Request;
class {%className%} extends \ywxapp\controller\AddonFrontend
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index{%actionSuffix%}()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save{%actionSuffix%}(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read{%actionSuffix%}($id)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update{%actionSuffix%}(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete{%actionSuffix%}($id)
{
//
}
}
@@ -0,0 +1,28 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%} extends \ywxapp\controller\AddonFrontend
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
//
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\Request;
class {%className%} extends \ywxapp\controller\AddonFrontend
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index{%actionSuffix%}()
{
return "这是一个{%app_namespace%} 插件应用";
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create{%actionSuffix%}()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save{%actionSuffix%}(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read{%actionSuffix%}($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit{%actionSuffix%}($id)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update{%actionSuffix%}(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete{%actionSuffix%}($id)
{
//
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
public function __construct()
{
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
//这是{%app%}应用的配置文件
return [
'name' => '{%app%}',
'title' => '',
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.0',
'state' => 1,
'url' => '/{%app%}',
'license' => '',
'licenseto' => 0,
'config' => [],
'events' => [
// 事件绑定,
'bind' => [],
// 事件监听
'listen' => [],
// 事件订阅
'subscribe' => [],
],
'middleware' => [
// 别名 => 中间件类
'alias' => [],
// 中间件优先级,越靠前优先级越高
'priority' => [],
],
'services' => [],
];
+20
View File
@@ -0,0 +1,20 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
/**
* 事件监听处理
*
* @return mixed
*/
public function handle($param)
{
// 这里可以执行一些插件的初始化操作,比如加载配置、注册服务等
// 例如:加载插件的语言包
// \think\facade\Lang::load(addon_path('demo') . 'lang/zh-cn.php');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
/**
* 处理请求
*
* @param \think\Request $request
* @param \Closure $next
* @return Response
*/
public function handle($request, \Closure $next)
{
//
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use ywxapp\model\BaseModel;
/**
* @mixin \think\Model
*/
class {%className%} extends BaseModel
{
protected function getOptions(): array
{
// 所有的参数配置统一返回
return [
'strict' => false,
//'name' => '',
// 'connection' => '',
// 'query' => [],
// 'type' => [],
// 'hidden' => [],
// 'visible' => [],
// 'append' => [],
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%} extends \think\Service
{
/**
* 注册服务
*
* @return mixed
*/
public function register()
{
//
}
/**
* 执行服务
*
* @return mixed
*/
public function boot()
{
//
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\Validate;
class {%className%} extends Validate
{
/**
* 定义验证规则
* 格式:'字段名' => ['规则1','规则2'...]
*
* @var array
*/
protected $rule = [];
/**
* 定义错误信息
* 格式:'字段名.规则名' => '错误信息'
*
* @var array
*/
protected $message = [];
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use ywxapp\library\Menu;
use ywxapp\app\app;
use think\Request;
class {%className%} extends app
{
/**
* 插件安装方法
* @return bool
*/
public function install()
{
$menu = [];
Menu::create($menu);
return true;
}
/**
* 插件卸载方法
* @return bool
*/
public function uninstall()
{
Menu::delete('{%app%}');
return true;
}
/**
* 插件启用方法
*/
public function enable()
{
Menu::enable('{%app%}');
}
/**
* 插件禁用方法
*/
public function disable()
{
Menu::disable('{%app%}');
}
/**
* 前端菜单方法
*/
abstract public function frontMenu();
/**
* 会员菜单方法
*/
abstract public function memberMenu();
/**
* 后台菜单方法
*/
abstract public function backMenu();
}
+27
View File
@@ -0,0 +1,27 @@
<?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 {%namespace%};
/**
* 应用模块引导类
*
* 由 `php think app build` 自动生成(应用标识:{%app%})。
* 可作为该应用模块的入口 / 服务提供者,在此注册自定义服务、事件、中间件等。
*/
class {%className%}
{
/**
* 应用模块标识
* @var string
*/
public string $name = '{%app%}';
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
class {%className%} extends Command
{
protected function configure()
{
// 指令配置
$this->setName('{%commandName%}')
->setDescription('the {%commandName%} command');
}
protected function execute(Input $input, Output $output)
{
// 指令输出
$output->writeln('{%commandName%}');
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\Request;
use ywxapp\app\Controller;
class {%className%} extends Controller
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index{%actionSuffix%}()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save{%actionSuffix%}(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read{%actionSuffix%}($id)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update{%actionSuffix%}(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete{%actionSuffix%}($id)
{
//
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use ywxapp\app\Controller;
class {%className%} extends Controller
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
//
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\Request;
use ywxapp\app\Controller;
class {%className%} extends Controller
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index{%actionSuffix%}()
{
//
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create{%actionSuffix%}()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save{%actionSuffix%}(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read{%actionSuffix%}($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit{%actionSuffix%}($id)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update{%actionSuffix%}(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete{%actionSuffix%}($id)
{
//
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
/**
* 事件监听处理
*
* @return mixed
*/
public function handle($event)
{
//
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
/**
* 处理请求
*
* @param \think\Request $request
* @param \Closure $next
* @return Response
*/
public function handle($request, \Closure $next)
{
//
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use ywxapp\model\BaseModel;
// use think\model\concern\SoftDelete;
/**
* @mixin \think\Model
*/
class {%className%} extends BaseModel
{
//use SoftDelete;
protected function getOptions(): array
{
return [
'strict' => true,
'name' => 'user',
'autoWriteTimestamp' => 'int',
'readonly' => ['uid'],
'createTime' => 'create_at',
'updateTime' => 'update_at',
//'deleteTime' => 'delete_at',
//'hidden' => ['password', 'delete_at'],
//'append' => ['status_text', 'last_login'],
];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%} extends \think\Service
{
/**
* 注册服务
*
* @return mixed
*/
public function register()
{
//
}
/**
* 执行服务
*
* @return mixed
*/
public function boot()
{
//
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
class {%className%}
{
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare (strict_types = 1);
namespace {%namespace%};
use think\Validate;
class {%className%} extends Validate
{
/**
* 定义验证规则
* 格式:'字段名' => ['规则1','规则2'...]
*
* @var array
*/
protected $rule = [];
/**
* 定义错误信息
* 格式:'字段名.规则名' => '错误信息'
*
* @var array
*/
protected $message = [];
}
+59
View File
@@ -0,0 +1,59 @@
-----BEGIN CERTIFICATE-----
MIIE8DCCA9igAwIBAgISBIqpUxWdLjxwT3OUCLv/nisJMA0GCSqGSIb3DQEBCwUA
MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD
EwNSMTEwHhcNMjQxMDA2MjMyOTAyWhcNMjUwMTA0MjMyOTAxWjAaMRgwFgYDVQQD
Ew93d3cueGl4aW5nd2wuY24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
AQCYXKbejo2pQKnyNpgPCq2czi7NQkooXDlqj4t7SEk4Tw0AtauGNPIal+pBWU5r
HKeUuKEDGz/LqcCyd9YfhBvbCa6jCPGmymgUVXES5cnHJKw5wc1+kjJq1dheQ55T
SCeZ1PVdZNyfDuhh4i8p2tREh2mxnRPY24DZrhBydkAwlqABeC7aarCDj9gPb2uh
/EcJ51Q+geQhRVGSlLYQQS7+gNVL/WdKuSH0eGS07yw5SnsPTSRrCLTemKOmxZqV
zGQKRM3vkz8zRfaHQbW+eL/y3udvbJbKdbuwACzlZbJOXT64DKb5Le8ARWJozEuV
+lgf2RZkmWzZgQb1L/INymnLAgMBAAGjggIVMIICETAOBgNVHQ8BAf8EBAMCBaAw
HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYD
VR0OBBYEFDU0Hjnqlsf+CZ9J0fuHBfwGoNxzMB8GA1UdIwQYMBaAFMXPRqTq9MPA
emyVxC2wXpIvJuO5MFcGCCsGAQUFBwEBBEswSTAiBggrBgEFBQcwAYYWaHR0cDov
L3IxMS5vLmxlbmNyLm9yZzAjBggrBgEFBQcwAoYXaHR0cDovL3IxMS5pLmxlbmNy
Lm9yZy8wGgYDVR0RBBMwEYIPd3d3LnhpeGluZ3dsLmNuMBMGA1UdIAQMMAowCAYG
Z4EMAQIBMIIBBgYKKwYBBAHWeQIEAgSB9wSB9ADyAHcAouMK5EXvva2bfjjtR2d3
U9eCW4SU1yteGyzEuVCkR+cAAAGSZF/NWwAABAMASDBGAiEA6H2F+bZd9MQsUPMF
Xtq1n3i/KDFKIFsxQzEuJ4DCzasCIQDK0VQovaW0NUkQD2Tj+YLIISulAx1zHTkW
N1MU8W+FJgB3ABNK3xq1mEIJeAxv70x6kaQWtyNJzlhXat+u2qfCq+AiAAABkmRf
zjwAAAQDAEgwRgIhAPOG0h/G9lUBsLmcsutR2yMBET4dP1S7Ii2X/j9ijKfOAiEA
22i5IJ6hR9l0XC+SiZ8Q5ZMnzhVJv9NBJsADSlqORz0wDQYJKoZIhvcNAQELBQAD
ggEBACeS4gOR8V51NV1HtSfBDf/AF4/G/u4BBbsi3xEW0NW8McD8OkwTI5NRcupN
A1jYMMFAqOUFgMjC6hJO/fOO7iFbKpbkBy3y+eI+943uaP8i5+S6ORErshn6A4NS
hnGfXxAclS9XZadnayjY3sUCpvqxkGh7Yf1s2Qpk7UIdBH0wT+H61IyGCGMsP72Q
J/VgDWi0eDWi8iKXL2vdKbSA2+8VyLcW5NY+hnITzXNCDaHLICxjo4Fsy/YmM4o1
GJFuZbN/7Od7bEqoa4PmSyzP/3UwLYUJ+0eQTIqqBEEQJac0TwbCwlMF+i99M1lm
7LDYQtTuVk3a4j4bDsEdxKgXqQ0=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIRAIp9PhPWLzDvI4a9KQdrNPgwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDEMMAoGA1UEAxMDUjExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAuoe8XBsAOcvKCs3UZxD5ATylTqVhyybKUvsVAbe5KPUoHu0nsyQYOWcJ
DAjs4DqwO3cOvfPlOVRBDE6uQdaZdN5R2+97/1i9qLcT9t4x1fJyyXJqC4N0lZxG
AGQUmfOx2SLZzaiSqhwmej/+71gFewiVgdtxD4774zEJuwm+UE1fj5F2PVqdnoPy
6cRms+EGZkNIGIBloDcYmpuEMpexsr3E+BUAnSeI++JjF5ZsmydnS8TbKF5pwnnw
SVzgJFDhxLyhBax7QG0AtMJBP6dYuC/FXJuluwme8f7rsIU5/agK70XEeOtlKsLP
Xzze41xNG/cLJyuqC0J3U095ah2H2QIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB
hjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB
/wIBADAdBgNVHQ4EFgQUxc9GpOr0w8B6bJXELbBeki8m47kwHwYDVR0jBBgwFoAU
ebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC
hhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG
A1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN
AQELBQADggIBAE7iiV0KAxyQOND1H/lxXPjDj7I3iHpvsCUf7b632IYGjukJhM1y
v4Hz/MrPU0jtvfZpQtSlET41yBOykh0FX+ou1Nj4ScOt9ZmWnO8m2OG0JAtIIE38
01S0qcYhyOE2G/93ZCkXufBL713qzXnQv5C/viOykNpKqUgxdKlEC+Hi9i2DcaR1
e9KUwQUZRhy5j/PEdEglKg3l9dtD4tuTm7kZtB8v32oOjzHTYw+7KdzdZiw/sBtn
UfhBPORNuay4pJxmY/WrhSMdzFO2q3Gu3MUBcdo27goYKjL9CTF8j/Zz55yctUoV
aneCWs/ajUX+HypkBTA+c8LGDLnWO2NKq0YD/pnARkAnYGPfUDoHR9gVSp/qRx+Z
WghiDLZsMwhN1zjtSC0uBWiugF3vTNzYIEFfaPG7Ws3jDrAMMYebQ95JQ+HIBD/R
PBuHRTBpqKlyDnkSHDHYPiNX3adPoPAcgdF3H2/W0rmoswMWgTlLn1Wu0mrks7/q
pdWfS6PJ1jty80r2VKsM/Dj3YIDfbjXKdaFU5C+8bhfJGqU3taKauuz0wHVGT3eo
6FlWkWYtbt4pgdamlwVeZEW+LM7qZEJEsMNPrfC03APKmZsJgpWCDWOKZvkZcvjV
uYkQ4omYCTX5ohy+knMjdOmdH9c7SpqEWBDC86fiNex+O0XOMEZSa8DA
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCYXKbejo2pQKny
NpgPCq2czi7NQkooXDlqj4t7SEk4Tw0AtauGNPIal+pBWU5rHKeUuKEDGz/LqcCy
d9YfhBvbCa6jCPGmymgUVXES5cnHJKw5wc1+kjJq1dheQ55TSCeZ1PVdZNyfDuhh
4i8p2tREh2mxnRPY24DZrhBydkAwlqABeC7aarCDj9gPb2uh/EcJ51Q+geQhRVGS
lLYQQS7+gNVL/WdKuSH0eGS07yw5SnsPTSRrCLTemKOmxZqVzGQKRM3vkz8zRfaH
QbW+eL/y3udvbJbKdbuwACzlZbJOXT64DKb5Le8ARWJozEuV+lgf2RZkmWzZgQb1
L/INymnLAgMBAAECggEABNxFISnAWyXfm1p31K2tMX4NACnyiQG9fdNrylhZUvKH
96h+pZ61b85o1VeD/jwWcdLHUCdjtvDeCijIgTjgVUckdbmopn7OfdeRQ95mEXRE
tOhPJeUuto8b1+X6/FfQ65dfkcXQmUJgfL7lF5tjf3bSaFgadKXhS7sXII+lJzzv
ljYTy2b2PzycXaqNTMovamiAtAukt6lo2NjN2NC5GynJJ8LWEOoFKHbOPEh1gUnK
7Zvwgg1bm+FRNGiHiKJ7QAsgW3Ua90vqLt1qYZbPH01l9NlrFK/H8QE/GDiLL5Tu
anWqlq1PGJHdnBCX+A+Txz0//SVk8lCKP2fgj1HQAQKBgQDOZL4EQkXWEeDDQPNW
Zvo2D3K+SvqmJTXh01XwoexyOUgAxE1MgzRLQmAUoQo9wH6PUZlLS1YKOsBkeHov
PyHVxN2oJNgSjLpOGjUm0juBhfYr3LL0TeRF9YurO7IUecyw+crzNGwnqje/f7n9
SjROsdNeha0sLZ4dNJhNgCPYuwKBgQC8+2FuVN4e6NgcCbggvVtY59poqN+jdRsh
Gni6IXHjzgLAGIFZqsYN5tWIhA3a/htnkcJyL6H5LquK406HRxKR4VtX7KyscCbn
wlKcGx0oIEYhLOyVAR2gu/E6oEbKjLatKio6GAjok1LB25PFZkczC0jAZMtX5Afn
zL2GSMnqMQKBgFg8dYf4lUapqf+PmviLWdkWzaVRBMtXCSwcX5dagm1q07+QLMPT
K94o6E6pcmloDDNVXUX1VTlWWL4bS5E7Wkm7uk+SQNXdWCDfz21jX5FGJjImTlNn
oXnPOgDgqodacwoOIJfNB5gFi4PRJUCGIsqp94VnfNtwPTKbM6meaLTVAoGAVObO
z+Wa4OIU7QvEyBiqKFgJfImZ53KeHJIq+Nw7sW+FNs4LlsAtOGOjPTCulNsibrZC
WFBkAXHhKYWTax0YD6fiBK9UqCe+otJfkLhxsexF9XOcWhjlOagV6RPGmgr7qvJN
hEn1/p7pSCSgz8dyZ1FDfwQJgtP0ZURLRUAATpECgYBX8hdnTVQ7rr+6vfpQ+yGg
QGGE3XySTe/LF8RIZju4aqyy9ejOnT/B0V8PcfMOXCcGJEvOtLwhWVmfx302xGdF
HOt/GnIXzjnH+V+DjpgXNmZdl6x9iblc08kAULFWXMxvzqyEIaYvJ8suVPQnGI9Q
Ls1fbWv3O/tHE3r1G8APXw==
-----END PRIVATE KEY-----
+138
View File
@@ -0,0 +1,138 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\controller;
use ywxapp\service\AddonService as AddonService;
use think\facade\Db;
/**
* Addon 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon
{
public function index()
{
$list = [];
if (defined('ADDON_PATH') && 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;
}
$installedInfo = Db::name('addon')->where('name', $name)->find();
$list[] = [
'name' => $name,
'title' => $info['title'] ?? $name,
'description' => $info['description'] ?? '',
'version' => $info['version'] ?? '1.0.0',
'author' => $info['author'] ?? '',
'installed' => $installedInfo ? true : false,
'status' => $installedInfo['status'] ?? 0,
'installed_version' => $installedInfo['version'] ?? null,
'has_update' => $installedInfo
? version_compare($info['version'] ?? '1.0.0', $installedInfo['version'], '>')
: false,
];
}
}
return json([
'code' => 200,
'data' => $list
]);
}
public function install($name)
{
try {
AddonService::instance($name)->install();
return json(['code' => 200, 'msg' => '安装成功']);
} catch (\Exception $e) {
return json(['code' => 500, 'msg' => $e->getMessage()]);
}
}
public function uninstall($name)
{
try {
AddonService::instance($name)->uninstall();
return json(['code' => 200, 'msg' => '卸载成功']);
} catch (\Exception $e) {
return json(['code' => 500, 'msg' => $e->getMessage()]);
}
}
public function enable($name)
{
try {
AddonService::instance($name)->enable();
return json(['code' => 200, 'msg' => '启用成功']);
} catch (\Exception $e) {
return json(['code' => 500, 'msg' => $e->getMessage()]);
}
}
public function disable($name)
{
try {
AddonService::instance($name)->disable();
return json(['code' => 200, 'msg' => '禁用成功']);
} catch (\Exception $e) {
return json(['code' => 500, 'msg' => $e->getMessage()]);
}
}
public function upgrade($name)
{
try {
$service = AddonService::instance($name);
if (!method_exists($service, 'upgrade')) {
return json(['code' => 500, 'msg' => '当前版本不支持在线升级']);
}
$service->upgrade();
return json(['code' => 200, 'msg' => '升级成功']);
} catch (\Exception $e) {
return json(['code' => 500, 'msg' => $e->getMessage()]);
}
}
public function info($name)
{
try {
$info = AddonService::instance($name)->getInfo();
} catch (\Exception $e) {
return json(['code' => 404, 'msg' => '插件不存在']);
}
$installed = Db::name('addon')->where('name', $name)->find();
return json([
'code' => 200,
'data' => [
'info' => $info,
'installed' => $installed ?: false
]
]);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?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\controller;
/**
* 控制器基础类
*/
class ApiController extends BaseController
{
// use \ywxapp\traits\Backend;
/**
* 控制器初始化 _initialize
* @return void
*/
public function _initialize()
{
$this->initialize();
}
/** 控制器初始化 initialize
* @return void
*/
protected function initialize() {}
/**
* 获取当前登录用户信息
* \ywxapp\model\Member
*/
protected function user()
{
if ($this->auth && $this->auth->isLogin) {
return $this->auth->info;
}
$this->result->error('获取用户信息失败!');
}
}
+917
View File
@@ -0,0 +1,917 @@
<?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\controller;
use think\App;
use think\Exception;
use think\facade\Db;
use think\exception\ValidateException;
use think\facade\View;
use app\common\library\Token;
use ywxapp\library\AdminAuth;
use ywxapp\library\SkinOverlay;
use ywxapp\library\SkinVariables;
use ywxapp\AddonBase;
/**
* 后台控制器基类(统一核心后台与插件后台).
*
* - 核心后台(app\backend\...):afterAuth 启用核心 layout 并注入管理员。
* - 插件后台(addon\*\...):beforeAuth 强制 AdminAuth 与插件视图目录,
* afterAuth 复用核心 layoutfetch 叠加皮肤层与配色变量。
*
* 公共逻辑(属性 / buildparams / 数据权限 / CRUD / 登录登出)统一落地于此,
* 原 Backend、AddonBackend、traits\Backend 已并入并删除。
*/
abstract class BackendBase extends BaseController
{
/**
* 快速搜索时执行查找的字段.
*/
protected $searchFields = 'id';
/**
* 是否是关联查询.
*/
protected $relationSearch = false;
/**
* 是否开启数据限制
* 支持auth/personal
* 表示按权限判断/仅限个人
* 默认为禁用,若启用请务必保证表中存在admin_id字段.
*/
protected $dataLimit = false;
/**
* 数据限制字段.
*/
protected $dataLimitField = 'admin_id';
/**
* 数据限制开启时自动填充限制字段值
*/
protected $dataLimitFieldAutoFill = true;
/**
* 是否开启Validate验证
*/
protected $modelValidate = false;
/**
* 是否开启模型场景验证
*/
protected $modelSceneValidate = false;
/**
* Multi方法可批量修改的字段.
*/
protected $multiFields = 'status';
/**
* Selectpage可显示的字段.
*/
protected $selectpageFields = '*';
/**
* 前台提交过来,需要排除的字段数据.
*/
protected $excludeFields = '';
/**
* 导入文件首行类型
* 支持comment/name
* 表示注释或字段名.
*/
protected $importHeadType = 'comment';
/**
* 视图类实例
* @var \think\View
*/
protected $view;
/**
* 构造方法:在 parent 解析容器 auth 之前,先判定本控制器是否为「插件后台」。
*
* 插件后台路由经全局分发落在 frontend 应用,容器 auth 默认是前台 Auth。
* 这里用 static::class(实际子类类名,构造前即可获取,不依赖路由 dispatch 时机)
* 匹配 addon\<插件>\controller\backend\* 目录约定,命中则把容器 auth 绑成 AdminAuth
* 使 parent 构造取到的 $app->auth 即为后台鉴权实例(isAdmin=true)。
* 核心后台(backend 应用)由 AppService 闭包直接给 AdminAuth,无需此处处理。
*
* @param App $app
*/
public function __construct(App $app)
{
// 插件后台判定:getNamespace() 只到 addon\<插件> 层(不含 controller\backend 子段),
// 无法区分后台/前台控制器,故插件后台必须用 static::class(完整类名构造前即可获取,
// 含 addon\<插件>\controller\backend\ 段)来匹配;getNamespace() 仅用于插件上下文兜底。
$class = static::class;
if (strpos($class, 'addon\\') === 0
&& strpos($class, '\\controller\\backend\\') !== false) {
$app->bind('auth', AdminAuth::class);
}
parent::__construct($app);
}
/**
* 控制器初始化骨架:准备视图实例 -> beforeAuth -> 校验 -> afterAuth.
* @return void
*/
public function _initialize()
{
$this->view = $this->app->view;
$this->beforeAuth();
// 登录与权限校验:未登录时整页请求 302 跳登录页,AJAX 返回 401 由前端跳转
$this->auth->verifyAuth($this->noNeedLogin, $this->noNeedVerify);
// 鉴权后置钩子(启用 layout / 注入登录管理员 / 修正 $site 等)
$this->afterAuth();
// 注入站点信息(route_base / site.module / site.controller 等),供前端 route.js 使用
$this->assignSite();
// 子类初始化钩子
$this->initialize();
}
/**
* 控制器初始化方法,子类可通过重写该方法实现自己的初始化逻辑.
* @return void
*/
protected function initialize() {}
/**
* 鉴权前置钩子:根据上下文自动适配核心 / 插件后台.
* @return void
*/
protected function beforeAuth()
{
if ($this->isAddonContext()) {
// 插件后台安全基线:必须登录,杜绝继承来的免登录白名单
$this->noNeedLogin = [];
// 鉴权实例由容器统一解析:本类控制器命名空间为 addon\*,
// AppService 的 auth 闭包已据此返回 AdminAuth(无需手动 new)。
// 视图目录切到插件自身 view/backend/
$this->switchAddonViewPath();
} else {
// 核心后台处理
}
}
/**
* 鉴权后置钩子:根据上下文自动适配核心 / 插件后台.
* @return void
*/
protected function afterAuth()
{
if ($this->isAddonContext()) {
// 插件后台复用核心后台外壳(裸内容由 layout 包裹 {__CONTENT__}
$this->view->config([
"layout_on" => true,
"layout_name" => $this->app->getRootPath() . 'app/backend/view/common/layout.html'
]);
} else {
$this->view->config([
"layout_on" => true,
"layout_name" => 'common/layout'
]);
// 核心后台:layout 由后台模板自行 {extend common/layout} 控制(保持原 Backend 注释态,不强制包裹)
if ($this->auth && $this->auth->isLogin) {
$this->view->assign('member', $this->auth->info);
}
}
}
/**
* 渲染模板(基类入口).
* 插件后台叠加皮肤覆盖层与配色变量;核心后台走默认。
*/
protected function fetch($template = '', $vars = [], $replace = [], $config = [])
{
if ($this->isAddonContext()) {
$addon = $this->currentAddon();
$restore = null;
if ($addon && $overlay = SkinOverlay::resolve($addon, 'backend')) {
$restore = View::getConfig('view_path');
View::config(['view_path' => $overlay]);
}
$result = View::fetch($template, $vars, $replace, $config);
if ($restore !== null) {
View::config(['view_path' => $restore]);
}
// 注入皮肤变量 CSS(Discuz 式配色层,无需改 HTML
$style = SkinVariables::styleTag($addon, 'backend');
if ($style !== '' && ($pos = stripos($result, '</head>')) !== false) {
$result = substr($result, 0, $pos) . $style . "\n" . substr($result, $pos);
} elseif ($style !== '') {
$result = $style . $result;
}
return $result;
}
return View::fetch($template, $vars, $replace, $config);
}
/**
* 将站点上下文修正为后台(插件页复用核心 layout 时 $site.app/module 需为 backend.
* @return void
*/
protected function applyBackendSite()
{
$site = $this->view->getConfig('site') ?? [];
if (is_array($site)) {
$site['module'] = 'backend';
$site['app'] = 'backend';
$this->view->assign('site', $site);
}
}
/**
* 从控制器类名反推插件目录并切换视图根目录到 addon/<name>/view/backend/.
* @return void
*/
protected function switchAddonViewPath()
{
// 重构版 MultiApp 已将 appPath 设为 addon/<插件>/,直接拼视图目录,无需正则
$this->view->config([
'view_path' => $this->app->getAppPath() . 'view' . DIRECTORY_SEPARATOR
. 'backend' . DIRECTORY_SEPARATOR,
]);
}
/**
* 是否插件后台(addon\*\controller\backend\ 命名空间 + AdminAuth 鉴权).
* @return bool
*/
protected function isAddonAdmin(): bool
{
return strpos($this->app->getNamespace(), 'addon\\') === 0
&& strpos($this->app->getNamespace(), '\\controller\\backend\\') !== false
&& $this->auth instanceof AdminAuth;
}
/**
* 判断当前控制器是否属于插件上下文(addon\ 命名空间).
* @return bool
*/
protected function isAddonContext(): bool
{
return strpos($this->app->getNamespace(), 'addon\\') === 0;
}
/**
* 从当前控制器命名空间提取插件名(addon\<插件>\... -> <插件>)。
* 由 appPathaddon/<插件>/)剥 rootPath 取首段,无需正则。
* @return string
*/
protected function currentAddon(): string
{
$rel = trim(substr($this->app->getAppPath(), strlen($this->app->getRootPath())), DIRECTORY_SEPARATOR);
$seg = explode(DIRECTORY_SEPARATOR, $rel);
return $seg[0] === 'addon' && isset($seg[1]) ? $seg[1] : '';
}
/**
* 赋值到模板.
*/
protected function assign($name, $value = '')
{
$this->view->assign($name, $value);
return $this;
}
/**
* 是否已登录
* @return bool
*/
public function isLogin()
{
return $this->auth->isLogin;
}
/**
* 后台登录入口(免登录).
* 渲染登录页(layout(false) 独立整页)。
*/
public function login()
{
if ($this->request->isPost()) {
$account = $this->request->post('account/s', '');
$password = $this->request->post('password/s', '');
$captcha = $this->request->post('captcha/s', '');
if (! $this->auth->login($account, $password, $captcha)) {
$this->result->error($this->auth->getError());
}
$this->result->success('登录成功', ['url' => url('backend/index/index')->build()]);
}
View::layout(false);
return View::fetch();
}
/**
* 退出登录
*/
public function logout()
{
$this->auth->logout();
$this->result->success('退出成功', ['url' => url('backend/login/login')->build()]);
}
/**
* 设置页面标题(核心 layout 通过 {$title} 显示).
* @param string $title
*/
protected function setTitle(string $title)
{
$this->view->assign('title', $title);
}
/**
* 排除前台提交过来的字段
* @param $params
* @return array
*/
protected function preExcludeFields($params)
{
if (is_array($this->excludeFields)) {
foreach ($this->excludeFields as $field) {
if (array_key_exists($field, $params))
unset($params[$field]);
}
} else {
if (array_key_exists($this->excludeFields, $params))
unset($params[$this->excludeFields]);
}
return $params;
}
/**
* 查看
*/
public function index()
{
//设置过滤方法
$this->request->filter(['strip_tags']);
//如果发送的来源是Selectpage,则转发到Selectpage
if ($this->request->request('keyField'))
return $this->selectpage();
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
$total = $this->model
->where($where)
->order($sort, $order)
->count();
$list = $this->model
->where($where)
->order($sort, $order)
->limit($offset, $limit)
->select();
$list = $list->toArray();
$result = ['total' => $total, 'rows' => $list];
return $this->result->success($result);
}
/**
* 回收站
*/
public function recyclebin()
{
//设置过滤方法
$this->request->filter(['strip_tags']);
if ($this->request->isAjax()) {
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
$total = $this->model
->onlyTrashed()
->where($where)
->order($sort, $order)
->count();
$list = $this->model
->onlyTrashed()
->where($where)
->order($sort, $order)
->limit($offset, $limit)
->select();
$result = ['total' => $total, 'rows' => $list];
$this->result->success($result);
}
}
/**
* 添加
*/
public function create()
{
if ($this->request->isPost()) {
$params = $this->request->post('row/a');
if ($params) {
$params = $this->preExcludeFields($params);
if ($this->dataLimit && $this->dataLimitFieldAutoFill)
$params[$this->dataLimitField] = $this->auth->id;
$result = false;
Db::startTrans();
try {
//是否采用模型验证
if ($this->modelValidate) {
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
$validate = is_bool($this->modelValidate) ? ($this->modelSceneValidate ? $name . '.add' : $name) : $this->modelValidate;
validate($validate)->scene($this->modelSceneValidate ? 'edit' : $name)->check($params);
}
$result = $this->model->save($params);
Db::commit();
} catch (ValidateException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (\PDOException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (Exception $e) {
Db::rollback();
$this->result->error($e->getMessage());
}
if ($result !== false)
$this->result->success();
$this->result->error(lang('No rows were inserted'));
}
$this->result->error(lang('Parameter %s can not be empty', ''));
}
$this->result->error(lang('Parameter %s can not be empty'));
}
/**
* 编辑
*/
public function edit($id = null)
{
$row = $this->model->get($ids);
if (!$row)
$this->result->error(lang('No Results were found'));
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
if (!in_array($row[$this->dataLimitField], $adminIds))
$this->result->error(lang('You have no permission'));
}
if ($this->request->isPost()) {
$params = $this->request->post('row/a');
if ($params) {
$params = $this->preExcludeFields($params);
$result = false;
Db::startTrans();
try {
//是否采用模型验证
if ($this->modelValidate) {
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
$validate = is_bool($this->modelValidate) ? $name : $this->modelValidate;
$pk = $row->getPk();
if (!isset($params[$pk])) {
$params[$pk] = $row->$pk;
}
validate($validate)->scene($this->modelSceneValidate ? 'edit' : $name)->check($params);
}
$result = $row->save($params);
Db::commit();
} catch (ValidateException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (\PDOException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (Exception $e) {
Db::rollback();
$this->result->error($e->getMessage());
}
if ($result !== false)
$this->result->success();
$this->result->error(lang('No rows were updated'));
}
$this->result->error(lang('Parameter %s can not be empty', ''));
}
$this->view->assign('row', $row);
return $this->view->fetch();
}
/**
* 删除
*/
public function del($ids = '')
{
if ($ids) {
$pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds))
$this->model->where($this->dataLimitField, 'in', $adminIds);
$list = $this->model->where($pk, 'in', $ids)->select();
$count = 0;
Db::startTrans();
try {
foreach ($list as $k => $v) {
$count += $v->delete();
}
Db::commit();
} catch (\PDOException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (Exception $e) {
Db::rollback();
$this->result->error($e->getMessage());
}
if ($count)
$this->result->success();
$this->result->error(lang('No rows were deleted'));
}
$this->result->error(lang('Parameter %s can not be empty'));
}
/**
* 真实删除
*/
public function destroy($ids = '')
{
$pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds();
$where = [];
if (is_array($adminIds))
$where[$this->dataLimitField] = $adminIds;
if ($ids)
$where[$pk] = explode(',', $ids);
$count = 0;
Db::startTrans();
try {
$list = $this->model->onlyTrashed()->where($where)->select();
foreach ($list as $k => $v) {
$count += $v->force()->delete();
}
Db::commit();
} catch (\PDOException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (Exception $e) {
Db::rollback();
$this->result->error($e->getMessage());
}
if ($count)
$this->result->success();
$this->result->error(lang('Parameter %s can not be empty', 'ids'));
}
/**
* 还原
*/
public function restore($ids = '')
{
$pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds();
$where = [];
if (is_array($adminIds))
$where[$this->dataLimitField] = $adminIds;
if ($ids)
$where[$pk] = explode(',', $ids);
$count = 0;
Db::startTrans();
try {
$list = $this->model->onlyTrashed()->where($where)->select();
foreach ($list as $index => $item) {
$count += $item->restore();
}
Db::commit();
} catch (\PDOException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (Exception $e) {
Db::rollback();
$this->result->error($e->getMessage());
}
if ($count)
$this->result->success();
$this->result->error(lang('No rows were updated'));
}
/**
* 批量更新
*/
public function multi($ids = '')
{
$ids = $ids ? $ids : $this->request->param('ids');
if ($ids) {
if ($this->request->has('params')) {
parse_str($this->request->post('params'), $values);
$values = $this->auth->isSuperAdmin() ? $values : array_intersect_key(
$values,
array_flip(is_array($this->multiFields) ? $this->multiFields : explode(',', $this->multiFields))
);
if ($values) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$this->model->where($this->dataLimitField, 'in', $adminIds);
}
$count = 0;
Db::startTrans();
try {
$list = $this->model->where($this->model->getPk(), 'in', $ids)->select();
foreach ($list as $index => $item) {
$count += $item->save($values);
}
Db::commit();
} catch (\PDOException $e) {
Db::rollback();
$this->result->error($e->getMessage());
} catch (Exception $e) {
Db::rollback();
$this->result->error($e->getMessage());
}
if ($count) {
$this->result->success();
} else {
$this->result->error(lang('No rows were updated'));
}
} else {
$this->result->error(lang('You have no permission'));
}
}
}
$this->result->error(lang('Parameter %s can not be empty', 'ids'));
}
/**
* 导入
*/
protected function import()
{
$file = $this->request->request('file');
if (!$file) {
$this->result->error(lang('Parameter %s can not be empty', 'file'));
}
$filePath = app()->getRootPath() . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $file;
if (!is_file($filePath)) {
$this->result->error(lang('No results were found'));
}
//实例化reader
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
if (!in_array($ext, ['csv', 'xls', 'xlsx'])) {
$this->result->error(lang('Unknown data format'));
}
if ($ext === 'csv') {
$file = fopen($filePath, 'r');
$filePath = tempnam(sys_get_temp_dir(), 'import_csv');
$fp = fopen($filePath, 'w');
$n = 0;
while ($line = fgets($file)) {
$line = rtrim($line, "\n\r\0");
$encoding = mb_detect_encoding($line, ['utf-8', 'gbk', 'latin1', 'big5']);
if ($encoding != 'utf-8') {
$line = mb_convert_encoding($line, 'utf-8', $encoding);
}
if ($n == 0 || preg_match('/^".*"$/', $line)) {
fwrite($fp, $line . "\n");
} else {
fwrite($fp, '"' . str_replace(['"', ','], ['""', '","'], $line) . "\"\n");
}
$n++;
}
fclose($file) || fclose($fp);
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Csv();
} elseif ($ext === 'xls') {
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xls();
} else {
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
}
//导入文件首行类型,默认是注释,如果需要使用字段名称请使用name
$importHeadType = isset($this->importHeadType) ? $this->importHeadType : 'comment';
$table = $this->model->db()->getTable();
$database = \think\facade\Config::get('database.database');
$fieldArr = [];
$list = Db::query(
'SELECT COLUMN_NAME,COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND TABLE_SCHEMA = ?',
[$table, $database]
);
foreach ($list as $k => $v) {
if ($importHeadType == 'comment')
$fieldArr[$v['COLUMN_COMMENT']] = $v['COLUMN_NAME'];
else
$fieldArr[$v['COLUMN_NAME']] = $v['COLUMN_NAME'];
}
//加载文件
$insert = [];
try {
if (!$PHPExcel = $reader->load($filePath)) {
$this->result->error(lang('Unknown data format'));
}
$currentSheet = $PHPExcel->getSheet(0); //读取文件中的第一个工作表
$allColumn = $currentSheet->getHighestDataColumn(); //取得最大的列号
$allRow = $currentSheet->getHighestRow(); //取得一共有多少行
$maxColumnNumber = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($allColumn);
$fields = [];
for ($currentRow = 1; $currentRow <= 1; $currentRow++) {
for ($currentColumn = 1; $currentColumn <= $maxColumnNumber; $currentColumn++) {
$val = $currentSheet->getCellByColumnAndRow($currentColumn, $currentRow)->getValue();
$fields[] = $val;
}
}
for ($currentRow = 2; $currentRow <= $allRow; $currentRow++) {
$values = [];
for ($currentColumn = 1; $currentColumn <= $maxColumnNumber; $currentColumn++) {
$val = $currentSheet->getCellByColumnAndColumn($currentColumn, $currentRow)->getValue();
$values[] = is_null($val) ? '' : $val;
}
$row = [];
$temp = array_combine($fields, $values);
foreach ($temp as $k => $v) {
if (isset($fieldArr[$k]) && $k !== '') {
$row[$fieldArr[$k]] = $v;
}
}
if ($row) {
$insert[] = $row;
}
}
} catch (Exception $exception) {
$this->result->error($exception->getMessage());
}
if (!$insert) {
$this->result->error(lang('No rows were updated'));
}
try {
//是否包含admin_id字段
$has_admin_id = false;
foreach ($fieldArr as $name => $key) {
if ($key == 'admin_id') {
$has_admin_id = true;
break;
}
}
if ($has_admin_id) {
$auth = $this->auth;
foreach ($insert as &$val) {
if (!isset($val['admin_id']) || empty($val['admin_id'])) {
$val['admin_id'] = $auth->isLogin ? $auth->id : 0;
}
}
}
$this->model->saveAll($insert);
} catch (\PDOException $exception) {
$msg = $exception->getMessage();
if (
preg_match(
"/.+Integrity constraint violation: 1062 Duplicate entry '(.+)' for key '(.+)'/is",
$msg,
$matches
)
) {
$msg = "导入失败,包含【{$matches[1]}】的记录已存在";
}
$this->result->error($msg);
} catch (Exception $e) {
$this->result->error($e->getMessage());
}
$this->result->success();
}
/**
* 生成查询所需要的条件,排序,分页等信息(含安全加固).
*
* @param bool|array $searchfields 快速搜索字段
* @return array
*/
protected function buildparams($searchfields = false)
{
$searchfields = is_array($searchfields) ? $searchfields : (is_bool($searchfields) && $searchfields !== false ? $this->searchFields : $searchfields);
$searchfields = is_string($searchfields) ? explode(',', $searchfields) : $searchfields;
$filter = $this->request->get('filter', '');
$op = $this->request->get('op', '', 'trim');
$sort = $this->request->get('sort', 'id');
$order = $this->request->get('order', 'desc');
$offset = $this->request->get('offset', 0);
$limit = $this->request->get('limit', 0);
$filter = (array)json_decode($filter, true);
$op = (array)json_decode($op, true);
$filter = $filter ? $filter : [];
$where = [];
$tableName = '';
$model = $this->model;
if (! empty($model)) {
// 兼容模型别名
$tableName = $model->getQuery()->getTable();
}
$alias = $model && method_exists($model, 'getTable') ? $model->getTable() : '';
$pkField = $model && method_exists($model, 'getPk') ? $model->getPk() : 'id';
foreach ($filter as $k => $v) {
// 安全加固:仅允许白名单字段(字母/数字/下划线/点,禁止表达式注入)
if (! preg_match('/^[A-Za-z_][A-Za-z0-9_.]*$/', $k)) {
continue;
}
$sym = isset($op[$k]) ? $op[$k] : '=';
// 安全加固:限定操作符白名单
if (! in_array($sym, ['=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'RANGE', 'NOT RANGE', 'NULL', 'IS NULL', 'NOT NULL', 'IS NOT NULL', 'FIND_IN_SET'])) {
$sym = '=';
}
if (strtoupper($sym) === 'FIND_IN_SET') {
// FIND_IN_SET(col, val) —— 列名同样需校验
if (preg_match('/^[A-Za-z_][A-Za-z0-9_.]*$/', $k)) {
$where[] = ['', 'EXP', Db::raw("FIND_IN_SET(`{$k}`, '" . addslashes($v) . "')")];
}
continue;
}
if (stripos($k, '.') === false) {
$k = $alias ? ($alias . '.' . $k) : $k;
}
switch (strtoupper($sym)) {
case '=':
case '>':
case '>=':
case '<':
case '<=':
case 'LIKE':
case 'NOT LIKE':
$where[] = [$k, $sym, $v];
break;
case 'IN':
case 'NOT IN':
$arr = is_array($v) ? $v : (strpos($v, ',') !== false ? explode(',', $v) : [$v]);
$where[] = [$k, $sym, $arr];
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$arr = array_slice(explode(',', $v), 0, 2);
if (stripos($v, ',') === false || ! array_filter($arr)) {
continue 2;
}
$where[] = [$k, $sym, $arr];
break;
case 'RANGE':
case 'NOT RANGE':
$v = str_replace(' - ', ',', $v);
$arr = array_slice(explode(',', $v), 0, 2);
if (stripos($v, ',') === false || ! array_filter($arr)) {
continue 2;
}
//当出现一边为空时改变操作符
if ($arr[0] === '') {
$sym = $sym == 'RANGE' ? ' <= ' : '>';
$arr = $arr[1];
} elseif ($arr[1] === '') {
$sym = $sym == 'RANGE' ? ' >= ' : '<';
$arr = $arr[0];
}
$where[] = [$k, str_replace('RANGE', 'BETWEEN', $sym) . ' time', $arr];
break;
case 'NULL':
case 'IS NULL':
case 'NOT NULL':
case 'IS NOT NULL':
$where[] = [$k, strtolower(str_replace('IS ', '', $sym))];
break;
default:
break;
}
}
if (! empty($where)) {
$where = function ($query) use ($where) {
foreach ($where as $k => $v) {
if (is_array($v)) {
call_user_func_array([$query, 'where'], $v);
} else {
$query->where($v);
}
}
};
}
return [$where, trim($sort), trim($order), $offset, $limit];
}
/**
* 获取数据限制的管理员 ID 集合(用于数据权限隔离).
*
* - dataLimit 为 false:返回 null,表示不限制(默认)
* - 'personal':仅当前管理员本人
* - 'auth':可按组织树扩展,此处简化为当前管理员
*
* @return array|null
*/
protected function getDataLimitAdminIds()
{
if (! $this->dataLimit) {
return null;
}
$adminId = $this->auth->model->id ?? null;
return $adminId !== null ? [$adminId] : null;
}
}
+253
View File
@@ -0,0 +1,253 @@
<?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\controller;
use think\App;
use think\exception\ValidateException;
use think\Validate;
use ywxapp\library\Result;
use think\facade\Lang;
use think\facade\View;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* 应用实例
* @var \think\App
*/
protected $app;
/**
* Request实例
* @var \think\Request
*/
protected $request;
/**
* 应用模块名称
* @var string
*/
protected $module = '';
/**
* 是否批量验证
* @var bool
*/
protected $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected $middleware = [];
/**
* 控制器模型
* @var \think\Model
*/
protected $model = null;
/**
* 响应实例
* @var \ywxapp\library\Result
*/
protected $result;
/**
* 用户实例
* @var \ywxapp\library\Auth
*/
protected $auth;
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = [];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(App $app)
{
$this->app = $app;
// 安全的CORS配置(必须在 $this->app 赋值后再调用,使用容器 Env 而非全局 env() 助手)
$this->setCorsHeaders();
$this->module = $app->http->getName();
$this->result = $app->result;
$this->request = $app->request;
$this->auth = $app->auth;
// 尝试用 token 还原登录态:有 token 就解析并初始化用户,没有则跳过(不报错、不影响后续)
$this->auth->tryInitByToken();
// 登录验证(需要登录的 action 未登录返回 401;已登录则校验权限)
// 加载当前控制器语言包
$this->loadlang($this->request->controller());
$this->_initialize();
$this->assignSite();
}
/**
* 设置安全的CORS头部
*/
protected function setCorsHeaders(): void
{
// 从配置或环境变量获取允许的来源(使用容器 Env,避免依赖全局 env() 助手在插件/CLI 上下文未注册)
$allowedOrigins = $this->app->env->get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8080');
$allowedOriginsArray = array_filter(array_map('trim', explode(',', $allowedOrigins)));
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// 检查来源是否在白名单中
if (in_array($origin, $allowedOriginsArray) || empty($origin)) {
if (!empty($origin)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Max-Age: 86400'); // 24小时预检缓存
}
// 处理OPTIONS预检请求
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
}
/**
* 向模板注入当前应用的基础路径信息。
*
* 关键:对外 URL 的模块段必须是 app_map 的「虚拟名」(admin/user/home),
* 而非 http->getName() 返回的「真实目录名」(backend/member/frontend)
* 否则拼接出的 URL 会与路由不匹配(重复 / 错位模块名)。
* 虚拟名直接取自 request->root() 中解析出的那段。
*/
protected function assignSite(): void
{
$root = $this->app->request->root(); // 如 /admin、/user、默认应用为空
$appRoot = ltrim($root, '/'); // 虚拟名:admin/user/home,默认应用为 ''
// 部署根(去掉模块段):/sub/admin -> /sub/admin -> ''(网站根目录)
$deployRoot = preg_replace('#^/[^/]+#', '', $root);
View::assign('site', [
'root' => $deployRoot === '' ? '' : rtrim($deployRoot, '/'),
'module' => $appRoot, // 对外模块段(虚拟名,URL 用):admin/user/home
'app' => $this->module, // 真实应用目录名(静态资源目录用):backend/member/frontend/home
'controller' => $this->request->controller(),
'action' => $this->request->action(),
'devToken' => config('ywxapp.developer_token'),
'devAddon' => config('ywxapp.addon_developer')
]);
// 后端生成一个「带占位符的基准 URL」,供 JS(route.js) 做片段替换,
// 从而与后端路由规则(app_map/别名/后缀)完全对齐。
// 例如当前应用在 backend 时生成: /admin/{__CTRL__}/{__ACT__}
// 注意:不可用 Route::buildUrl('{__CTRL__}/{__ACT__}') —— 它把占位符当真实
// 路由解析,在某些路由配置下会误生成 /menu/{__CTRL__} 之类的异常路径。
// 改为按 app_map 反查当前应用(真实目录名 $this->module)的虚拟前缀手动拼。
$appMap = (array) config('app.app_map');
$appPrefix = array_search($this->module, $appMap, true);
if (! $appPrefix) {
$appPrefix = $this->module; // 无映射时直接用真实目录名
}
$base = $deployRoot === '' ? '' : rtrim($deployRoot, '/');
$routeBase = $base . '/' . $appPrefix . '/{__CTRL__}/{__ACT__}';
View::assign('route_base', $routeBase);
}
/**
* 控制器初始化 _initialize
* @return void
*/
protected function _initialize()
{}
/**
* 加载语言文件.
*
* @param string $name
*/
protected function loadlang($name = '')
{
$name = $name ?: $this->request->controller();
if (strpos($name, '.')) {
$_arr = explode('.', $name);
if (count($_arr) == 2) {
$path = $_arr[0] . '/' . strtolower($_arr[1]);
} else {
$path = strtolower($name);
}
} else {
$path = strtolower($name);
}
Lang::load($this->app->getAppPath() . '/lang/' . Lang::getLangset() . '/' . $path . '.php');
}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, string | array $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (! empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
/**
* 成功响应(转发到 Result).
*/
public function success($data = null, $message = 'success', int $code = 0)
{
return $this->result->success($data, $message, $code);
}
/**
* 失败响应(转发到 Result).
*/
public function error($message = 'Error', int $code = 1, $data = null)
{
return $this->result->error($message, $code, $data);
}
}
+202
View File
@@ -0,0 +1,202 @@
<?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\controller;
use think\facade\View;
use ywxapp\model\Configure as ConfModel;
use ywxapp\model\Links;
use ywxapp\model\Notice;
use ywxapp\model\Ad;
use ywxapp\library\SkinOverlay;
use ywxapp\library\SkinVariables;
use ywxapp\library\TemplateManager;
/**
* 前台控制器基类(统一核心前台与插件前台).
*
* - 核心前台(app\frontend\...):afterAuth 不启用后台 layout,渲染视图走应用默认视图目录。
* - 插件前台(addon\*\...):视图根切到 addon/<name>/view/frontend/
* fetch 叠加皮肤覆盖层、配色变量与风格切换器。
*
* 公共逻辑(站点配置 / 友情链接 / user / success / error)统一落地于此,
* 原 Frontend、AddonFrontend 已并入并删除。
*/
abstract class FrontendBase extends BaseController
{
/**
* 视图类实例
* @var \think\View
*/
protected $view;
/**
* 前台默认放行登录:前台本质是「开放展示」,故默认不拦截(游客可访问)。
* 需要登录 + 用户组权限的控制器(如插件用户中心、开发者中心)把本属性
* 设为 [] 或具体 action 数组,即复用 BaseController 同一份 verifyAuth 守卫。
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* 控制器初始化骨架.
* @return void
*/
public function _initialize()
{
$this->view = $this->app->view;
if ($this->isAddonContext()) {
// 插件前台:视图根切到插件自身 view/frontend/
$this->switchAddonViewPath();
} else {
// 核心前台(如 app/wxapp):统一走全局 layout 外壳,
// 子模板写裸内容,由 common/layout.html 的 {__CONTENT__} 包裹。
$this->view->config([
'layout_on' => true,
'layout_name' => 'common/layout',
]);
// 默认页面标题(子模板可通过 view() 的变量覆盖)
$this->view->assign('title', 'YwxApp 框架展示');
}
// 登录与用户组权限校验:默认放行(noNeedLogin=['*']),需登录的子类自行收窄。
// verifyAuth 内部 tokenParse() 还原登录态并做权限校验,未登录整页 302 / AJAX 返 401
// 与后台、会员中心共用同一份 Auth::verifyAuth 逻辑。
$this->auth->verifyAuth($this->noNeedLogin, $this->noNeedVerify);
if ($this->auth && $this->auth->isLogin) {
$this->view->assign('member', $this->auth->info);
}
$siteConf = ConfModel::cache(true)->column('value', 'name');
$this->view->assign('siteConf', $siteConf);
$links = Links::cache(true)->where('status', 1)->order('sort desc')->select();
$this->view->assign('links', $links);
// 全站置顶公告条(方案 A):仅取当前生效 + 置顶 + 启用的公告
$typeList = Notice::typeList();
$topNotices = array_map(function ($n) use ($typeList) {
$n['type_text'] = $typeList[$n['type']] ?? '公告';
return $n;
}, Notice::getActiveTopNotices());
$this->view->assign('topNotices', $topNotices);
// 全站广告位:按 position 分组的启用广告,模板可随时调用 {notempty name="adSlots.home_top"}
$this->view->assign('adSlots', Ad::getSlots());
// 注入站点信息(route_base / site.module / site.controller 等),供前端 route.js 使用
$this->assignSite();
// 前台主导航菜单(后台可配置,两级下拉),供 header.html 渲染
$this->view->assign('navMenus', \app\backend\model\NavMenu::getNavTree());
$this->initialize();
}
/**
* 控制器初始化 initialize(子类重写).
* @return void
*/
protected function initialize()
{
}
/**
* 赋值到模板.
*/
public function assign($name, $value = null)
{
View::assign($name, $value);
return $this;
}
/**
* 从控制器类名反推插件目录并切换视图根目录到 addon/<name>/view/frontend/.
* @return void
*/
protected function switchAddonViewPath()
{
// 重构版 MultiApp 已将 appPath 设为 addon/<插件>/,直接拼视图目录,无需正则
$this->view->config([
'view_path' => $this->app->getAppPath() . 'view' . DIRECTORY_SEPARATOR
. 'frontend' . DIRECTORY_SEPARATOR,
]);
}
/**
* 当前插件名(从控制器命名空间反推 addon\<name>\....
* 由 appPathaddon/<插件>/)剥 rootPath 取首段,无需正则。
* @return string
*/
protected function currentAddon(): string
{
$rel = trim(substr($this->app->getAppPath(), strlen($this->app->getRootPath())), DIRECTORY_SEPARATOR);
$seg = explode(DIRECTORY_SEPARATOR, $rel);
return $seg[0] === 'addon' && isset($seg[1]) ? $seg[1] : '';
}
/**
* 判断当前控制器是否属于插件上下文(addon\ 命名空间).
* @return bool
*/
protected function isAddonContext(): bool
{
return strpos($this->app->getNamespace(), 'addon\\') === 0;
}
/**
* 获取当前登录用户信息(前台会员).
* @return mixed
*/
protected function user()
{
if ($this->auth && $this->auth->isLogin) {
return $this->auth->info;
}
$this->result->error('获取用户信息失败!');
}
/**
* 渲染模板:插件前台叠加皮肤覆盖层 / 配色变量 / 风格切换器.
*/
protected function fetch($template = '', $vars = [], $replace = [], $config = [])
{
if ($this->isAddonContext()) {
$addon = $this->currentAddon();
$restore = null;
if ($addon && $overlay = SkinOverlay::resolve($addon, 'frontend')) {
// 把视图根目录切到「插件视图 + 皮肤覆盖」合并层,使 extend 的 layout 也走皮肤
$restore = View::getConfig('view_path');
View::config(['view_path' => $overlay]);
}
$result = View::fetch($template, $vars, $replace, $config);
if ($restore !== null) {
View::config(['view_path' => $restore]);
}
// 注入皮肤变量 CSS(Discuz 式配色层,无需改 HTML
$style = SkinVariables::styleTag($addon, 'frontend');
if ($style !== '' && ($pos = stripos($result, '</head>')) !== false) {
$result = substr($result, 0, $pos) . $style . "\n" . substr($result, $pos);
} elseif ($style !== '') {
$result = $style . $result;
}
// 注入前台风格切换器(界面设置开启 allow_member_select 时自动出现)
$switcher = TemplateManager::switcherHtml($addon, 'frontend');
if ($switcher !== '') {
if (($pos = stripos($result, '</body>')) !== false) {
$result = substr($result, 0, $pos) . $switcher . "\n" . substr($result, $pos);
} else {
$result .= $switcher;
}
}
return $result;
}
return View::fetch($template, $vars, $replace, $config);
}
/**
* success/error 已统一提升到 BaseControllersuccess/error 代理 Result),此处不再重复定义。
*/
}
+146
View File
@@ -0,0 +1,146 @@
<?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\controller;
use ywxapp\library\SkinVariables;
use ywxapp\model\MemberUser;
/**
* 会员中心控制器基类(MemberBase)
*
* 同时服务于两类场景:
* 1) 会员中心本体(app/member 应用):view_path = app/member/view/layout = common/layout
* 2) 插件用户中心(addon/<插件>/controller/member/):view_path = addon/<插件>/view/member/layout = member/layout
*
* 鉴权:容器 auth 在 AppService 中默认已绑定 \ywxapp\library\Auth(会员鉴权),
* 故会员登录态由 BaseController::verifyAuth 自动校验(设置 $noNeedLogin 可放行)。
*
* 用法:
* // 会员中心本体控制器
* namespace app\member\controller;
* use ywxapp\controller\MemberBase;
* class Index extends MemberBase { ... }
*
* // 插件用户中心控制器
* namespace addon\blog\controller\member;
* use ywxapp\controller\MemberBase;
* class Index extends MemberBase { ... }
*/
class MemberBase extends BaseController
{
/**
* 视图类实例
* @var \think\View
*/
protected $view;
/**
* 控制器初始化
*/
public function _initialize()
{
MemberUser::ensureSchema(); // 用户中心核心表自愈(缺表/缺列兜底)
$this->view = $this->app->view;
$appName = $this->app->http->getName();
// 全局 layout 模式:会员中心本体套 view/layout,插件用户中心套各自的 view/member/layout。
// 不需要套 layout 的页面(如会员中心 iframe 主框架页)可在控制器内调用 $this->view->layout(false) 关闭。
$layout = 'common/layout';
if ($appName === 'member') {
// 会员中心本体:视图根目录为 app/member/view/layout 为 view/layout.html
$viewPath = $this->app->getAppPath() . 'view' . DIRECTORY_SEPARATOR;
} else {
// 插件用户中心:视图根目录为 addon/<插件>/view/member/
$addon = $this->currentAddon();
$viewPath = $addon
? root_path() . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR
. 'view' . DIRECTORY_SEPARATOR . 'member' . DIRECTORY_SEPARATOR
: $this->app->getAppPath() . 'view' . DIRECTORY_SEPARATOR . 'member' . DIRECTORY_SEPARATOR;
// 插件内使用插件自己的 view/member/layout.htmlview_path 已含 member/
$layout = 'common/layout';
}
$this->view->config([
'view_suffix' => 'html',
'view_path' => $viewPath,
'layout_on' => true,
'layout_name' => $layout,
]);
// 登录与权限校验:统一走 Base 的 verifyAuth + $noNeedLogin 机制
// (未登录且非免登录 action 时整页 302 跳登录页,AJAX 返回 401,与后台一致)。
// 注意:verifyAuth 内部会 tokenParse() 还原登录态,无需在中间件重复处理。
$this->auth->verifyAuth($this->noNeedLogin, $this->noNeedVerify);
if ($this->auth && $this->auth->isLogin) {
$this->view->assign('member', $this->auth->info);
}
$siteConf = \ywxapp\model\Configure::cache(true)->column('value', 'name');
$this->view->assign('siteConf', $siteConf);
$links = \ywxapp\model\Links::cache(true)->where('status', 1)->order('sort desc')->select();
$this->view->assign('links', $links);
// 注入站点信息(route_base / site.module / site.controller 等),供前端 route.js 使用
$this->assignSite();
$this->initialize();
}
/**
* 子类初始化钩子
*/
protected function initialize() {}
/**
* 获取当前登录会员信息(未登录直接报错)
* @return \ywxapp\model\Member
*/
protected function user()
{
if ($this->auth && $this->auth->isLogin) {
return $this->auth->info;
}
$this->result->error('请先登录会员中心');
}
/**
* 当前插件名(addon\<name>\...
* 由 appPathaddon/<插件>/)剥 rootPath 取首段,无需正则。
*/
protected function currentAddon(): string
{
$rel = trim(substr($this->app->getAppPath(), strlen($this->app->getRootPath())), DIRECTORY_SEPARATOR);
$seg = explode(DIRECTORY_SEPARATOR, $rel);
return $seg[0] === 'addon' && isset($seg[1]) ? $seg[1] : '';
}
/**
* 渲染模板:优先使用已激活模板的覆盖页(Discuz 式局部覆盖),未命中回退插件默认视图。
*/
protected function fetch($template = '', $vars = [], $replace = [], $config = [])
{
$addon = $this->currentAddon();
$restore = null;
if ($addon && $overlay = \ywxapp\library\SkinOverlay::resolve($addon, 'member')) {
// 把视图根目录切到「插件视图 + 皮肤覆盖」合并层,使 extend 的 layout 也走皮肤
$restore = \think\facade\View::getConfig('view_path');
\think\facade\View::config(['view_path' => $overlay]);
}
$result = \think\facade\View::fetch($template, $vars, $replace, $config);
if ($restore !== null) {
\think\facade\View::config(['view_path' => $restore]);
}
// 注入皮肤变量 CSS(Discuz 式配色层,无需改 HTML
$style = SkinVariables::styleTag($addon, 'member');
if ($style !== '' && ($pos = stripos($result, '</head>')) !== false) {
$result = substr($result, 0, $pos) . $style . "\n" . substr($result, $pos);
} elseif ($style !== '') {
$result = $style . $result;
}
return $result;
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\controller;
use ywxapp\model\Attachment;
use ywxapp\service\FileStorageService;
/**
* UeditorPlus
*
* @author ywxapp <admin@ywxapp.cn>
*/
class UeditorPlus
{
private $user;
public function __construct()
{
if (app('auth')) {
$this->user = app()->auth->info;
}
}
/**
* 获取配置 config
* GET action config 请求动作名称
*/
public function config()
{
return json(config('ueditor-plus'));
}
/**
* 上传图片 image
* GET action image 请求动作名称,可通过 imageActionName 配置修改名称
* FILE file file 上传的文件,文件表单名称可通过 imageFieldName 配置修改名称
*return { "state":"SUCCESS", "url":"upload/demo.jpg", "title":"demo.jpg", "original":"demo.jpg"; }
*/
public function image()
{
$file = request()->file('file');
// return json(['data'=> app()->auth->info]);
try {
$storage = new FileStorageService();
$data = $storage->upload($file, 'images');
$info = $this->toSaveData($data);
return json([
'mime' => $data['mime'],
"state" => "SUCCESS",
"url" => $info->url,
"title" => $info->name,
"original" => $info->original,
]);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
}
/**
* 图片抓取 catch
* GET action catch 请求动作名称,可通过 catcherActionName 配置修改名称
* POST source url 抓取的图片地址,文件表单名称可通过 catcherFieldName 配置修改名称
*/
public function catch ()
{
$file = request()->file('file');
try {
// 实例化上传服务
$storage = new FileStorageService();
// 执行上传
// 这里的逻辑不需要关心具体是上传到哪里,由配置决定
$result = $storage->upload($file, 'avatar/' . date('Ymd'));
return json(['code' => 0, 'msg' => '上传成功', 'data' => $result]);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
}
/**
* 视频上传 video
* GET action video 请求动作名称,可通过 videoActionName 配置修改名称
* FILE file file 上传的文件,文件表单名称可通过 videoFieldName 配置修改名称
*/
public function video()
{
$file = request()->file('file');
$data = [
'size' => $file->getSize(),
'Mime' => $file->getOriginalMime(),
];
return json(['code' => 0, 'msg' => '上传成功', 'data' => $data]);
try {
// 实例化上传服务
$storage = new FileStorageService();
// 执行上传
// 这里的逻辑不需要关心具体是上传到哪里,由配置决定
$result = $storage->upload($file, 'avatar/' . date('Ymd'));
return json(['code' => 0, 'msg' => '上传成功', 'data' => $result]);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
}
/**
* 文件上传 file
* GET action file 请求动作名称,可通过 fileActionName 配置修改名称
* FILE file file 上传的文件,文件表单名称可通过 fileFieldName 配置修改名称
*/
public function file()
{
$file = request()->file('file');
try {
// 实例化上传服务
$storage = new FileStorageService();
// 执行上传
// 这里的逻辑不需要关心具体是上传到哪里,由配置决定
$result = $storage->upload($file, 'avatar/' . date('Ymd'));
return json(['code' => 0, 'msg' => '上传成功', 'data' => $result]);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
}
/**
* 图片列表 listImage
* GET action listImage 请求动作名称,可通过 imageManagerActionName 配置修改名称
*/
public function listImage()
{
try {
$user = app()->auth->info;
$datas = Attachment::where('uid', $user->uid)->where('mime', 'like', 'image%')->field('name,url')->select();
return json([
"state" => "SUCCESS",
"list" => $datas,
"start" => 0,
"total" => count($datas),
]);
} catch (\Exception $e) {
// 记录日志,但不要因为入库失败而中断上传流程(或者根据需求决定)
\think\facade\Log::error( $e->getMessage());
}
}
/**
* 文件列表 listFile
* GET action listFile 请求动作名称,可通过 fileManagerActionName 配置修改名称
*/
public function listFile()
{
}
/**
* 将文件信息保存到数据库
*/
protected function toSaveData(array $data, array $options = [])
{
try {
$user = app()->auth->info;
$data['uid'] = $user->uid; // 根据你的登录逻辑获取用户ID
$info = Attachment::create($data);
return $info;
} catch (\Exception $e) {
// 记录日志,但不要因为入库失败而中断上传流程(或者根据需求决定)
\think\facade\Log::error('文件记录入库失败: ' . $e->getMessage());
}
}
}
+157
View File
@@ -0,0 +1,157 @@
<?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\controller;
use think\facade\Config;
use think\facade\Request;
use ywxapp\exceptions\UploadException;
use ywxapp\service\FileStorageService;
/**
* UploadController
*
* @author ywxapp <admin@ywxapp.cn>
*/
class UploadController extends BaseController
{
/**
* 不需要登录验证的方法
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* 不需要权限验证的方法
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 initialize
* @return void
*/
protected function initialize()
{}
/**
* 单文件上传
* @return \think\response\Json
*/
public function upload()
{
try {
// 获取上传的文件
$file = Request::file('file');
if (! $file || ! $file->isValid()) {
throw UploadException::fileNotExists();
}
// 获取存储驱动(可从前端指定)
$driver = Request::param('driver', Config::get('upload.default'));
$path = Request::param('type', '');
$filename = Request::param('filename', '');
$storage = new FileStorageService();
$result = $storage->upload($file, $path . '/' . date('Ymd'));
$this->result->success($result);
} catch (\Exception $e) {
$this->result->error($e->getMessage(), 500);
}
}
/**
* 多文件上传
* @return \think\response\Json
*/
// public function batchUpload()
// {
// try {
// $files = Request::file('files');
// if (empty($files)) {
// throw UploadException::fileNotExists();
// }
// $driver = Request::param('driver', Config::get('upload.default'));
// $path = Request::param('path', '');
// $uploadService = new UploadService($driver);
// $results = $uploadService->batchUpload($files, $path);
// // 检查是否有失败的上传
// $hasError = collect($results)->some(function ($item) {
// return isset($item['error']);
// });
// if ($hasError) {
// return json($this->result->error('部分文件上传失败', 200, $results));
// }
// return json($this->result->success($results));
// } catch (UploadException $e) {
// return json($this->result->error($e->getMessage(), 400));
// } catch (\Exception $e) {
// return json($this->result->error($e->getMessage(), 500));
// }
// }
/**
* Base64上传
* @return \think\response\Json
*/
// public function base64Upload()
// {
// try {
// $base64 = Request::param('base64', '');
// if (empty($base64)) {
// throw UploadException::fileNotExists();
// }
// // 解析base64
// if (preg_match('/^(data:\s*image\/(\w+);base64,)/', $base64, $matches)) {
// $ext = $matches[2];
// $data = base64_decode(str_replace($matches[1], '', $base64));
// // 创建临时文件
// $tempFile = tmpfile();
// fwrite($tempFile, $data);
// $meta = stream_get_meta_data($tempFile);
// $tempPath = $meta['uri'];
// // 创建UploadedFile对象
// $file = new \think\file\UploadedFile(
// $tempPath,
// 'base64_image.' . $ext,
// mime_content_type($tempPath),
// filesize($tempPath),
// UPLOAD_ERR_OK
// );
// $driver = Request::param('driver', Config::get('upload.default'));
// $path = Request::param('path', '');
// $uploadService = new UploadService($driver);
// $result = $uploadService->upload($file, $path);
// fclose($tempFile);
// return json($this->result->success($result));
// }
// throw new \Exception('无效的Base64数据');
// } catch (UploadException $e) {
// return json($this->result->error($e->getMessage(), 400));
// } catch (\Exception $e) {
// return json($this->result->error($e->getMessage(), 500));
// }
// }
}
+73
View File
@@ -0,0 +1,73 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\event;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
use ywxapp\library\Result;
/**
* Email
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Email
{
protected $mail;
public function __construct()
{
$this->mail = new PHPMailer(true);
// 配置 SMTP 参数
$this->mail->isSMTP(); // 使用 SMTP
$this->mail->Host = 'mail.xixingwl.cn'; // SMTP 服务器地址
$this->mail->SMTPAuth = true; // 开启 SMTP 认证
$this->mail->Username = 'test@xixingwl.cn'; // SMTP 用户名
$this->mail->Password = 'Y20mylove.'; // SMTP 密码
$this->mail->SMTPSecure = 'tls'; // 加密协议(tls 或 ssl
$this->mail->Port = 587; // SMTP 端口
$this->mail->SMTPOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
]
];
}
public function sendEmail($to, $subject, $body)
{
try {
// 设置收件人
$this->mail->setFrom('test@xixingwl.cn', 'From Name');
$this->mail->addAddress($to); // 添加收件人
// 设置邮件内容
$this->mail->isHTML(true); // 设置邮件格式为 HTML
$this->mail->Subject = $subject;
$this->mail->Body = $body;
// 发送邮件
$this->mail->send();
return true;
} catch (Exception $e) {
Result::instance()->error(message: "Message could not be sent. Mailer Error: {$this->mail->ErrorInfo}");
return false;
}
}
public function handle($mail)
{
echo "1244";
$subject = '欢迎注册';
$body = "亲爱的 {$mail['email']},感谢您注册我们的网站!";
return $this->sendEmail($mail['email'], $subject, $body);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?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\exception;
use think\Exception;
/**
* 插件异常处理类
* @package think\addon
*/
class AddonException extends Exception
{
public function __construct($message, $code = 0, $data = '')
{
$this->message = $message;
$this->code = $code;
$this->data = $data;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\exceptions;
/**
* Class AdminException
* @package ywxapp\exceptions
*/
class AdminException extends \RuntimeException
{
public function __construct($message, $replace = [], $code = 0, \Throwable $previous = null)
{
if (is_array($message)) {
$errInfo = $message;
$message = $errInfo[1] ?? '未知错误';
if ($code === 0) {
$code = $errInfo[0] ?? 400;
}
}
if (is_numeric($message)) {
$code = $message;
$message = getLang($message, $replace);
}
parent::__construct($message, $code, $previous);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\exceptions;
/**
* API应用错误信息
* Class ApiException
* @package ywxapp\exceptions
*/
class ApiException extends \RuntimeException
{
public function __construct($message, $replace = [], $code = 0, \Throwable $previous = null)
{
if (is_array($message)) {
$errInfo = $message;
$message = $errInfo[1] ?? '未知错误';
if ($code === 0) {
$code = $errInfo[0] ?? 400;
}
}
if (is_numeric($message)) {
$code = $message;
$message = getLang($message, $replace);
}
parent::__construct($message, $code, $previous);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\exceptions;
/**
* API应用错误信息
* Class ApiException
* @package ywxapp\exceptions
*/
class ApiStatusException extends \RuntimeException
{
protected $apiStatus;
protected $apiData;
public function __construct($status, $message, $data = [], $replace = [], $code = 0, \Throwable $previous = null)
{
if (is_array($message)) {
$errInfo = $message;
$message = $errInfo[1] ?? '未知错误';
if ($code === 0) {
$code = $errInfo[0] ?? 400;
}
}
if (is_numeric($message)) {
$code = $message;
$message = getLang($message, $replace);
}
$this->apiData = $data;
$this->apiStatus = $status;
parent::__construct($message, $code, $previous);
}
public function getApiStatus()
{
return $this->apiStatus;
}
public function getApiData()
{
return $this->apiData;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\exceptions;
/**
* Class AuthException
* @package ywxapp\exceptions
*/
class AuthException extends \RuntimeException
{
public function __construct($message = "", $replace = [], $code = 0, \Throwable $previous = null)
{
if (is_array($message)) {
$errInfo = $message;
$message = $errInfo[1] ?? '未知错误';
if ($code === 0) {
$code = $errInfo[0] ?? 400;
}
}
if (is_numeric($message)) {
$code = $message;
$message = getLang($message, $replace);
}
parent::__construct($message, $code, $previous);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\exceptions;
/**
* Class CrudException
* @package ywxapp\exceptions
*/
class CrudException extends \RuntimeException
{
public function __construct($message = "", $replace = [], $code = 0, \Throwable $previous = null)
{
if (is_array($message)) {
$errInfo = $message;
$message = $errInfo[1] ?? '未知错误';
if ($code === 0) {
$code = $errInfo[0] ?? 400;
}
}
if (is_numeric($message)) {
$code = $message;
$message = getLang($message, $replace);
}
parent::__construct($message, $code, $previous);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\exceptions;
/**
* Class PayException
* @package ywxapp\exceptions
*/
class PayException extends \RuntimeException
{
public function __construct($message, $replace = [], $code = 0, \Throwable $previous = null)
{
if (is_array($message)) {
$errInfo = $message;
$message = $errInfo[1] ?? '未知错误';
if ($code === 0) {
$code = $errInfo[0] ?? 400;
}
}
if (is_numeric($message)) {
$code = $message;
$message = getLang($message, $replace);
}
parent::__construct($message, $code, $previous);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\handler;
use Throwable;
use think\Response;
use think\exception\Handle;
use think\exception\HttpException;
use think\exception\ValidateException;
use think\exception\HttpResponseException;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
/**
* 应用异常处理类.
*/
class ExceptionHandle extends Handle
{
/**
* 不需要记录信息(日志)的异常类列表.
*
* @var array
*/
protected $ignoreReport = [
HttpException::class,
HttpResponseException::class,
ModelNotFoundException::class,
DataNotFoundException::class,
ValidateException::class,
];
/**
* 记录异常信息(包括日志或者其它方式记录).
*
* @param Throwable $exception
*
* @return void
*/
public function report(Throwable $exception): void
{
event('app_exception_report', ['exception'=>$exception,'ignoreReport'=>$this->ignoreReport]);
// 使用内置的方式记录异常日志
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @param \think\Request $request
* @param Throwable $e
*
* @return Response
*/
public function render($request, Throwable $e): Response
{
event('app_exception', $e);
// 添加自定义异常处理机制
// 其他错误交给系统处理
return parent::render($request, $e);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
if (!function_exists('addon_url')) {
/**
* 生成插件访问地址(MultiApp 多应用模型:/<addon>/<controller>/<action>
* @param string $name 插件标识
* @param string $url 相对路径,如 'admin/index' '/admin/index'
* @param array $vars 附加 GET 参数
* @return string
*/
function addon_url(string $name, string $url = '', array $vars = []): string
{
$path = '/' . trim($name, '/');
if ($url !== '' && $url !== '/') {
$path .= '/' . ltrim($url, '/');
}
return $vars ? $path . '?' . http_build_query($vars) : $path;
}
}
if (!function_exists('get_addon_instance')) {
/**
* 获取插件主类实例(addon\<name>\Addon
* @param string $name 插件标识
* @return object|null
*/
function get_addon_instance(string $name)
{
$class = '\\addon\\' . $name . '\\Addon';
if (!class_exists($class)) {
return null;
}
return app($class);
}
}
if (!function_exists('get_addon_info')) {
/**
* 读取插件信息(优先缓存,force=true 强制重读)
* @param string $name 插件标识
* @param bool $force
* @return array
*/
function get_addon_info(string $name = '', bool $force = false): array
{
$instance = get_addon_instance($name);
return $instance ? $instance->getInfo($name, $force) : [];
}
}
if (!function_exists('get_addon_config')) {
/**
* 读取插件配置(优先缓存,force=true 强制重读)
* @param string $name 插件标识
* @param bool $force
* @return array
*/
function get_addon_config(string $name = '', bool $force = false): array
{
$instance = get_addon_instance($name);
return $instance ? $instance->getConfig($name, $force) : [];
}
}
if (!function_exists('is_market_client')) {
/**
* 市场是否处于「客户机」模式(「谁是中心站」由 config/ywxapp.php 决定):
* - market_mode=center 强制中心站
* - market_mode=client 强制客户机
* - market_mode=auto(默认):
* · api_url 为空 本机即中心站(直接读写本地市场 wxapp_appmarket_addon_list
* · api_url 指向本机(同主机)→ 仍视为中心站(配置纠偏)
* · api_url 指向其他主机 本机作为客户机,经 RemoteService 连接该中心站
*
* auto 模式下依赖「当前请求主机」判定自身:改用框架 Request 抽象(可测试、CLI 可控),
* 不再直接读 $_SERVER['HTTP_HOST']。CLI(路由缓存/refresh-menu)无法取得主机时保守判为客户机——
* 因此中心站请保持 api_url 为空,或显式设置 market_mode=center 以彻底解除该约束。
* @return bool
*/
function is_market_client(): bool
{
$mode = \think\facade\Config::get('ywxapp.market_mode', 'auto');
if ($mode === 'center') {
return false;
}
if ($mode === 'client') {
return true;
}
// auto:基于 api_url + 自身主机推导
$apiUrl = \think\facade\Config::get('ywxapp.api_url', '');
if (empty($apiUrl)) {
return false;
}
$host = parse_url($apiUrl, PHP_URL_HOST);
if (empty($host)) {
return false;
}
// 用框架 Request 抽象主机(可 mock / CLI 可控),替代直接读 $_SERVER['HTTP_HOST']
$req = function_exists('request') ? request() : null;
$self = $req ? (string) $req->host() : '';
if ($self === '') {
// CLI(如路由缓存/refresh-menu)无法判定主机:回退到 market_mode 显式配置。
// 只有显式声明 market_mode=client 才当作客户机;否则(auto/center)按中心站处理,
// 避免中心站 CLI 刷新菜单时误删 centerOnly 项(应用市场审核/开发者/收益等运营菜单)。
return \think\facade\Config::get('ywxapp.market_mode', 'auto') === 'client';
}
$selfHost = preg_replace('/:\d+$/', '', $self);
$cmpHost = preg_replace('/:\d+$/', '', $host);
if (strcasecmp($selfHost, $cmpHost) === 0) {
return false; // api_url 指向自身 = 视为中心站(配置纠偏)
}
return true;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
return [
'hello' => 'Hello',
'welcome' => 'Welcome to ThinkPHP 8!',
'username' => 'Username',
'password' => 'Password',
'login' => 'Login',
'logout' => 'Logout',
'submit' => 'Submit',
'cancel' => 'Cancel',
'required_field' => 'Required field',
'invalid_email' => 'Invalid email address',
'please_enter_username'=> 'Please enter username',
'please_enter_password'=> 'Please enter password',
];
+25
View File
@@ -0,0 +1,25 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
return [
'hello' => '你好',
'welcome' => '欢迎使用 ThinkPHP 8!',
'username' => '用户名',
'password' => '密码',
'login' => '登录',
'logout' => '退出',
'submit' => '提交',
'cancel' => '取消',
'required_field' => '必填字段',
'invalid_email' => '无效的电子邮件地址',
'please_enter_username'=> '请输入用户名',
'please_enter_password'=> '请输入密码',
'No Results were found' => "未找到结果!",
];
+104
View File
@@ -0,0 +1,104 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
return [
'addon %s not found' => '插件未找到',
'addon %s is disabled' => '插件已禁用',
'addon controller %s not found' => '插件控制器未找到',
'addon action %s not found' => '插件控制器方法未找到',
'addon can not be empty' => '插件不能为空',
'Keep login' => '保持会话',
'Forgot password' => '忘记密码?',
'Username' => '用户名',
'User id' => '会员ID',
'Nickname' => '昵称',
'Password' => '密码',
'Sign up' => '注 册',
'Sign in' => '登 录',
'Sign out' => '注 销',
'Guest' => '游客',
'Welcome' => '%s,你好!',
'Add' => '添加',
'Edit' => '编辑',
'Delete' => '删除',
'Move' => '移动',
'Name' => '名称',
'Status' => '状态',
'Weigh' => '权重',
'Operate' => '操作',
'Warning' => '温馨提示',
'Default' => '默认',
'Article' => '文章',
'Page' => '单页',
'OK' => '确定',
'Cancel' => '取消',
'Loading' => '加载中',
'More' => '更多',
'Normal' => '正常',
'Hidden' => '隐藏',
'Submit' => '提交',
'Reset' => '重置',
'Execute' => '执行',
'Close' => '关闭',
'Search' => '搜索',
'Refresh' => '刷新',
'First' => '首页',
'Previous' => '上一页',
'Next' => '下一页',
'Last' => '末页',
'None' => '无',
'Online' => '在线',
'Logout' => '注销',
'Profile' => '个人资料',
'Index' => '首页',
'Hot' => '热门',
'Recommend' => '推荐',
'Dashboard' => '控制台',
'Code' => '编号',
'Message' => '内容',
'Line' => '行号',
'File' => '文件',
'Menu' => '菜单',
'Type' => '类型',
'Title' => '标题',
'Content' => '内容',
'Append' => '追加',
'Memo' => '备注',
'Parent' => '父级',
'Params' => '参数',
'Permission' => '权限',
'Begin time' => '开始时间',
'End time' => '结束时间',
'Create time' => '创建时间',
'Flag' => '标志',
'Home' => '首页',
'Store' => '插件市场',
'Services' => '服务',
'Download' => '下载',
'Demo' => '演示',
'Donation' => '捐赠',
'Forum' => '社区',
'Docs' => '文档',
'Go back' => '返回首页',
'Jump now' => '立即跳转',
'Please login first' => '请登录后再操作',
'Send verification code' => '发送验证码',
'Redirect now' => '立即跳转',
'Operation completed' => '操作成功!',
'Operation failed' => '操作失败!',
'Unknown data format' => '未知的数据格式!',
'Network error' => '网络错误!',
'Advanced search' => '高级搜索',
'Invalid parameters' => '未知参数',
'No results were found' => '记录未找到',
'Parameter %s can not be empty' => '参数%s不能为空',
'You have no permission' => '你没有权限访问',
'An unexpected error occurred' => '发生了一个意外错误,程序猿正在紧急处理中',
'This page will be re-directed in %s seconds' => '页面将在 %s 秒后自动跳转',
];
+226
View File
@@ -0,0 +1,226 @@
<?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\library;
use think\App;
use think\exception\HttpException;
use think\facade\Db;
use think\facade\Event;
use think\facade\Validate;
use ywxapp\library\Result;
use ywxapp\model\BackendAdmin as AdminModel;
use ywxapp\service\JwtService;
use ywxapp\utils\Random;
/**
* 后台(管理员)鉴权类,继承前台 Auth,仅重写差异钩子与业务逻辑。
*
* 差异点:
* - 模型:Backend 模型(主键 id
* - 角色字段:roles;权限集:getPermissionNames()
* - 登录事件:user_login_successed
* - 登出:不清整个 session(仅清 token),保持与原 Backend 行为一致
* - 登录/注册/改密:后台业务逻辑(查 Backend 表)
*/
class AdminAuth extends Auth
{
/**
* 超级管理员
*/
protected $isSuperAdmin = false;
/**
* 超级管理员
*/
private $superAdmin = 1;
/**
* 允许查询的字段(后台)
* @var array
*/
protected $allowFields = ['id', 'account', 'nickname', 'roles', 'avatar', 'score'];
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app, $options = [])
{
parent::__construct($app, $options);
$this->isAdmin = true; // 标记后台管理员上下文,供 tryLoginByToken 做 isAdmin 一致性校验
}
/**
* 返回管理员模型类(主键为 id
*/
protected function getUserModel(): string
{
return AdminModel::class;
}
/**
* 后台角色取自 roles 字段(前台 Auth 默认取 groups,此处重写)
*/
protected function resolveRoles($info)
{
return $info->roles ?? [];
}
/**
* 管理员权限取自模型方法(后台式权限集)
*/
protected function resolvePowers($info): array
{
return $info->getPermissionNames();
}
/**
* 管理员不存在时的错误提示
*/
protected function notFoundMessage($id): string
{
return "AdminId:$id is incorrect";
}
/**
* 管理员登录成功后触发事件
*/
protected function afterLogin($info): void
{
Event::trigger('user_login_successed', $this->info);
}
/**
* 后台登出:仅清 token(不清整个 session),保持与原 Backend 行为一致
*/
protected function afterLogout(): void {}
/**
* 添加管理员(后台).
*
* @param string $username 用户名
* @param string $password 密码
* @param array $extend 扩展参数
* @return void
*/
public function register($account = '', $password = '', $email = '', $mobile = '', $extend = [])
{
AdminModel::ensureSchema(); // 后台核心表自愈(缺表/缺列兜底)
if (AdminModel::getByAccount($account)) {
Result::instance()->error('Account already exist');
}
$data = [
'password' => password_hash($password ? $password : Random::alpha(6), PASSWORD_DEFAULT, ['cost' => 12]),
'status' => 0,
't1' => $account,
];
$field = Validate::is($account, 'email') ? 'email' : (Validate::is($account, 'mobile') ? 'mobile' : 'account');
$data[$field] = $account;
$data = array_merge($data, $extend);
$params = Event::trigger('AdminBeforeRegister', $data, true);
$data = array_merge($data, $params);
Db::startTrans();
try {
$info = AdminModel::create($data);
$this->info = AdminModel::find($info->id);
Event::trigger('AdminAfterRegister', $this->info);
Db::commit();
$newClaims = [
'uid' => $info->id,
'isAdmin' => true,
'account' => $info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
} catch (\think\Exception $e) {
Db::rollback();
Result::instance()->error($e->getMessage());
}
}
/**
* 管理员登录(后台).
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @return void
*/
public function login($account, $password, $isAuthPass = true)
{
AdminModel::ensureSchema(); // 后台核心表自愈(缺表/缺列兜底)
$field = Validate::is($account, 'email') ? 'email' : (Validate::regex(
$account,
'/^1\d{10}$/'
) ? 'mobile' : 'account');
$info = AdminModel::where([$field => $account])->find();
if (! $info) {
Result::instance()->error('Account is incorrect');
}
if ($info->status != 1) {
Result::instance()->error('Account is locked');
}
$info->resetPassword($password);
// 验证密码(直接使用库中存储的哈希,禁止先 resetPassword
if (! $info->checkPassword($password)) {
$info->recordLoginFail($this->app->request->ip()); // 记录失败
Result::instance()->error('密码错误', 4011);
}
$info->recordLoginSuccess();
$newClaims = [
'uid' => $info->id,
'isAdmin' => true,
'role' => 'admin',
'account' => $info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
$this->initUser($info->id);
}
/**
* 修改密码(后台)
* @param string $newpassword 新密码
* @param string $oldpassword 旧密码
* @param bool $ignoreoldpassword 忽略旧密码
* @return boolean
*/
public function changepwd($newpassword, $oldpassword = '', $ignoreoldpassword = false)
{
if (! $this->_logined) {
$this->setError('You are not logged in');
return false;
}
//判断旧密码是否正确
if ($this->_user->password == $this->getEncryptPassword($oldpassword, $this->_user->salt) || $ignoreoldpassword) {
Db::startTrans();
try {
$salt = Random::alnum();
$newpassword = $this->getEncryptPassword($newpassword, $salt);
$this->_user->save(['loginfailure' => 0, 'password' => $newpassword, 'salt' => $salt]);
Token::clear($this->_user->uid);
//修改密码成功的事件
Hook::listen("user_changepwd_successed", $this->_user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
} else {
$this->setError('Password is incorrect');
return false;
}
}
}
+671
View File
@@ -0,0 +1,671 @@
<?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\library;
use think\App;
use think\exception\HttpException;
use think\facade\Cookie;
use think\facade\Db;
use think\facade\Session;
use think\facade\Validate;
use ywxapp\library\Result;
use ywxapp\model\BackendAdmin;
use ywxapp\model\MemberUser as UserModel;
use ywxapp\service\JwtService;
use ywxapp\utils\Random;
/**
* 前台(会员/API)鉴权类
*
* 负责会员中心的认证:按 Member 表(主键 uid)查用户、解析会员角色/权限,
* 以及登录/注册/改密等前台业务逻辑。
* 后台鉴权由 AdminAuth 继承本类,仅重写差异钩子(不同模型、权限集、登录事件)。
*/
class Auth
{
protected $app;
/**
* 是否登录
* @var bool
*/
protected $isLogin = false;
/**
* 是否管理员?
*/
protected $isAdmin = false;
/**
* 当前登录用户信息
* @var \ywxapp\model\Member
*/
protected $model;
/**
* 个人信息
* @var \ywxapp\model\Member
*/
protected $info;
/**
* 权限规则
* @var array
*/
protected $powers;
/**
* 角色
* @var array
*/
protected $roles;
/**
* 用户资料,用于临时存放一些用户数据,比如用户IP地址,需要使用后销毁
* @var array
*/
protected $profile;
/**
* 允许查询的字段(前台)
* @var array
*/
protected $allowFields = ['uid', 'account', 'nickname', 'roles', 'avatar', 'score'];
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app, $options = [])
{
$this->app = $app;
}
/**
* 尝试用 access_token 解析并登录(对外可独立调用,如中间件)。
*
* 设计原则:** token 才解析并登录,没有 token 则静默跳过**(不报错、不登录),
* 从而天然支持「访客访问无需登录的页面」与「携带 token 自动登录」两种场景。
*
* @return bool 是否成功还原登录态
*/
public function tryInitByToken(): bool
{
// 已登录则直接返回,避免重复解析与重复 initUser
if ($this->isLogin) {
return true;
}
$tokenStr = $this->resolveAccessToken();
if (empty($tokenStr)) {
// 没有 token:不解析、不登录
return false;
}
try {
$claims = $this->parseClaims($tokenStr);
// 上下文一致性校验:token 的 isAdmin 声明必须与当前 auth 类型匹配
// (前台 isAdmin=false / 后台 AdminAuth isAdmin=true),防止前后台 token 串用越权。
if (($claims['isAdmin'] ?? false) !== $this->isAdmin) {
throw new \Exception('auth context mismatch');
}
$this->initUser($claims['uid']);
return true;
} catch (\Exception $e) {
// token 无效/过期:清除解析缓存
\think\facade\Cache::delete('auth_token_' . md5($tokenStr));
// 尝试用 refresh_token 自动续期(整页/iframe 场景也能续期,避免被踢回登录页)
$this->tryRefresh();
return $this->isLogin;
}
}
/**
* 登录态校验(供后台/前台/会员中心控制器层 verifyAuth 统一调用)
* 先尝试用 token 还原登录态,再返回是否登录。
*/
public function checkLogin(): bool
{
$this->tryInitByToken();
return (bool) $this->isLogin;
}
/**
* 鉴权前置:尝试用 token 还原登录态(兼容旧调用点 verifyAuth / checkLogin)。
*/
protected function tokenParse()
{
$this->tryInitByToken();
}
/**
* 初始化登录用户(模板方法)
* 公共流程:按主键查模型 登录连续性统计 解析角色/权限 标记登录态 触发事件。
* 与具体模型/角色/权限相关、前后台不同的部分,下沉到下方 protected 钩子,
* 从而天然适配前后台不同的主键(admin=id / user=uid)与不同的权限体系。
*
* @param mixed $id 用户主键(admin iduser uid
* @return static
*/
public function initUser($id)
{
$modelClass = $this->getUserModel();
$info = $modelClass::findOrEmpty($id);
if ($info->isEmpty()) {
Result::instance()->error($this->notFoundMessage($id));
}
Db::startTrans();
try {
if ($info->update_at < \ywxapp\utils\Date::unixtime('day')) {
$info->successions = $info->logintime < \ywxapp\utils\Date::unixtime(
'day',
-1
) ? 1 : $info->successions + 1;
$info->maxsuccessions = max($info->successions, $info->maxsuccessions);
}
$info->save();
$this->roles = $this->resolveRoles($info);
$this->powers = $this->resolvePowers($info);
$this->isLogin = true;
$this->info = $info->toArray();
$this->model = $info;
$this->afterLogin($info);
Db::commit();
} catch (\think\Exception $e) {
Db::rollback();
throw new HttpException(4001, $e->getMessage());
}
return $this;
}
/**
* 返回当前上下文的用户模型类(核心 PK 适配钩子)。
* 前台(Auth)默认 Member 模型(主键 uid);后台(AdminAuth)重写为 Backend 模型(主键 id)。
*/
protected function getUserModel(): string
{
return UserModel::class;
}
/**
* 根据模型实例解析角色集合。前台会员角色取自 groups 字段;后台在 AdminAuth 重写为 roles。
*/
protected function resolveRoles($info)
{
return $info->groups ?? [];
}
/**
* 根据模型实例解析权限集。前台会员默认无后台式权限集,由控制器 noNeedVerify 控制访问。
*/
protected function resolvePowers($info): array
{
return [];
}
/**
* 用户不存在时的错误提示
*/
protected function notFoundMessage($id): string
{
return "UserId:$id is incorrect";
}
/**
* 登录成功后的事件钩子(前台触发 UserLogined
*/
protected function afterLogin($info): void
{
event('UserLogined', $this->info);
}
/**
* 退出登录后的钩子。前台会员:清空整个 session 并触发退出事件(由 Auth::logout 调用)。
* 后台 AdminAuth 重写为空(仅清 token,保持与原 Backend 行为一致)。
*/
protected function afterLogout(): void
{
\think\facade\Session::clear();
event('user_logout_after', $this->model);
}
/**
* 解析 access_token claims(带缓存)
* @param string $tokenStr
* @return array
*/
protected function parseClaims(string $tokenStr): array
{
$cacheKey = 'auth_token_' . md5($tokenStr);
$claims = \think\facade\Cache::get($cacheKey);
if (!$claims) {
$token = JwtService::instance()->parseAndValidate($tokenStr);
$claims = $token->claims()->all();
\think\facade\Cache::set($cacheKey, $claims, 300);
}
return $claims;
}
/**
* header / Session / Cookie 中解析当前 access_token
*/
protected function resolveAccessToken(): string
{
$authHeader = $this->app->request->header('authorization');
if (!empty($authHeader) && str_starts_with($authHeader, 'Bearer ')) {
return (string) substr($authHeader, 7);
}
$token = Session::get('access_token');
if (!empty($token)) {
return (string) $token;
}
return (string) \think\facade\Cookie::get('access_token');
}
/**
* 解析可用于续期的 refresh_tokenheader / Session / Cookie
*/
protected function resolveRefreshToken(): string
{
$authHeader = $this->app->request->header('authorization_refresh');
if (!empty($authHeader) && str_starts_with($authHeader, 'Bearer ')) {
return (string) substr($authHeader, 7);
}
$token = Session::get('refresh_token');
if (!empty($token)) {
return (string) $token;
}
return (string) \think\facade\Cookie::get('refresh_token');
}
/**
* 使用 refresh_token 自动续期 access_token。
* 用于整页/iframe 等非 ajax 场景:access_token 过期但 refresh_token 仍有效时,
* 后端透明生成新 access_token 并写回 Session / Cookie,避免子页面被踢回登录页。
*/
protected function tryRefresh(): void
{
$refreshToken = $this->resolveRefreshToken();
if (empty($refreshToken)) {
return;
}
try {
$data = JwtService::instance()->refreshAccessToken($refreshToken);
$newToken = $data['access_token'] ?? '';
if (empty($newToken)) {
return;
}
Session::set('access_token', $newToken);
// 同步写入非 httpOnly cookie,便于前端与 iframe 后续请求携带
\think\facade\Cookie::set('access_token', $newToken, ['httponly' => false, 'path' => '/']);
$claims = JwtService::instance()->parseAndValidate($newToken)->claims()->all();
// 续期得到的 token 也必须与当前上下文一致,否则视为无效
if (($claims['isAdmin'] ?? false) !== $this->isAdmin) {
return;
}
$this->initUser($claims['uid']);
} catch (\Exception $e) {
// 续期失败,保持未登录,交由 verifyAuth 返回 401
}
}
/**
* 用户权限验证
*/
public function verifyAuth($noNeedLogin = [], $noNeedRight = [])
{
// 先尝试用 token 还原登录态:登录态由 tryInitByToken 写入 $this->isLogin
// 必须由本方法自行触发,不能依赖外部(核心后台靠容器单例的历史初始化、
// 插件后台 new AdminAuth 全新实例都不可靠,会导致 isLogin 恒为默认 false 而误踢登录)。
$this->tokenParse();
$request = app()->request;
$action = strtolower($request->action());
if (in_array('*', $noNeedLogin) || in_array($action, $noNeedLogin)) {
return true;
}
// 规范化权限集合,避免 initUser 未赋值导致 in_array() 报 TypeError
$this->powers = $this->powers ?? [];
if ($this->isLogin == false) {
// 整页(非 AJAX)请求未登录:直接 302 跳转到登录页;
// AJAX 请求返回 401 JSON,交给前端 kernel.js 跳转,避免整页被踢。
if (! $request->isAjax() && ! $request->isPjax()) {
// 直接发送 302 并终止,确保构造期(_initialize 中调用)也能可靠跳转
$this->redirectToLogin();
}
Result::instance()->setStatusCode(401)->error(lang('Please login first'));
}
// 超级管理员直接放行(避免权限数据缺失时锁定后台)
if (! empty($this->model) && ! empty($this->model->id)
&& $this->model->id == config('ywxapp.superAdmin', 1)) {
return true;
}
// 权限集合含通配符 * 表示拥有全部权限(如超级管理员角色),直接放行
if (in_array('*', $this->powers, true)) {
return true;
}
if (in_array('*', $noNeedRight) || in_array($action, $noNeedRight)) {
return true;
}
$controller = strtolower($request->controller());
$path = str_replace('.', ':', $controller) . ':' . $action;
// 精确匹配 或 控制器级通配(controller:*
if (in_array($path, $this->powers) || in_array($controller . ':*', $this->powers)) {
return true;
}
Result::instance()->setStatusCode(403)->error(lang('You have no permission'));
}
/**
* 跳转到登录页(生成完整绝对 URL 302)。
*
* 登录跳转链接使用「当前请求域名 + 子目录(root) + 登录路径」拼成绝对地址,
* 避免在子目录部署或中心站/客户机跨域分发时跳到错误位置。
* 后台路径跟随 config/app.php app_map backend 对应的别名(默认 admin),
* 前台为 /Login。config/ywxapp.php backend_login_url / member_login_url 可强制覆盖。
*
* @return void
*/
/**
* app 配置推导后台应用的 URL 别名(app_map backend 对应的键)。
*
* 多应用模式下后台真实目录为 backend,对外 URL 段由 config/app.php
* app_map['<别名>'=>'backend'] 决定(默认 admin)。硬编码 /admin 会在别名
* 调整时失效,故反查配置,使登录跳转跟随部署配置。
*
* @return string 'backend'
*/
protected function UrlAlias($val): string
{
$appMap = config('app.app_map', []);
$alias = array_search($val, $appMap, true);
return $alias === false ? $val : (string) $alias;
}
public function redirectToLogin()
{
if ($this->isAdmin) {
// 优先读 config/ywxapp.php 的 backend_login_url 覆盖;留空则跟随 app_map 推导
$override = trim((string) config('ywxapp.backend_login_url', ''));
$path = $override !== ''
? $override
: '/' . $this->UrlAlias('backend') . '/Login/index';
} else {
$override = trim((string) config('ywxapp.member_login_url', ''));
$path = $override !== '' ? $override : '/' . $this->UrlAlias('member') . '/Login/index';
}
// 用 url() 助手生成完整绝对 URL(自动带域名 + 子目录 root),
// 避免在子目录部署或中心站/客户机跨域分发时跳到错误位置(原先为裸根路径)。
$loginUrl = (string) url($path, [], false, true);
// 直接发送 302 并终止,确保构造期(_initialize 中调用)也能可靠跳转
redirect($loginUrl)->send();
exit;
}
/**
* 登录成功后把 access/refresh token 持久化到共享 cookie(path=/) 与服务端 session
* 保证整页跳转(iframe、菜单点击等非 AJAX 场景,不会自动带 Authorization 头)
* 也能通过 cookie/session 还原登录态,避免被踢回登录页。
* @param array $tokens JwtService::createToken() 的返回值
*/
protected function persistTokens(array $tokens): void
{
$at = $tokens['access_token'] ?? '';
$rt = $tokens['refresh_token'] ?? '';
if ($at !== '') {
Session::set('access_token', $at);
\think\facade\Cookie::set('access_token', $at, ['httponly' => false, 'path' => '/']);
}
if ($rt !== '') {
Session::set('refresh_token', $rt);
\think\facade\Cookie::set('refresh_token', $rt, ['httponly' => false, 'path' => '/']);
}
}
/**
* 用户登出,清除 token 缓存与登录态
* @return void
*/
public function logout()
{
$tokenStr = $this->resolveAccessToken();
// 子类钩子:此时 model/info 尚未清空,可做事件触发、整 session 清除等
$this->afterLogout();
if ($tokenStr) {
\think\facade\Cache::delete('auth_token_' . md5($tokenStr));
}
// 清除服务端 session / cookie 中的 token
Session::delete('access_token');
Session::delete('refresh_token');
\think\facade\Cookie::delete('access_token');
\think\facade\Cookie::delete('refresh_token');
// 重置登录状态
$this->isLogin = false;
$this->info = null;
$this->model = null;
$this->powers = [];
$this->roles = [];
}
/**
* 魔术读取(兼容 $auth->info / $auth->model 等)
* @param mixed $name
* @return mixed
*/
public function __get($name)
{
return $this->$name;
}
/**
* 魔术赋值
* @param mixed $name
* @param mixed $value
* @return void
*/
public function __set($name, $value)
{
$this->$name = $value;
}
public static function instance($options = [])
{
return app()->auth;
}
/**
* 注册用户(前台)
*
* @param string $account 用户名
* @param string $password 密码
* @param string $email 邮箱
* @param string $mobile 手机号
* @param array $extend 扩展参数
* @return boolean
*/
public function register($account = '', $password = '', $email = '', $mobile = '', $extend = [])
{
//账号注册时需要开启事务,避免出现垃圾数据
Db::startTrans();
try {
$account = $account ?: Random::account() . mb_substr($mobile ?: strtoupper(Random::alnum(4)), -4);
$password = $password ?: Random::alnum(16);
$nickname = $extend['nickname'] ?? '用户' . mb_substr($mobile ?: strtoupper(Random::alnum(4)), -4);
// 检测用户名
if (UserModel::checkExists('account', $account)) {
Result::instance()->error('Account already exist');
Db::rollback();
return false;
}
// 检测邮箱
if ($email && UserModel::checkExists('email', $email)) {
Result::instance()->error('Email already exist');
Db::rollback();
return false;
}
// 检测手机号
if ($mobile && UserModel::checkExists('mobile', $mobile)) {
Result::instance()->error('Mobile already exist');
Db::rollback();
return false;
}
$ip = request()->ip();
$data = [
'gid' => config('fastadmin.user_default_group') ?: 0,
'account' => $account,
'password' => $password,
'email' => $email,
'mobile' => $mobile,
//'score' => config('fastadmin.user_initial_score') ?: 0,
'avatar' => '',
'nickname' => $nickname,
'create_ip' => $ip,
'update_ip' => $ip,
'status' => 1,
];
$params['password'] = $this->getEncryptPassword($password);
$params = array_merge($params, $extend);
$this->info = UserModel::create($params, true);
$newClaims = [
'uid' => $this->info->uid,
'account' => $this->info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
$this->isLogin = true;
event('user_register_after', $this->info);
Db::commit();
} catch (Exception $e) {
$this->setError($e->getMessage());
Db::rollback();
return false;
}
return true;
}
/**
* 用户登录(前台)
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @return boolean
*/
public function login($account, $password, $isAuthPass = true)
{
$field = Validate::checkRule($account, 'email') ? 'email' : (Validate::checkRule($account, 'mobile') ? 'mobile' : 'account');
$info = UserModel::where($field, $account)->findOrEmpty();
// 会员表查无此账号时,回退到后台管理员表校验(打通会员中心与后台同一账号登录)
if ($info->isEmpty()) {
$admin = BackendAdmin::where('account', $account)->find();
if ($admin && $admin->status == 1 && $admin->checkPassword($password)) {
$info = $this->syncFromAdmin($admin, $password);
}
}
if ($info->isEmpty()) {
Result::instance()->error('Account is incorrect');
return false;
}
if ($info->status != 1) {
Result::instance()->error('Member Account is locked');
return false;
}
if ($info->isLocked()) {
Result::instance()->error('Member Account is locked');
return false;
}
$info->resetPassword($password);
if (! $info->checkPassword($password)) {
$info->recordLoginFail($_SERVER['REMOTE_ADDR'] ?? '');
Result::instance()->error('Password is incorrect');
return false;
}
$newClaims = [
'uid' => $info->uid,
'account' => $info->account,
];
// createToken 同时生成 access/refresh;持久化到共享 cookie(path=/)+session
// 使整页跳转(iframe/菜单点击,无 Authorization 头)也能还原登录态。
$tokens = JWTService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
// 钩子点:会员/API 登录成功后触发,插件可在 info.php['events']['listen'] 中监听 user_login_after
event('user_login_after', $info);
$info->recordLogin($_SERVER['REMOTE_ADDR'] ?? '');
$this->initUser($info->uid);
}
/**
* 后台管理员账号首次登录会员中心时,在会员表自动创建一条对应记录,
* 密码按会员表规则(带 salt)加密,以便后续会员中心可独立登录。
* 仅在会员表无该 account 时创建。
*
* @param \ywxapp\model\Backend $admin 后台管理员模型(已通过密码校验)
* @param string $password 明文密码(用于会员表加密存储)
* @return \ywxapp\model\Member
*/
protected function syncFromAdmin($admin, $password)
{
$user = UserModel::where('account', $admin->account)->findOrEmpty();
if (! $user->isEmpty()) {
return $user;
}
$user = UserModel::create([
'account' => $admin->account,
'nickname' => $admin->nickname ?: $admin->account,
'password' => $password, // 触发 Member 模型的 setPasswordAttr 自动加盐加密
'gid' => config('fastadmin.user_default_group') ?: 0,
'status' => 1,
'create_ip' => request()->ip(),
'update_ip' => request()->ip(),
]);
return $user;
}
/**
* 修改密码(前台)
* @param string $newpassword 新密码
* @param string $oldpassword 旧密码
* @param bool $ignoreoldpassword 忽略旧密码
* @return boolean
*/
public function changepwd($newpassword, $oldpassword = '', $ignoreoldpassword = false)
{
if (! $this->_logined) {
$this->setError('You are not logged in');
return false;
}
//判断旧密码是否正确
if ($this->_user->password == $this->getEncryptPassword($oldpassword, $this->_user->salt) || $ignoreoldpassword) {
Db::startTrans();
try {
$salt = Random::alnum();
$newpassword = $this->getEncryptPassword($newpassword, $salt);
$this->_user->save(['loginfailure' => 0, 'password' => $newpassword, 'salt' => $salt]);
Token::clear($this->_user->uid);
//修改密码成功的事件
event('user_changepwd_after', $this->_user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
} else {
$this->setError('Password is incorrect');
return false;
}
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use think\facade\Event;
use ywxapp\library\Result;
use ywxapp\model\Email as EmailModel;
use ywxapp\model\MemberUser as UsersModel;
/**
* 邮箱验证码类.
*/
class Email
{
/**
* 验证码有效时长
* @var int
*/
protected $expire = 120;
/**
* 最大允许检测的次数.
* @var int
*/
protected $maxCheckNums = 10;
/**
* 获取当前对象实例.
*
* @return EmailModel
*/
public static function instance()
{
return app()->email;
}
/**
* 发送验证码
*
* @param int $email 邮箱
* @param int $code 验证码,为空时将自动生成4位数字
* @param string $event 事件
*
* @return bool
*/
public function sendEmail($emailAddr, $code = null, $event = 'default')
{
$lastEmail = EmailModel::where('email', $emailAddr)
->where('event', $event)
->order('id', 'DESC')
->find();
Event::trigger('email_get', $lastEmail, true);
if ($lastEmail && time() - $lastEmail['create_at'] < 60)
Result::instance()->error(message: ('发送频繁'));
if ($event) {
$userinfo = UsersModel::where('email', $emailAddr)->find();
if ($event == 'register' && $userinfo)
Result::instance()->error(('已被注册'));
elseif (in_array($event, ['changeemail']) && $userinfo)
Result::instance()->error(('已被占用'));
elseif (in_array($event, ['changepwd', 'resetpwd']) && !$userinfo)
Result::instance()->error(('未注册'));
}
$code = is_null($code) ? mt_rand(100000, 999999) : $code;
$time = time();
$ip = request()->ip();
$email = EmailModel::create([
'event' => $event,
'email' => $emailAddr,
'code' => $code,
'ip' => $ip,
'create_at' => $time,
]);
$result = Event::trigger('email_send', $email, true);
if (!$result)
Result::instance()->error(message: ('发送失败'));
Result::instance()->success(message: ('发送成功'));
}
/**
* 发送通知.
*
* @param mixed $email 邮箱,多个以,分隔
* @param string $msg 消息内容
* @param string $template 消息模板
*
* @return bool
*/
public function notice($email, $msg = '', $template = null)
{
$params = [
'email' => $email,
'msg' => $msg,
'template' => $template,
];
$result = Event::trigger('email_notice', $params, true);
return $result ? true : false;
}
/**
* 校验验证码
*
* @param int $email 邮箱
* @param int $code 验证码
* @param string $event 事件
*
* @return bool
*/
public function check($emailAddr, $code, $event = 'default')
{
$lastEmail = EmailModel::where('email', $emailAddr)
->where('event', $event)
->order('id', 'DESC')
->find();
if (!$lastEmail || $code != $lastEmail['code'])
Result::instance()->error(message: ('验证码不正确'));
if ($lastEmail['create_at'] > time() - $this->expire)
Result::instance()->error(message: ('验证码已经过期'));
if ($code != $lastEmail['code'])
Result::instance()->error(message: ('验证码不正确'));
$result = Event::trigger('ems_check', $lastEmail, true);
$lastEmail->status = 1;
$lastEmail->save();
return $result;
}
/**
* 清空指定邮箱验证码
*
* @param int $email 邮箱
* @param string $event 事件
*
* @return bool
*/
public static function flush($email, $event = 'default')
{
EmailModel::where(['email' => $email, 'event' => $event])->delete();
Event::trigger('email_flush');
return true;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
use think\facade\Log;
/**
* 基于 PHPMailer 的简单邮件发送封装。
* 未配置 SMTP 时静默返回 false(不阻断主流程)。
*/
class Mailer
{
/**
* 发送邮件
* @param string $to 收件人
* @param string $subject 主题
* @param string $body 正文(支持 HTML
* @param bool $isHtml 是否为 HTML 正文
* @return bool
*/
public static function send(string $to, string $subject, string $body, bool $isHtml = true): bool
{
$cfg = config('mail', []);
if (empty($cfg['host']) || empty($cfg['username']) || empty($cfg['password'])) {
Log::warning('[Mailer] 未配置 SMTP,跳过发送 -> ' . $to);
return false;
}
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = $cfg['host'];
$mail->SMTPAuth = true;
$mail->Username = $cfg['username'];
$mail->Password = $cfg['password'];
$mail->SMTPSecure = $cfg['secure'] ?? 'ssl';
$mail->Port = (int) ($cfg['port'] ?? 465);
$mail->CharSet = 'UTF-8';
$mail->setFrom($cfg['from'] ?: $cfg['username'], $cfg['from_name'] ?? 'YwxApp');
$mail->addAddress($to);
$mail->isHTML($isHtml);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->send();
return true;
} catch (PHPMailerException $e) {
Log::error('[Mailer] 发送失败 -> ' . $mail->ErrorInfo);
return false;
} catch (\Exception $e) {
Log::error('[Mailer] 异常 -> ' . $e->getMessage());
return false;
}
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
// use fast\Tree;
use think\Exception;
use think\facade\Db;
use ywxapp\model\BackendPower;
use ywxapp\service\AddonService;
/**
* Menu
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Menu
{
/**
* 创建菜单
* @param array $menu
* @param mixed $parent 父类的name或pid
*/
public static function create($menu = [], $parent = 0)
{
$old = [];
self::menuUpdate($menu, $old, $parent);
//菜单刷新处理
$info = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1];
preg_match('/addon\\\\([a-z0-9]+)\\\\/i', $info['class'], $matches);
if ($matches && isset($matches[1])) {
Menu::refresh($matches[1], $menu);
}
}
/**
* 删除菜单.
*
* @param string $name 规则name
* @return bool
*/
public static function delete($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
BackendPower::destroy($ids);
return true;
}
/**
* 启用菜单.
*
* @param string $name
* @return bool
*/
public static function enable($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
BackendPower::where('id', 'in', $ids)->update(['status' => 'normal']);
return true;
}
/**
* 禁用菜单.
*
* @param string $name
* @return bool
*/
public static function disable($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
BackendPower::where('id', 'in', $ids)->update(['status' => 'hidden']);
return true;
}
/**
* 升级菜单
* @param string $name 插件名称
* @param array $menu 新菜单
* @return bool
*/
public static function upgrade($name, $menu)
{
$ids = self::getAuthRuleIdsByName($name);
$old = BackendPower::where('id', 'in', $ids)->select();
$old = $old ? $old->toArray() : [];
$old = array_column($old, null, 'name');
Db::startTrans();
try {
self::menuUpdate($menu, $old);
$ids = [];
foreach ($old as $index => $item) {
if (! isset($item['keep'])) {
$ids[] = $item['id'];
}
}
if ($ids) {
//旧版本的菜单需要做删除处理
$config = AddonService::config($name);
$menus = isset($config['menus']) ? $config['menus'] : [];
$where[] = ['id', 'in', $ids];
if ($menus) {
//必须是旧版本中的菜单,可排除用户自主创建的菜单
$where[] = ['name', 'in', $menus];
}
BackendPower::where($where)->delete();
}
Db::commit();
} catch (\PDOException $e) {
Db::rollback();
return false;
}
Menu::refresh($name, $menu);
return true;
}
/**
* 刷新插件菜单配置缓存
* @param string $name
* @param array $menu
*/
public static function refresh($name, $menu = [])
{
if (! $menu) {
// $menu为空时表示首次安装,首次安装需刷新插件菜单标识缓存
$menuIds = self::getAuthRuleIdsByName($name);
$menus = BackendPower::where('id', 'in', $menuIds)->column('name');
} else {
// 刷新新的菜单缓存
$getMenus = function ($menu) use (&$getMenus) {
$result = [];
foreach ($menu as $index => $item) {
$result[] = $item['name'];
$result = array_merge($result, isset($item['sublist']) && is_array($item['sublist']) ? $getMenus($item['sublist']) : []);
}
return $result;
};
$menus = $getMenus($menu);
}
//刷新新的插件核心菜单缓存
AddonService::config($name, ['menus' => $menus]);
}
/**
* 导出指定名称的菜单规则.
*
* @param string $name
*
* @return array
*/
public static function export($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return [];
}
$menuList = [];
$menu = BackendPower::getByName($name);
if ($menu) {
$ruleList = BackendPower::where('id', 'in', $ids)->select()->toArray();
$menuList = Tree::instance()->init($ruleList)->getTreeArray($menu['id']);
}
return $menuList;
}
/**
* 菜单升级
* @param array $newMenu
* @param array $oldMenu
* @param int $parent
* @throws Exception
*/
private static function menuUpdate($newMenu, &$oldMenu, $parent = 0)
{
if (! is_numeric($parent)) {
$parentRule = BackendPower::getByName($parent);
$pid = $parentRule ? $parentRule['id'] : 0;
} else {
$pid = $parent;
}
// 补全与 admin_power 表结构一致的字段(route/sort/type/addon),
// 否则插件跨应用菜单的链接(route)、排序(sort)、类型(type)、归属(addon)会丢失
$allow = array_flip(['file', 'name', 'title', 'icon', 'condition', 'remark', 'ismenu', 'weigh', 'route', 'sort', 'type', 'addon']);
foreach ($newMenu as $k => $v) {
$hasChild = isset($v['sublist']) && $v['sublist'] ? true : false;
$data = array_intersect_key($v, $allow);
$data['ismenu'] = isset($data['ismenu']) ? $data['ismenu'] : ($hasChild ? 1 : 0);
$data['icon'] = isset($data['icon']) ? $data['icon'] : ($hasChild ? 'fa fa-list' : 'fa fa-circle-o');
$data['pid'] = $pid;
$data['status'] = 'normal';
if (! isset($oldMenu[$data['name']])) {
$menu = BackendPower::create($data);
} else {
$menu = $oldMenu[$data['name']];
//更新旧菜单
BackendPower::update($data, ['id' => $menu['id']]);
$oldMenu[$data['name']]['keep'] = true;
}
if ($hasChild) {
self::menuUpdate($v['sublist'], $oldMenu, $menu['id']);
}
}
}
/**
* 根据名称获取规则IDS.
*
* @param string $name
*
* @return array
*/
public static function getAuthRuleIdsByName($name)
{
$ids = [];
$menu = BackendPower::getByName($name);
if ($menu) {
// 必须将结果集转换为数组
$ruleList = BackendPower::order('weigh', 'desc')->field('id,pid,name')->select()->toArray();
// 构造菜单数据
$ids = Tree::instance()->init($ruleList)->getChildrenIds($menu['id'], true);
}
return $ids;
}
}
+277
View File
@@ -0,0 +1,277 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use BcMath\Number;
use think\exception\HttpResponseException;
use think\facade\Request;
use think\Response;
class Result
{
/**
* Summary of StatusCode
* @var int
*/
protected $StatusCode = 200;
public $Message = null;
/**
* Summary of Headers
* @var array
*/
protected $Headers = [];
protected $AccessToken;
protected $AccessExpired;
protected $RefreshToken;
protected $RefreshExpired;
protected $Total = 0;
/**
* Summary of Type
* @var string
*/
protected $Type = 'json';
protected $isCout = false;
public function __construct()
{
if (! Request::isAjax()) {
$this->Type = 'html';
}
}
public function success($data = null, $message = 'success', int $code = 0)
{
$result = ['code' => 0, 'message' => $message, 'timestamp' => time()];
if ($data) {
$result['data'] = $data;
}
if ($this->Total > 0) {
$result['count'] = $this->Total;
}
if ($this->AccessToken != null) {
$result['access_token'] = $this->AccessToken;
$result['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken != null) {
$result['refresh_token'] = $this->RefreshToken;
$result['refresh_expired'] = $this->RefreshExpired;
}
$response = '';
if ('html' == strtolower($this->Type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp/view/']);
\think\facade\View::layout(false);
$result = \think\facade\View::fetch('success.html', $result);
}
$response = Response::create($result, strtolower($this->Type), $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
$response->send();
app()->http->end($response);
exit;
// throw new HttpResponseException($response);
}
public function error($message = 'Error', int $code = 1, $data = null)
{
$result = ['code' => $code, 'message' => $message, 'timestamp' => time()];
if ($data) {
$result['data'] = $data;
}
if ($this->AccessToken != null) {
$result['access_token'] = $this->AccessToken;
$result['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken != null) {
$result['refresh_token'] = $this->RefreshToken;
$result['refresh_expired'] = $this->RefreshExpired;
}
$response = '';
// 若已被强制为 JSON(如插件 API 在 MultiApp 中间件里 setType('json')),
// 或请求本身是 ajax,则统一返回 JSON;否则按 HTML 渲染。
$type = ($this->Type === 'json') ? 'json' : (Request::isAjax() ? 'json' : 'html');
if ($this->StatusCode == 401) {
$result['url'] = url('login/index');
$result['code'] = $this->StatusCode;
if ('html' == strtolower($type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR]);
$result = \think\facade\View::fetch('401.html', $result);
}
$response = Response::create($result, strtolower($type), 401);
$this->applyTokenCookies($response);
} else {
if ('html' == strtolower($type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR]);
\think\facade\View::layout(false);
$result = \think\facade\View::fetch('error.html', $result);
}
$response = Response::create($result, strtolower($type), $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
}
$response->send();
app()->http->end($response);
exit;
throw new HttpResponseException($response);
}
/**
* 返回封装后的 API 数据到客户端.
*
* @param mixed $data 要返回的数据
* @param int $code 返回的 code
* @param mixed $msg 提示信息
* @param string $type 返回数据格式
* @param array $header 发送的 Header 信息
*/
protected function result($data, $code = 0, $msg = '', $type = '', array $header = [])
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => Request::server('REQUEST_TIME'),
'data' => $data,
];
$type = $type ?: $this->getResponseType();
$response = Response::create($result, $type)->header($header);
throw new HttpResponseException($response);
}
/**
* URL 重定向.
*
* @param string $url 跳转的 URL 表达式
* @param array|int $params 其它 URL 参数
* @param int $code http code
* @param array $with 隐式传参
*/
public function redirect($url, $code = 302, $params = [], $with = []): self
{
if (is_int($params)) {
$code = $params;
}
$response = \redirect($url);
$response->code($code)->with($with);
throw new HttpResponseException($response);
}
/**
* 设置Token
* @param string $token
* @return void
*/
public function setAccessToken($token, $expired = null): self
{
$this->AccessToken = $token;
$this->AccessExpired = $expired;
return app()->result;
}
/**
* 设置Token
* @param string $token
* @return void
*/
public function setRefreshToken($token, $expired = null): self
{
$this->RefreshToken = $token;
$this->RefreshExpired = $expired;
return $this;
}
public function setStatusCode(int $code = 200): self
{
$this->StatusCode = $code;
return $this;
}
public function setMessage(string $message = ''): self
{
$this->Message = $message;
return $this;
}
public function setHeaders(array $header = []): self
{
$this->Headers = $header;
return $this;
}
/**
* 强制设置响应类型(json/html
* 插件 API 由小程序/APP 等客户端调用,请求不带 X-Requested-With
* MultiApp 中间件里统一 setType('json') 以确保返回 JSON。
* @param string $type
* @return self
*/
public function setType(string $type = 'json'): self
{
$this->Type = strtolower($type);
return app()->result;
}
/**
* 把当前持有的 access/refresh token 写入响应 Cookiepath=/,非 httponly)。
*
* 背景:登录/注册的 token JwtService 存入本 Result 单例(setAccessToken/setRefreshToken),
* persistTokens() 用的全局 Cookie 门面与 Response 持有的 Cookie 实例并非同一个,
* 仅靠门面 set token 不会被 Response::send() 输出,导致整页跳转(菜单点击、iframe)
* 不带 token 后端读不到 跳登录页。此处直接写到 Response 实例上,确保 100% 随响应输出。
*
* @param Response $response
* @return void
*/
protected function applyTokenCookies(Response $response): void
{
if ($this->AccessToken !== null && $this->AccessToken !== '') {
$expire = $this->AccessExpired ? ((int) $this->AccessExpired - time()) : 0;
$response->cookie('access_token', (string) $this->AccessToken, [
'expire' => $expire,
'path' => '/',
'httponly' => false,
]);
}
if ($this->RefreshToken !== null && $this->RefreshToken !== '') {
$expire = $this->RefreshExpired ? ((int) $this->RefreshExpired - time()) : 0;
$response->cookie('refresh_token', (string) $this->RefreshToken, [
'expire' => $expire,
'path' => '/',
'httponly' => false,
]);
}
}
public function setCount(int $total): self
{
$this->Total = $total;
return app()->result;
}
public static function instance($options = []): Result
{
return app()->result;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
/**
* EsSearch —— 轻量 ElasticSearch REST 封装(零新增依赖,复用项目已引入的 GuzzleHttp
*
* 设计原则(对应路线 C3「ES 全文检索替代 LIKE」):
* - 薄封装:ES 仅负责「按关键词返回匹配的 id 列表」,命中后由调用方 `whereIn('id', $ids)` 回表取完整行 + 关联,
* 最大限度复用现有视图与模型关联,降低改造面。
* - 可降级:本类方法在 ES 不可用时抛出异常,由上层 SearchService 捕获并回退到原生 LIKE。
* - 中文分词:依赖 ES 服务端 IK 分词插件(index 创建时指定 analyzer);若未装,ES 默认 standard 仍可按词/字匹配。
*
* 依赖:项目根 composer.json 已间接引入 guzzlehttp/guzzle(经 yansongda/pay)。
*/
declare(strict_types=1);
namespace ywxapp\library\Search;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
class EsSearch
{
/** @var Client|null 懒加载的 HTTP 客户端 */
protected static ?Client $client = null;
/**
* 获取 HTTP 客户端
*/
protected static function client(): Client
{
if (self::$client === null) {
self::$client = new Client(['timeout' => 3.0, 'connect_timeout' => 2.0]);
}
return self::$client;
}
/**
* 拼接 ES 端点 URL
*/
protected static function url(string $host, string $path): string
{
return rtrim($host, '/') . $path;
}
/**
* 确保索引存在(带中文 IK 分词器映射,缺失则创建)
* @throws \RuntimeException
*/
public static function ensureIndex(string $host, string $index, array $fields): void
{
$url = self::url($host, '/' . $index);
try {
$resp = self::client()->request('HEAD', $url);
if ($resp->getStatusCode() === 200) {
return; // 已存在
}
} catch (TransferException $e) {
throw new \RuntimeException('ES 连接失败:' . $e->getMessage());
}
// 创建索引,默认用 ik_max_word(若有 IK 插件),否则 standard
$properties = [];
foreach ($fields as $f) {
$properties[$f] = ['type' => 'text', 'analyzer' => 'ik_max_word'];
}
$body = json_encode([
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
'mappings' => ['properties' => $properties],
], JSON_UNESCAPED_UNICODE);
try {
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
} catch (TransferException $e) {
// IK 分词器不存在时降级为 standard
$properties = [];
foreach ($fields as $f) {
$properties[$f] = ['type' => 'text'];
}
$body = json_encode([
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
'mappings' => ['properties' => $properties],
], JSON_UNESCAPED_UNICODE);
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
}
}
/**
* 索引单条文档
* @throws \RuntimeException
*/
public static function indexDoc(string $host, string $index, int $id, array $body): void
{
try {
self::client()->request(
'PUT',
self::url($host, '/' . $index . '/_doc/' . $id),
['body' => json_encode($body, JSON_UNESCAPED_UNICODE), 'headers' => ['Content-Type' => 'application/json']]
);
} catch (TransferException $e) {
throw new \RuntimeException('ES 写入失败:' . $e->getMessage());
}
}
/**
* 删除文档
*/
public static function deleteDoc(string $host, string $index, int $id): void
{
try {
self::client()->request('DELETE', self::url($host, '/' . $index . '/_doc/' . $id));
} catch (TransferException $e) {
// 索引不存在或文档已删,忽略
}
}
/**
* 全文检索,返回匹配的 id 列表(按相关度排序)
* @param array $fields 检索字段,如 ['title','content']
* @return int[] 命中文档 id(相关度降序)
* @throws \RuntimeException
*/
public static function search(string $host, string $index, string $keyword, array $fields, int $limit = 50, int $from = 0): array
{
if (trim($keyword) === '') {
return [];
}
$should = [];
foreach ($fields as $f) {
$should[] = ['match' => [$f => ['query' => $keyword, 'boost' => 1.0]]];
}
$body = json_encode([
'from' => $from,
'size' => $limit,
'query' => ['bool' => ['should' => $should, 'minimum_should_match' => 1]],
'_source' => false,
], JSON_UNESCAPED_UNICODE);
try {
$resp = self::client()->request(
'POST',
self::url($host, '/' . $index . '/_search'),
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
);
} catch (TransferException $e) {
throw new \RuntimeException('ES 检索失败:' . $e->getMessage());
}
$data = json_decode((string) $resp->getBody(), true);
$ids = [];
foreach (($data['hits']['hits'] ?? []) as $hit) {
$ids[] = (int) ($hit['_id'] ?? 0);
}
return $ids;
}
/**
* 相关推荐(基于 ES more_like_this,按内容相似度返回相似文档 id
* @param array $fields 参与相似的字段,如 ['title','content']
* @param string $likeText 源文档文本(title + content 拼接)
* @return int[] 相似文档 id(相关度降序,不含自身)
* @throws \RuntimeException
*/
public static function moreLikeThis(string $host, string $index, string $likeText, array $fields, int $excludeId, int $limit = 6): array
{
$likeText = trim($likeText);
if ($likeText === '') {
return [];
}
$body = json_encode([
'size' => $limit + 1,
'query' => [
'more_like_this' => [
'fields' => $fields,
'like' => $likeText,
'min_term_freq' => 1,
'min_doc_freq' => 1,
'minimum_should_match' => '20%',
],
],
'_source' => false,
], JSON_UNESCAPED_UNICODE);
try {
$resp = self::client()->request(
'POST',
self::url($host, '/' . $index . '/_search'),
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
);
} catch (TransferException $e) {
throw new \RuntimeException('ES 相关推荐失败:' . $e->getMessage());
}
$data = json_decode((string) $resp->getBody(), true);
$ids = [];
foreach (($data['hits']['hits'] ?? []) as $hit) {
$id = (int) ($hit['_id'] ?? 0);
if ($id !== $excludeId) {
$ids[] = $id;
}
}
return array_slice($ids, 0, $limit);
}
/**
* 全量重建索引(清空后批量写入)
* @param callable $each 迭代器回调:function(int $page, int $size): array 返回 [id=>body] 映射
*/
public static function rebuild(string $host, string $index, array $fields, callable $each, int $size = 200): void
{
// 删除旧索引重建
try {
self::client()->request('DELETE', self::url($host, '/' . $index));
} catch (TransferException $e) {
}
self::ensureIndex($host, $index, $fields);
$page = 1;
while (true) {
$batch = $each($page, $size);
if (empty($batch)) {
break;
}
foreach ($batch as $id => $doc) {
self::indexDoc($host, $index, (int) $id, $doc);
}
if (count($batch) < $size) {
break;
}
$page++;
}
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use ywxapp\library\TemplateManager;
/**
* 皮肤覆盖层(Discuz 式局部覆盖核心)。
*
* 由于 ThinkPHP {extend}/{include} 由模板引擎内部用 view_path 解析,
* 无法在控制器 fetch 钩子里拦截。因此这里把「插件视图 + 皮肤覆盖」合并到
* 一个真实的覆盖层目录 runtime/skin/<addon>/<area>/
* - 先复制插件 addon/<addon>/view/<area>/ 全部文件
* - 再用模板 templates/<tpl>/view/<addon>/<area>/ 的文件覆盖(皮肤优先)
* 控制器渲染前把 View view_path 指向该覆盖层,顶层页面与 extend
* layout/partial 均从覆盖层解析;无激活皮肤时返回 null(回退插件默认视图)。
*
* 合并结果按源目录最新修改时间缓存,源有变动才重建。
*/
class SkinOverlay
{
/**
* 解析某插件/区域的合并覆盖层目录;无激活皮肤返回 null
*
* @param string $addon 插件名,如 blog
* @param string $area frontend / backend / member
* @return string|null
*/
/**
* 解析某插件当前生效的模板名(不构建覆盖层,供变量层等复用)。
*
* 生效层级(高→低):
* 1. 显式插件绑定(管理员设定,最高优先级)
* 2. 会员自选(界面设置开启 allow_member_select 且访客 cookie 有效)
* 3. 全站默认 '*'
* 无激活皮肤 / 被禁用 / 回退默认 时返回 null
*/
public static function templateOf(string $addon): ?string
{
$map = config('template.active_map', []);
if (! is_array($map)) {
$map = [];
}
// 1) 显式插件绑定优先
if (isset($map[$addon]) && $map[$addon] !== '' && $map[$addon] !== 'default') {
$tpl = $map[$addon];
} else {
// 2) 会员自选(若开启且有效)
$member = self::memberChoice();
if ($member !== null) {
$tpl = $member;
} else {
// 3) 全站默认
$tpl = $map['*'] ?? 'default';
}
}
if (empty($tpl) || $tpl === 'default') {
return null;
}
// 模板被禁用时不生效(界面设置中可启用/禁用)
if (!TemplateManager::isEnabled($tpl)) {
return null;
}
return $tpl;
}
/**
* 读取访客自选模板(界面设置开启 allow_member_select 时生效)。
* 仅接受「已安装且启用」的合法模板名,否则返回 null(安全:防 cookie 注入/遍历)。
*/
protected static function memberChoice(): ?string
{
$settings = TemplateManager::getSettings();
if (empty($settings['allow_member_select'])) {
return null;
}
$name = (string) \think\facade\Request::cookie('ywx_skin', '');
if ($name === '' || $name === 'default') {
return null;
}
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
return null;
}
// 必须是真实存在且启用的模板
$dir = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
if (! is_dir($dir) || ! TemplateManager::isEnabled($name)) {
return null;
}
return $name;
}
public static function resolve(string $addon, string $area): ?string
{
$tpl = self::templateOf($addon);
if ($tpl === null) {
return null;
}
$pluginView = root_path() . 'addon' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR;
$skinView = root_path() . 'templates' . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR;
// 该区域皮肤没有提供任何覆盖文件,无需合并,直接回退插件默认视图
if (! is_dir($skinView)) {
return null;
}
$overlay = runtime_path() . 'skin' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR;
self::ensure($overlay, $pluginView, $skinView);
return $overlay;
}
/**
* 确保覆盖层目录最新(按需重建)。
* .built 标记文件记录源目录最新修改时间,避免空目录误判为「缓存有效」。
*/
protected static function ensure(string $overlay, string $pluginView, string $skinView): void
{
$srcMtime = 0;
if (is_dir($pluginView)) {
$srcMtime = max($srcMtime, self::dirMtime($pluginView));
}
if (is_dir($skinView)) {
$srcMtime = max($srcMtime, self::dirMtime($skinView));
}
$marker = $overlay . '.built';
if (is_dir($overlay) && is_file($marker) && (int) file_get_contents($marker) >= $srcMtime) {
return; // 缓存有效
}
if (is_dir($overlay)) {
self::delTree($overlay);
}
if (is_dir($pluginView)) {
self::copyDir($pluginView, $overlay);
} else {
@mkdir($overlay, 0755, true);
}
if (is_dir($skinView)) {
self::copyDir($skinView, $overlay);
}
@file_put_contents($marker, (string) $srcMtime);
}
protected static function copyDir(string $src, string $dst): void
{
$src = rtrim($src, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$dst = rtrim($dst, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (! is_dir($src)) {
return;
}
if (! is_dir($dst)) {
@mkdir($dst, 0755, true);
}
foreach (array_diff(scandir($src), ['.', '..']) as $item) {
$s = $src . $item;
$d = $dst . $item;
if (is_dir($s)) {
self::copyDir($s, $d);
} else {
copy($s, $d);
}
}
}
protected static function delTree(string $dir): void
{
$dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (! is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir), ['.', '..']) as $item) {
$p = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($p)) {
self::delTree($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
protected static function dirMtime(string $dir): int
{
$mtime = 0;
$rii = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($rii as $file) {
if ($file->isFile()) {
$mtime = max($mtime, $file->getMTime());
}
}
return $mtime;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 皮肤变量/配色层(Discuz 式风格变量,无需改 HTML)。
*
* 模板在 template.json 声明 variables(如主色/背景/字体),后台可覆盖并写入
* config/template.php settings.variables[<模板名>]。渲染时由 Addon 基类的
* fetch 钩子把变量注入为 <style id="ywx-skin-vars">:root{--primary:...}</style>
* 皮肤 CSS var(--primary) 即可生效,无需改动任何视图文件。
*/
class SkinVariables
{
/**
* 读取模板声明的变量定义(已归一化为 {key:{label,default,type}})。
* 支持简写:variables: { "primary": "#2d8cf0" } 等价于 { "primary": {"default":"#2d8cf0"} }
*/
public static function definitions(string $name): array
{
$file = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR . 'template.json';
if (! is_file($file)) {
return [];
}
$meta = json_decode(file_get_contents($file), true) ?? [];
$vars = $meta['variables'] ?? [];
if (! is_array($vars)) {
return [];
}
$out = [];
foreach ($vars as $key => $def) {
if (is_string($def)) {
$def = ['default' => $def];
}
if (! is_array($def)) {
continue;
}
$out[$key] = [
'label' => $def['label'] ?? $key,
'default' => $def['default'] ?? '',
'type' => $def['type'] ?? 'color',
];
}
return $out;
}
/**
* 当前生效的变量值(默认 + 后台覆盖)。
* 返回带 -- 前缀的 CSS 变量名 => 值(仅返回非空值)。
*/
public static function values(string $name): array
{
$defs = self::definitions($name);
$settings = TemplateManager::getSettings();
$overrides = $settings['variables'][$name] ?? [];
if (! is_array($overrides)) {
$overrides = [];
}
$out = [];
foreach ($defs as $key => $d) {
$val = $overrides[$key] ?? $d['default'];
if ($val === '' || $val === null) {
continue;
}
$out['--' . ltrim($key, '-')] = (string) $val;
}
return $out;
}
/**
* 生成 :root 内的 CSS 变量声明字符串(不含 :root{} 包裹),如无变量返回空串。
*/
public static function declaration(string $name): string
{
$vals = self::values($name);
$parts = [];
foreach ($vals as $k => $v) {
$parts[] = $k . ':' . $v;
}
return implode(';', $parts);
}
/**
* 针对某插件/区域,返回可直接注入页面的 <style> 标签;无激活皮肤或无变量返回空串。
*/
public static function styleTag(string $addon, string $area): string
{
$name = SkinOverlay::templateOf($addon);
if ($name === null) {
return '';
}
$decl = self::declaration($name);
if ($decl === '') {
return '';
}
return '<style id="ywx-skin-vars">:root{' . $decl . '}</style>';
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use ywxapp\utils\Random;
use think\facade\Event;
use ywxapp\model\Sms as SmsModel;
/**
* 短信验证码类
*/
class Sms
{
/**
* 验证码有效时长
* @var int
*/
protected static $expire = 120;
/**
* 最大允许检测的次数
* @var int
*/
protected static $maxCheckNums = 10;
/**
* 获取最后一次手机发送的数据
*
* @param int $mobile 手机号
* @param string $event 事件
* @return Sms
*/
public static function get($mobile, $event = 'default')
{
$sms = SmsModel::where(['mobile' => $mobile, 'event' => $event]) ->order('id', 'DESC') ->find();
Event::trigger('SmsGet', $sms, true);
return $sms ?: null;
}
/**
* 发送验证码
*
* @param int $mobile 手机号
* @param int $code 验证码,为空时将自动生成4位数字
* @param string $event 事件
* @return boolean
*/
public static function send($mobile, $code = null, $event = 'default')
{
$code = is_null($code) ? Random::numeric(6) : $code;
$time = time();
$ip = request()->ip();
$sms = SmsModel::create(['event' => $event, 'mobile' => $mobile, 'code' => $code, 'ip' => $ip, 'create_at' => $time]);
$result = Event::trigger('SmsSend', $sms, true);
if (! $result) {
$sms->delete();
return false;
}
return true;
}
/**
* 发送通知
*
* @param mixed $mobile 手机号,多个以,分隔
* @param string $msg 消息内容
* @param string $template 消息模板
* @return boolean
*/
public static function notice($mobile, $msg = '', $template = null)
{
$params = [
'mobile' => $mobile,
'msg' => $msg,
'template' => $template,
];
$result = Event::trigger('SmsNotice', $params, true);
return (bool) $result;
}
/**
* 校验验证码
*
* @param int $mobile 手机号
* @param int $code 验证码
* @param string $event 事件
* @return boolean
*/
public static function check($mobile, $code, $event = 'default')
{
$time = time() - self::$expire;
$sms = SmsModel::where(['mobile' => $mobile, 'event' => $event]) ->order('id', 'DESC') ->find();
if ($sms) {
if ($sms['create_at'] > $time && $sms['times'] <= self::$maxCheckNums) {
$correct = $code == $sms['code'];
if (! $correct) {
$sms->times = $sms->times + 1;
$sms->save();
return false;
} else {
$result = Event::trigger('SmsCheck', $sms, true);
return $result;
}
} else {
// 过期则清空该手机验证码
self::flush($mobile, $event);
return false;
}
} else {
return false;
}
}
/**
* 清空指定手机号验证码
*
* @param int $mobile 手机号
* @param string $event 事件
* @return boolean
*/
public static function flush($mobile, $event = 'default')
{
SmsModel::where(['mobile' => $mobile, 'event' => $event])
->delete();
Event::trigger('SmsFlush');
return true;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?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\library;
/**
* 搜索引擎蜘蛛识别
*
* 仅按 User-Agent 特征串识别(不做 DNS 反查,保证零阻塞)。
* 新增蜘蛛只需在 SPIDERS 追加一行:标识 => [UA特征串列表, 显示名]
*
* @author ywxapp <admin@ywxapp.cn>
*/
class SpiderDetect
{
/**
* 蜘蛛特征表:key=入库标识,value=[UA 特征串(不区分大小写,命中任一即算), 中文显示名]
*/
public const SPIDERS = [
'baidu' => [['Baiduspider'], '百度'],
'google' => [['Googlebot', 'Google-InspectionTool', 'AdsBot-Google'], '谷歌'],
'bing' => [['bingbot', 'msnbot', 'BingPreview'], '必应'],
'sogou' => [['Sogou web spider', 'Sogou inst spider', 'Sogou Pic Spider'], '搜狗'],
'so360' => [['360Spider', 'HaoSouSpider', 'qihoobot'], '360'],
'bytedance' => [['Bytespider', 'ToutiaoSpider'], '字节跳动'],
'shenma' => [['YisouSpider'], '神马'],
'huawei' => [['PetalBot'], '华为花瓣'],
'yandex' => [['YandexBot'], 'Yandex'],
'duckduckgo' => [['DuckDuckBot', 'DuckDuckGo-Favicons-Bot'], 'DuckDuckGo'],
'yahoo' => [['Yahoo! Slurp'], '雅虎'],
'apple' => [['Applebot'], '苹果'],
'gptbot' => [['GPTBot', 'ChatGPT-User', 'OAI-SearchBot'], 'OpenAI'],
'claudebot' => [['ClaudeBot', 'anthropic-ai'], 'Anthropic'],
'semrush' => [['SemrushBot'], 'Semrush'],
'ahrefs' => [['AhrefsBot'], 'Ahrefs'],
'mj12' => [['MJ12bot'], 'Majestic'],
'facebook' => [['facebookexternalhit', 'FacebookBot'], 'Facebook'],
];
/**
* 识别 UA,命中返回蜘蛛标识(SPIDERS key),未命中返回 null
*/
public static function detect(string $userAgent): ?string
{
if ($userAgent === '') {
return null;
}
foreach (self::SPIDERS as $key => [$needles]) {
foreach ($needles as $needle) {
if (stripos($userAgent, $needle) !== false) {
return $key;
}
}
}
return null;
}
/**
* 蜘蛛标识 中文显示名(未知标识原样返回)
*/
public static function label(string $key): string
{
return self::SPIDERS[$key][1] ?? $key;
}
/**
* 全部蜘蛛 标识=>显示名(后台下拉/报表用)
*/
public static function labels(): array
{
$out = [];
foreach (self::SPIDERS as $key => [, $label]) {
$out[$key] = $label;
}
return $out;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
// | 流式文件下载响应(避免 ThinkPHP download() 助手将整文件读入内存导致大文件/慢网络截断)
// +----------------------------------------------------------------------
namespace ywxapp\library;
use think\Response;
/**
* 流式输出二进制文件(zip 等)的下载响应。
*
* 相对 ThinkPHP 内置 download() 助手的优势:
* - fread 分块输出(非 file_get_contents 整文件入内存),大文件不再吃爆 memory_limit
* - 构造函数内 set_time_limit(0),慢网络下不会被 Web 端默认执行时限截断;
* - 支持 HTTP RangeAccept-Ranges: bytes),客户端可断点续传,进一步抵御下载中断;
* - 正确回吐 Content-Length,便于客户端做字节数完整性校验。
*
* 用法:return new StreamZipResponse($absPath, $downloadName);
*/
class StreamZipResponse extends Response
{
protected $filePath;
protected $dlName;
protected $contentType = 'application/octet-stream';
public function __construct(string $filePath, string $dlName, int $code = 200)
{
$this->filePath = $filePath;
$this->dlName = $dlName;
$this->code = $code;
$this->header = [];
// 取消脚本执行时间限制:防止大文件 / 慢网络下被 PHP 默认 max_execution_time 截断
@set_time_limit(0);
@ignore_user_abort(false);
}
public function send(): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!is_file($this->filePath)) {
if (!headers_sent()) {
http_response_code(404);
}
return;
}
$size = filesize($this->filePath);
$name = rawurlencode($this->dlName);
// 解析 Range(断点续传)
$range = $this->parseRange($size);
$isRange = $range !== null;
$start = $range['start'] ?? 0;
$end = $range['end'] ?? ($size - 1);
if (!headers_sent()) {
http_response_code($isRange ? 206 : 200);
header('Pragma: public');
header('Accept-Ranges: bytes');
header('Content-Type: ' . $this->contentType);
header('Cache-control: max-age=360');
header('Content-Disposition: attachment; filename="' . $name . '"; filename* = UTF-8\'\'' . $name);
header('Content-Transfer-Encoding: binary');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 360) . ' GMT');
if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
header('Content-Length: ' . ($end - $start + 1));
} else {
header('Content-Length: ' . $size);
}
}
$this->outputRange($this->filePath, $start, $end);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
}
/**
* 解析 HTTP Range 头,返回 [start, end] null(不支持/非法)
*/
protected function parseRange(int $size): ?array
{
$rangeHeader = $this->header['range'] ?? ($_SERVER['HTTP_RANGE'] ?? '');
if ($rangeHeader === '' || !preg_match('/bytes=(\d*)-(\d*)/i', (string) $rangeHeader, $m)) {
return null;
}
$start = $m[1] === '' ? 0 : (int) $m[1];
$end = $m[2] === '' ? ($size - 1) : (int) $m[2];
if ($start < 0 || $end >= $size || $start > $end) {
return null;
}
return ['start' => $start, 'end' => $end];
}
/**
* 分块输出指定区间内容
*/
protected function outputRange(string $path, int $start, int $end): void
{
$fp = fopen($path, 'rb');
if ($fp === false) {
return;
}
if ($start > 0) {
fseek($fp, $start);
}
$remaining = $end - $start + 1;
$chunk = 8192;
while ($remaining > 0 && !feof($fp)) {
$read = $remaining > $chunk ? $chunk : $remaining;
echo fread($fp, $read);
$remaining -= $read;
if (connection_status() !== 0) {
break;
}
}
fclose($fp);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* 模板皮肤安装器(后台「安装模板」/ appmall 下载后部署的接入点)。
*
* 模板包 zip scripts/package_template.php 产出,内部两段前缀:
* - templates/<name>/... 部署到 <root>/templates/<name>/
* - static/... 部署到 <root>/public/static/templates/<name>/
*
* 模板不携带 DB 迁移,安装即文件落地;与插件(Addon) importsql 机制解耦。
*/
namespace ywxapp\library;
class TemplateInstaller
{
/**
* zip 部署模板到项目。
*
* @param string $zipPath 模板包 zip 绝对路径
* @param string $rootDir 项目根目录(含 templates/ public/
* @return array 部署后的清单 ['name','version','target_addon','tpl_dir','static_dir']
* @throws \RuntimeException
*/
public static function install(string $zipPath, string $rootDir): array
{
if (!is_file($zipPath)) {
throw new \RuntimeException("模板包不存在: {$zipPath}");
}
if (!class_exists('ZipArchive')) {
throw new \RuntimeException('ZipArchive 扩展不可用');
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new \RuntimeException("无法打开模板包: {$zipPath}");
}
// 先探测模板名(templates/<name>/ 前缀)
$name = '';
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if (preg_match('#^templates/([^/]+)/#', $entry, $m)) {
$name = $m[1];
break;
}
}
if ($name === '') {
$zip->close();
throw new \RuntimeException('zip 中未找到 templates/<name>/ 结构,可能不是合法模板包');
}
$tplTarget = rtrim($rootDir, '/\\') . '/templates/' . $name . '/';
$staticTarget = rtrim($rootDir, '/\\') . '/public/static/templates/' . $name . '/';
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === '' || $entry === null) {
continue;
}
$prefix = 'templates/' . $name . '/';
if (strpos($entry, $prefix) === 0) {
self::writeEntry($zip, $entry, $tplTarget . substr($entry, strlen($prefix)));
} elseif (strpos($entry, 'static/') === 0) {
$rel = substr($entry, strlen('static/'));
if ($rel === '') {
continue;
}
self::writeEntry($zip, $entry, $staticTarget . $rel);
}
}
$zip->close();
$metaFile = $tplTarget . 'template.json';
$meta = is_file($metaFile) ? (json_decode(file_get_contents($metaFile), true) ?? []) : [];
return [
'name' => $name,
'version' => $meta['version'] ?? '',
'title' => $meta['title'] ?? $name,
'target_addon' => $meta['target_addon'] ?? [],
'tpl_dir' => $tplTarget,
'static_dir' => $staticTarget,
];
}
/**
* zip 内单个条目写出到目标路径(目录建目录,文件写内容)。
*/
protected static function writeEntry(ZipArchive $zip, string $entry, string $target): void
{
if (substr($entry, -1) === '/') {
if (!is_dir($target)) {
@mkdir($target, 0755, true);
}
return;
}
$dir = dirname($target);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$content = $zip->getFromName($entry);
if ($content === false) {
return;
}
file_put_contents($target, $content);
}
}
+418
View File
@@ -0,0 +1,418 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 模板管理核心(运行时引擎,站点级运营能力)。
*
* 职责:
* - 扫描已安装模板(templates/<name>/template.json
* - 维护 active_map(插件 => 模板名)并热写回 config/template.php
* - 设置激活 / 卸载时清理 SkinOverlay 渲染缓存(runtime/skin/
*
* TemplateInstaller 分工:Installer 负责「从 zip 落地文件」,
* 本类负责「列出 / 激活 / 卸载 / 配置持久化」。两者都不触碰 DB
* 契合模板不携带 DB 迁移的约定。
*/
class TemplateManager
{
const FILE_HEADER = ""
. "// +----------------------------------------------------------------------\n"
. "// | YwxApp 模板激活配置\n"
. "// | 整站皮肤 + 局部覆盖 + 每插件独立选(Discuz 式卖模板)\n"
. "// | active_map: 插件名 => 模板名;'*' 为全局默认;\n"
. "// | 值为 'default' 或空 = 使用该插件自带视图(不覆盖)。\n"
. "// +----------------------------------------------------------------------\n\n";
/**
* 读取当前激活映射(插件/全局 => 模板名)。
*/
public static function getActiveMap(): array
{
$map = config('template.active_map', []);
return is_array($map) ? $map : [];
}
/**
* 列出所有已安装模板(扫描 templates/<name>/template.json)。
* 返回元素含:name/title/version/author/description/target_addon/
* target_areas/preview/preview_url/applied_to(已被哪些插件激活)
*/
public static function listInstalled(): array
{
$dir = root_path() . 'templates' . DIRECTORY_SEPARATOR;
$out = [];
if (!is_dir($dir)) {
return $out;
}
$map = self::getActiveMap();
foreach (array_diff(scandir($dir), ['.', '..']) as $name) {
$p = $dir . $name . DIRECTORY_SEPARATOR;
if (!is_dir($p)) {
continue;
}
$json = $p . 'template.json';
if (!is_file($json)) {
continue;
}
$meta = json_decode(file_get_contents($json), true) ?? [];
$meta['name'] = $name;
$meta['target_addon'] = $meta['target_addon'] ?? [];
$meta['target_areas'] = $meta['target_areas'] ?? [];
$preview = $meta['preview'] ?? 'preview.png';
$meta['preview_url'] = is_file($p . $preview)
? '/static/templates/' . $name . '/' . $preview
: '';
// 多图预览(template.json 的 previews 数组)+ 首图,去重建灯箱画廊
$previews = $meta['previews'] ?? [];
if (! is_array($previews)) {
$previews = [];
}
$all = array_values(array_unique(array_filter(array_merge([$preview], $previews))));
$urls = [];
foreach ($all as $pg) {
if ($pg !== '' && is_file($p . $pg)) {
$urls[] = '/static/templates/' . $name . '/' . $pg;
}
}
$meta['preview_urls'] = $urls;
$meta['static_url'] = '/static/templates/' . $name . '/';
// 反向推导:哪些插件当前激活了该模板
$meta['applied_to'] = array_keys(array_filter($map, static function ($v) use ($name) {
return $v === $name;
}));
$meta['enabled'] = self::isEnabled($name);
$meta['variable_defs'] = \ywxapp\library\SkinVariables::definitions($name);
$meta['variable_overrides'] = self::getSettings()['variables'][$name] ?? [];
$out[] = $meta;
}
return $out;
}
/**
* 列出可应用模板的插件(含 view 目录的 addon)。
*/
public static function listaddon(): array
{
$addonDir = root_path() . 'addon' . DIRECTORY_SEPARATOR;
$out = [];
if (!is_dir($addonDir)) {
return $out;
}
foreach (array_diff(scandir($addonDir), ['.', '..']) as $name) {
$p = $addonDir . $name;
if (!is_dir($p) || !is_dir($p . DIRECTORY_SEPARATOR . 'view')) {
continue;
}
$title = $name;
if (is_file($p . DIRECTORY_SEPARATOR . 'info.php')) {
$info = include $p . DIRECTORY_SEPARATOR . 'info.php';
$title = $info['title'] ?? $name;
}
$out[] = ['name' => $name, 'title' => $title];
}
return $out;
}
/**
* 设置某插件的激活模板('default'/ = 回退自带视图)。
* 写回 config 文件 + 更新内存配置 + 清理该插件渲染缓存。
*/
public static function setActive(string $addon, string $template): void
{
$map = self::getActiveMap();
if ($template === 'default' || $template === '') {
unset($map[$addon]);
} else {
$map[$addon] = $template;
}
self::setActiveMap($map);
self::clearOverlayCache($addon);
}
/**
* 读取界面设置(允许会员自选 / 禁用列表 / 变量覆盖)。
*/
public static function getSettings(): array
{
$s = config('template.settings', []);
if (! is_array($s)) {
$s = [];
}
$s['disabled'] = $s['disabled'] ?? [];
$s['allow_member_select'] = $s['allow_member_select'] ?? false;
$s['variables'] = $s['variables'] ?? [];
return $s;
}
/**
* 写回 active_map + settings config/template.php(保留注释头),并同步内存配置。
*/
public static function writeConfig(array $map, array $settings): void
{
$file = config_path() . 'template.php';
$content = "<?php\n" . self::FILE_HEADER
. "return [\n"
. " 'active_map' => " . self::exportMap($map) . ",\n"
. " 'settings' => " . self::exportPhp($settings) . ",\n"
. "];\n";
file_put_contents($file, $content);
// 同步内存配置,使当前请求立即生效(新请求会重新加载文件)
\think\facade\Config::set(['active_map' => $map, 'settings' => $settings], 'template');
}
/**
* 仅更新 active_map,保持 settings 不变。
*/
public static function setActiveMap(array $map): void
{
self::writeConfig($map, self::getSettings());
}
/**
* 仅更新 settings,保持 active_map 不变。
*/
public static function setSettings(array $settings): void
{
self::writeConfig(self::getActiveMap(), $settings);
}
/**
* 列出对指定插件/区域「适用」的模板(已安装 + 启用 + 提供该区域覆盖视图)。
* 供前台会员自选切换器枚举可选项。
*/
public static function listApplicable(string $addon, string $area): array
{
$out = [];
$base = root_path() . 'templates' . DIRECTORY_SEPARATOR;
if (! is_dir($base)) {
return $out;
}
foreach (array_diff(scandir($base), ['.', '..']) as $name) {
$p = $base . $name . DIRECTORY_SEPARATOR;
if (! is_dir($p) || ! self::isEnabled($name)) {
continue;
}
$view = $p . 'view' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR;
if (! is_dir($view)) {
continue;
}
$json = $p . 'template.json';
$meta = is_file($json) ? (json_decode(file_get_contents($json), true) ?? []) : [];
$out[] = [
'name' => $name,
'title' => $meta['title'] ?? $name,
'version' => $meta['version'] ?? '',
];
}
return $out;
}
/**
* 生成前台「风格切换器」浮动面板 HTML(含内联脚本)。
* 仅当 allow_member_select 开启且当前插件存在可用模板时返回非空串,否则返回 ''
* 该面板自动读取/写入 cookie `ywx_skin`(访客级覆盖,无需登录、无需 DB)。
*/
public static function switcherHtml(string $addon, string $area): string
{
$settings = self::getSettings();
if (empty($settings['allow_member_select'])) {
return '';
}
$applicable = self::listApplicable($addon, $area);
if (empty($applicable)) {
return '';
}
$current = (string) \think\facade\Request::cookie('ywx_skin', '');
$opts = '<option value="">默认(跟随站点)</option>';
foreach ($applicable as $t) {
$sel = $t['name'] === $current ? ' selected' : '';
$opts .= '<option value="' . htmlspecialchars($t['name'], ENT_QUOTES) . '"' . $sel . '>'
. htmlspecialchars($t['title'], ENT_QUOTES) . ' (' . htmlspecialchars($t['version'], ENT_QUOTES) . ')</option>';
}
return <<<HTML
<div id="ywx-skin-switcher" style="position:fixed;right:16px;bottom:16px;z-index:99999;background:#fff;border:1px solid #e6e6e6;border-radius:10px;padding:10px 12px;box-shadow:0 4px 18px rgba(0,0,0,.15);font-size:13px;color:#333;max-width:240px;">
<div style="margin-bottom:6px;font-weight:600;color:#666;">风格切换</div>
<select id="ywx-skin-select" style="width:100%;padding:5px 6px;border:1px solid #ddd;border-radius:6px;">{$opts}</select>
</div>
<script>
(function(){
var sel = document.getElementById('ywx-skin-select');
if (!sel) return;
sel.onchange = function () {
var v = sel.value;
document.cookie = 'ywx_skin=' + encodeURIComponent(v) + ';path=/;max-age=' + (v ? 31536000 : 0) + ';samesite=lax';
location.reload();
};
})();
</script>
HTML;
}
/**
* 导出任意值(含嵌套数组/字符串/整型/布尔/null)为合法 PHP 字面量片段。
* 用于把 settings(含变量覆盖嵌套数组)持久化进 config/template.php。
*/
protected static function exportPhp($value, int $indent = 4): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_null($value)) {
return 'null';
}
if (is_string($value)) {
return "'" . addslashes($value) . "'";
}
if (is_array($value)) {
if (empty($value)) {
return '[]';
}
$assoc = array_keys($value) !== range(0, count($value) - 1);
$lines = [];
foreach ($value as $k => $v) {
$key = $assoc ? ("'" . addslashes((string) $k) . "' => ") : '';
$lines[] = str_repeat(' ', $indent + 4) . $key . self::exportPhp($v, $indent + 4);
}
return "[\n" . implode(",\n", $lines) . "\n" . str_repeat(' ', $indent) . "]";
}
return 'null';
}
/**
* 保存某模板的变量覆盖值(写入 settings.variables[<name>])。
* @param array $vars 形如 ['primary' => '#ff0000', 'bg' => '#fff']
*/
public static function saveVariables(string $name, array $vars): void
{
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
throw new \RuntimeException('模板标识非法');
}
$settings = self::getSettings();
// 仅保留该模板实际声明的变量键,避免脏数据
$defs = \ywxapp\library\SkinVariables::definitions($name);
$clean = [];
foreach ($defs as $key => $d) {
if (array_key_exists($key, $vars)) {
$clean[$key] = (string) $vars[$key];
}
}
$settings['variables'][$name] = $clean;
self::setSettings($settings);
}
/**
* 启用/禁用模板(禁用后即使被绑定,SkinOverlay 也不生效)。
*/
public static function setEnabled(string $name, bool $enabled): void
{
$settings = self::getSettings();
$disabled = $settings['disabled'] ?? [];
if ($enabled) {
$disabled = array_values(array_diff($disabled, [$name]));
} elseif (! in_array($name, $disabled, true)) {
$disabled[] = $name;
}
$settings['disabled'] = $disabled;
self::setSettings($settings);
}
/**
* 模板是否启用。
*/
public static function isEnabled(string $name): bool
{
$disabled = self::getSettings()['disabled'] ?? [];
return ! in_array($name, $disabled, true);
}
/**
* 卸载模板:删除 templates/<name>/ public/static/templates/<name>/
* 并从 active_map 移除该模板的绑定。
*/
public static function uninstall(string $name): bool
{
$tpl = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
$static = root_path() . 'public' . DIRECTORY_SEPARATOR . 'static'
. DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
if (is_dir($tpl)) {
self::delTree($tpl);
}
if (is_dir($static)) {
self::delTree($static);
}
// 从激活映射中摘除
$map = self::getActiveMap();
$changed = false;
foreach ($map as $k => $v) {
if ($v === $name) {
unset($map[$k]);
$changed = true;
}
}
if ($changed) {
self::setActiveMap($map);
}
return true;
}
/**
* 启用/禁用 / 全局默认 等状态变更后,清理相关渲染缓存。
* @param string $addon 指定插件只清该插件;'' 清全部
*/
public static function clearOverlayCache(string $addon = ''): void
{
$base = runtime_path() . 'skin' . DIRECTORY_SEPARATOR;
if ($addon === '') {
self::delTree($base);
} else {
self::delTree($base . $addon . DIRECTORY_SEPARATOR);
}
}
/**
* 关联数组导出为美观的 PHP 片段(键/值均为字符串)。
*/
protected static function exportMap(array $map): string
{
if (empty($map)) {
return '[]';
}
$lines = [];
foreach ($map as $k => $v) {
$lines[] = " '" . addslashes((string) $k) . "' => '" . addslashes((string) $v) . "',";
}
return "[\n" . implode("\n", $lines) . "\n ]";
}
/**
* 递归删除目录。
*/
protected static function delTree(string $dir): void
{
$dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (!is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir), ['.', '..']) as $item) {
$p = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($p)) {
self::delTree($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 模板(皮肤)覆盖解析器 —— Discuz 式局部覆盖核心。
*
* 解析顺序(优先级高 低):
* 1. templates/<激活模板>/view/<插件>/<区域>/<页面>.html 用户购买的自定义皮肤
* 2. addon/<插件>/view/<区域>/<页面>.html 插件默认视图(回退)
*
* 激活映射见 config/template.php active_map
* ['blog' => 'myblog', '*' => 'default'];值 'default'/ = 用插件自带视图。
*
* 决策(2026-07-28):整站皮肤 + 局部覆盖 + 每插件独立选。
*/
class TemplateResolver
{
/**
* 解析模板覆盖页的绝对路径;未命中返回 null(交由默认视图回退)。
*
* @param string $addon 插件名,如 blog
* @param string $area 区域:frontend / backend / member
* @param string $template 模板名,如 article/detail (与控制器 fetch 入参一致)
* @return string|null
*/
public static function resolve(string $addon, string $area, string $template): ?string
{
// 空模板或跨应用语法(含 @)不处理,直接回退
if ($template === '' || strpos($template, '@') !== false) {
return null;
}
$map = config('template.active_map', []);
if (!is_array($map)) {
return null;
}
$tpl = $map[$addon] ?? ($map['*'] ?? 'default');
if (empty($tpl) || $tpl === 'default') {
return null;
}
$rel = 'templates' . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR
. 'view' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR
. $template . '.html';
$file = root_path() . $rel;
return is_file($file) ? $file : null;
}
}
@@ -0,0 +1,48 @@
<?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\middleware;
use Closure;
use think\Request;
use think\Response;
use ywxapp\service\AddonHotReload;
/**
* 插件热重载中间件
*
* 在开发环境中自动检测插件文件变更并执行热重载
*/
class AddonHotReloadMiddleware
{
/**
* 处理请求
*
* @param Request $request
* @param Closure $next
* @return Response
*/
public function handle(Request $request, Closure $next)
{
// 仅在开发环境启用
if (config('app.app_debug')) {
try {
// 启用自动热重载检测
AddonHotReload::enableAutoReload();
} catch (\Exception $e) {
// 静默处理错误,避免影响正常请求
\think\facade\Log::warning("插件热重载中间件执行失败:" . $e->getMessage());
}
}
return $next($request);
}
}
@@ -0,0 +1,79 @@
<?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\middleware;
use think\facade\Config;
use ywxapp\service\AddonPerformanceMonitor;
/**
* 插件性能监控中间件(全局注册于 app/middleware.php
*
* 设计要点:
* - 从请求路径首段解析插件名(插件路由统一为 /<插件名>/<group>/...),
* 仅对「已启用插件」的请求做性能采样,核心应用(admin/index/api 等)零开销;
* - AddonPerformanceMonitor::measure() 包裹 $next($request),把每次插件
* HTTP 请求的真实耗时/内存/成功失败写入缓存,使 addon:health 的性能项有真数据;
* - measure() finally 已防御性吞掉缓存异常,本中间件无需再包 try/catch
* 且绝不二次调用 $next(避免重复执行业务);
* - 控制器抛异常时 measure() 记录为失败并原样向外抛出,框架错误处理不受影响。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class AddonPerformanceMiddleware
{
public function handle($request, \Closure $next)
{
$addon = $this->resolveAddon($request);
if ($addon === null) {
return $next($request);
}
$action = $this->resolveAction($request);
return AddonPerformanceMonitor::measure($addon, $action, function () use ($next, $request) {
return $next($request);
});
}
/**
* 从路径首段解析插件名,并确认其为已启用插件(Config::get('addon') 仅含 state=1
*/
private function resolveAddon($request): ?string
{
$path = trim((string) $request->pathinfo(), '/');
if ($path === '') {
return null;
}
$segments = explode('/', $path);
$maybe = $segments[0];
$addon = Config::get('addon', []);
foreach ($addon as $info) {
if (($info['name'] ?? '') === $maybe) {
return $maybe;
}
}
return null;
}
/**
* 动作标签:HTTP 方法 + 插件名之后的剩余路径(如 GET backend/addonreview/index
*/
private function resolveAction($request): string
{
$path = trim((string) $request->pathinfo(), '/');
$segments = explode('/', $path);
array_shift($segments); // 去掉插件名首段
$rest = implode('/', $segments);
return ($request->method() ?? 'GET') . ' ' . ($rest === '' ? 'index' : $rest);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\middleware;
/**
* AllowCrossDomain
*
* @author ywxapp <admin@ywxapp.cn>
*/
class AllowCrossDomain
{
public function handle($request, \Closure $next)
{
$origin = $request->header('Origin') ?: '';
// 允许的域名列表(根据实际情况修改)
$allowedOrigins = [
'http://localhost',
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1',
'http://127.0.0.1:3000',
'https://your-production-domain.com'
];
// 检查来源是否允许
if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
// 明确允许的请求头(必须包含 content-type
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-CSRF-TOKEN');
// 允许的方法
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
// 预检请求直接返回
if ($request->method() == 'OPTIONS') {
header('Access-Control-Max-Age: 86400'); // 24小时缓存
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Length: 0');
return response()->code(204);
}
return $next($request);
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace ywxapp\middleware;
use Closure;
use think\App;
use think\exception\HttpException;
use think\Request;
use think\Response;
/**
* 多根目录多应用中间件
*
* 核心语义:
* - "blog" roots顺序查找,找到第一个匹配即用
* - "app2:blog" 强制在 /app2/blog找,找不到直接 404
* - 都不支持"先查 app找不到再降级到 app2"再降级这种串行写法
* (显式前缀必须严格匹配,避免行为不可预测)
*/
class MultiApp
{
protected App $app;
/** @var array<string,string> 根目录标识 => 绝对路径 */
protected array $roots = [];
/** @var array<string,string> 根目录标识 => 命名空间前缀 */
protected array $namespaces = [];
public function __construct(App $app)
{
$this->app = $app;
$this->roots = $app->config->get('app.app_roots', [
'app' => $app->getBasePath(),
'addon' => $app->getRootPath() . 'addon' . DIRECTORY_SEPARATOR,
]);
// $this->namespaces = $app->config->get('app.app_namespaces', [
// 'app' => 'app',
// 'addon' => 'addon',
// ]);
$this->namespaces = [
'app' => 'app',
'addon' => 'addon',
];
}
public function handle($request, Closure $next)
{
if (!$this->parseMultiApp()) {
return $next($request);
}
return $this->app->middleware
->pipeline('app')
->send($request)
->then(function ($request) use ($next) {
return $next($request);
});
}
protected function getRoutePath(): string
{
return $this->app->getAppPath() . 'route' . DIRECTORY_SEPARATOR;
}
/**
* 核心解析
*/
protected function parseMultiApp(): bool
{
$scriptName = $this->getScriptName();
$defaultApp = $this->app->config->get('app.default_app') ?: 'index';
$appName = $this->app->http->getName();
// ==================== 阶段 1:独立入口 / 显式绑定 ====================
if ($appName || ($scriptName && !in_array($scriptName, ['index', 'router', 'think']))) {
$appName = $appName ?: $scriptName;
$this->app->http->setBind();
// 独立入口可不走 domain_bind,直接解析
$resolved = $this->resolve($appName);
if (!$resolved) {
throw new HttpException(404, 'app not exists:' . $appName);
}
return $this->applyResolved($resolved, $appName);
}
// ==================== 阶段 2: 自动识别 ====================
$this->app->http->setBind(false);
$appName = null;
// -------- 2.1 域名绑定 --------
$bind = $this->app->config->get('app.domain_bind', []);
if (!empty($bind)) {
$subDomain = $this->app->request->subDomain();
$domain = $this->app->request->host(true);
if (isset($bind[$domain])) {
$appName = $bind[$domain];
$this->app->http->setBind();
} elseif (isset($bind[$subDomain])) {
$appName = $bind[$subDomain];
$this->app->http->setBind();
} elseif (isset($bind['*'])) {
$appName = $bind['*'];
$this->app->http->setBind();
}
}
if ($this->app->http->isBind()) {
$resolved = $this->resolve($appName);
if (!$resolved) {
throw new HttpException(404, 'app not exists:' . $appName);
}
return $this->applyResolved($resolved, $appName);
}
// -------- 2.2 URL 路径识别 --------
$path = $this->app->request->pathinfo();
$map = $this->app->config->get('app.app_map', []);
$deny = $this->app->config->get('app.deny_app_list', []);
$fullName = current(explode('/', $path));
if (strpos($fullName, '.')) {
$fullName = strstr($fullName, '.', true);
}
// 分支1: 命中 app_map
if (isset($map[$fullName])) {
if ($map[$fullName] instanceof Closure) {
$mapped = call_user_func_array($map[$fullName], [$this->app]) ?: $fullName;
} else {
$mapped = $map[$fullName];
}
$resolved = $this->resolve($mapped);
}
// 分支 2: 黑名单 / map 显式禁用
elseif ($fullName !== '' && (false !== array_search($fullName, $map) || in_array($fullName, $deny))) {
throw new HttpException(404, 'app not exists:' . $fullName);
}
// 分支 3: map 通配 *
elseif ($fullName !== '' && isset($map['*'])) {
$resolved = $this->resolve($map['*']);
}
// 分支 4: URL 段本身带前缀(强制指定根)
elseif (str_contains($fullName, ':')) {
$resolved = $this->resolve($fullName);
}
// 分支 5: 默认行为 —— 多根顺序查找
else {
$name = $fullName !== '' ? $fullName : null;
// URL 没给应用名 → 用 default_app
$name ??= $defaultApp;
$resolved = $this->resolve($name); // ← prefix='',走多根查找
}
// ===== 查找失败处理 =====
if (!$resolved) {
// app_express=true 且不是显式前缀时 → 兜底 default_app
$express = $this->app->config->get('app.app_express', false);
if ($express && !str_contains($fullName, ':')) {
$resolved = $this->resolve($defaultApp);
}
if (!$resolved) {
throw new HttpException(404, 'app not exists:' . ($fullName ?: $defaultApp));
}
}
// 重写 pathinfo: 把 URL 第一段剥掉
if ($fullName) {
$this->app->request->setRoot('/' . $fullName);
$this->app->request->setPathinfo(
strpos($path, '/') ? ltrim(strstr($path, '/'), '/') : ''
);
}
return $this->applyResolved($resolved, $fullName);
}
/**
* 解析应用名为最终 [prefix, name, appPath]
* 支持:
* "app2:blog" ['app2', 'blog', '/www/app2/blog/']
* "blog" ['', 'blog', null] 调用方拿到 null 时按 roots 顺序兜底
*/
protected function resolve(string $appName): ?array
{
[$prefix, $name] = $this->splitAppName($appName);
if ($prefix !== '') {
// 显式前缀: 必须严格匹配,失败立即返回 null(不再降级)
if (!isset($this->roots[$prefix])) {
return null;
}
$path = $this->roots[$prefix] . $name . DIRECTORY_SEPARATOR;
return is_dir($path) ? [$prefix, $name, $path] : null;
}
// 多根顺序查找
foreach ($this->roots as $key => $root) {
$path = $root . $name . DIRECTORY_SEPARATOR;
if (is_dir($path)) {
return [$key, $name, $path];
}
}
return null;
}
/**
* 拆分应用名
* "app2:blog" ["app2", "blog"]
* "blog" ["", "blog"]
*/
protected function splitAppName(string $fullName): array
{
if ($fullName !== '' && str_contains($fullName, ':')) {
[$prefix, $name] = explode(':', $fullName, 2);
return [$prefix ?: '', $name];
}
return ['', $fullName];
}
protected function getScriptName(): string
{
if (isset($_SERVER['SCRIPT_FILENAME'])) {
$file = $_SERVER['SCRIPT_FILENAME'];
} elseif (isset($_SERVER['argv'][0])) {
$file = realpath($_SERVER['argv'][0]);
}
return isset($file) ? pathinfo($file, PATHINFO_FILENAME) : '';
}
/**
* resolve() 结果落地到 App上下文
*/
protected function applyResolved(array $resolved, string $urlName): bool
{
[$prefix, $name, $appPath] = $resolved;
$this->app->http->name($name);
// 应用目录
$this->app->setAppPath($appPath);
// 命名空间
$nsPrefix = $this->namespaces[$prefix] ?? $prefix;
$this->app->setNamespace($nsPrefix . '\\' . $name);
// 运行时: 按根 + 应用双层隔离
$this->app->setRuntimePath(
$this->app->getRuntimePath() . $prefix . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR
);
// 路由目录
$this->app->http->setRoutePath($this->getRoutePath());
// 应用专属配置/中间件/provider/语言包
$this->loadApp($name, $appPath);
return true;
}
protected function loadApp(string $appName, string $appPath): void
{
if (is_file($appPath . 'common.php')) {
include_once $appPath . 'common.php';
}
$files = glob($appPath . 'config' . DIRECTORY_SEPARATOR . '*' . $this->app->getConfigExt());
foreach ($files as $file) {
$this->app->config->load($file, pathinfo($file, PATHINFO_FILENAME));
}
if (is_file($appPath . 'event.php')) {
$this->app->loadEvent(include $appPath . 'event.php');
}
if (is_file($appPath . 'middleware.php')) {
$this->app->middleware->import(include $appPath . 'middleware.php', 'app');
}
if (is_file($appPath . 'provider.php')) {
$this->app->bind(include $appPath . 'provider.php');
}
$this->app->loadLangPack($this->app->lang->defaultLangSet());
}
}
+116
View File
@@ -0,0 +1,116 @@
<?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\middleware;
use think\facade\Db;
use ywxapp\model\BaseModel;
use ywxapp\library\SpiderDetect;
/**
* 全站搜索蜘蛛统计中间件(全局注册于 app/middleware.php
*
* 设计要点:
* - 仅命中蜘蛛 UA 才落库,普通用户请求零额外查询;
* - 落库在响应生成之后($next 之后),不阻塞蜘蛛抓取响应;
* - 明细写 spider_log,按日聚合 upsert spider_stat(报表免全表扫描);
* - 任何数据库异常静默吞掉(统计绝不能影响业务),缺表时经 BaseModel 自愈引擎 自愈一次;
* - install.sql 为唯一事实源,此处 DDL 仅为老库运行时兜底。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class SpiderStat
{
public function handle($request, \Closure $next)
{
$response = $next($request);
try {
$ua = (string) $request->header('user-agent', '');
$spider = SpiderDetect::detect($ua);
if ($spider !== null) {
$this->record($request, $response, $spider, $ua);
}
} catch (\Throwable $e) {
// 统计失败绝不影响正常响应
}
return $response;
}
/**
* 落库:明细 + 按日聚合
*/
protected function record($request, $response, string $spider, string $ua): void
{
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
$logTable = $prefix . 'spider_log';
$statTable = $prefix . 'spider_stat';
$data = [
'spider' => $spider,
'url' => mb_substr((string) $request->url(), 0, 500),
'ip' => mb_substr((string) $request->ip(), 0, 45),
'app' => mb_substr((string) (app('http')->getName() ?: ''), 0, 20),
'user_agent' => mb_substr($ua, 0, 500),
'http_code' => method_exists($response, 'getCode') ? (int) $response->getCode() : 200,
'create_at' => time(),
];
try {
$this->insert($logTable, $statTable, $data, $spider);
} catch (\Throwable $e) {
// 表可能不存在(老库未升级):自愈一次后重试
$this->ensureTables($logTable, $statTable);
$this->insert($logTable, $statTable, $data, $spider);
}
}
/**
* 写明细 + 聚合 upsert
*/
protected function insert(string $logTable, string $statTable, array $data, string $spider): void
{
Db::table($logTable)->insert($data);
// 按日聚合:主键(stat_date, spider),存在则计数+1
Db::execute(
"INSERT INTO `{$statTable}` (`stat_date`, `spider`, `count`) VALUES (?, ?, 1) "
. "ON DUPLICATE KEY UPDATE `count` = `count` + 1",
[date('Y-m-d'), $spider]
);
}
/**
* 缺表自愈(DDL public/install/install.sql 保持一致)
*/
protected function ensureTables(string $logTable, string $statTable): void
{
BaseModel::ensureTable($logTable, "CREATE TABLE IF NOT EXISTS `{$logTable}` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`spider` varchar(20) NOT NULL DEFAULT '' COMMENT '蜘蛛标识',
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '抓取URL',
`ip` varchar(45) NOT NULL DEFAULT '' COMMENT '来源IP',
`app` varchar(20) NOT NULL DEFAULT '' COMMENT '应用名',
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT 'User-Agent',
`http_code` smallint unsigned NOT NULL DEFAULT '200' COMMENT '响应状态码',
`create_at` int NOT NULL DEFAULT '0' COMMENT '抓取时间',
PRIMARY KEY (`id`),
KEY `idx_spider_time` (`spider`,`create_at`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='搜索蜘蛛抓取日志'");
BaseModel::ensureTable($statTable, "CREATE TABLE IF NOT EXISTS `{$statTable}` (
`stat_date` date NOT NULL COMMENT '统计日期',
`spider` varchar(20) NOT NULL DEFAULT '' COMMENT '蜘蛛标识',
`count` int unsigned NOT NULL DEFAULT '0' COMMENT '抓取次数',
PRIMARY KEY (`stat_date`,`spider`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='搜索蜘蛛按日统计'");
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace ywxapp\model;
use think\facade\Db;
use ywxapp\model\BaseModel;
class Ad extends BaseModel
{
protected $name = 'ad';
// 广告类型
const TYPE_IMAGE = 1; // 图片
const TYPE_TEXT = 2; // 文字
const TYPE_CODE = 3; // 代码
// 广告位标识(预埋位:已挂模板的 4 个 + 预留候选,随时可在前台 include 使用)
const POS_HOME_TOP = 'home_top'; // 首页顶部横幅(已挂)
const POS_HOME_SIDE = 'home_side'; // 首页侧边(已挂)
const POS_POPUP = 'popup'; // 全站弹窗(已挂)
const POS_LIST_BOTTOM = 'list_bottom'; // 列表底部(已挂)
// —— 以下为预埋预留位,前台模板尚未挂载,需要时在对应页面加:
// {assign name="slot" value="home_bottom" /}{include file="common/adslot" /}
const POS_HOME_BOTTOM = 'home_bottom'; // 首页底部
const POS_CONTENT_TOP = 'content_top'; // 内容详情页顶部
const POS_CONTENT_BOTTOM = 'content_bottom'; // 内容详情页底部
const POS_SIDEBAR = 'sidebar'; // 通用侧边栏
const POS_FLOAT = 'float'; // 全站悬浮角标
public function getOptions(): array
{
return [
'name' => 'ad',
'strict' => true,
'autoWriteTimestamp' => true,
'createTime' => 'create_at',
'updateTime' => 'update_at',
'deleteTime' => 'delete_at',
];
}
protected $type = [
'id' => 'integer',
'type' => 'integer',
'sort' => 'integer',
'status' => 'integer',
'create_at' => 'integer',
'update_at' => 'integer',
'delete_at' => 'integer',
];
protected $readonly = [];
public static function typeList(): array
{
return [
self::TYPE_IMAGE => '图片',
self::TYPE_TEXT => '文字',
self::TYPE_CODE => '代码',
];
}
public static function positionList(): array
{
return [
self::POS_HOME_TOP => '首页顶部横幅',
self::POS_HOME_SIDE => '首页侧边',
self::POS_POPUP => '全站弹窗',
self::POS_LIST_BOTTOM => '列表底部',
self::POS_HOME_BOTTOM => '首页底部',
self::POS_CONTENT_TOP => '内容页顶部',
self::POS_CONTENT_BOTTOM => '内容页底部',
self::POS_SIDEBAR => '通用侧边栏',
self::POS_FLOAT => '全站悬浮角标',
];
}
/**
* 运行时自愈:确保 ad 表存在 position 列,避免老库缺列导致列表/取数报错
*/
public static function ensureSchema(): void
{
try {
$p = self::currentPrefix();
$prefix = $p;
$table = $prefix . 'ad';
// 缺表建表(install.sql L638 同款 DDL,运行时前缀兜底)
BaseModel::ensureTable($table, "CREATE TABLE IF NOT EXISTS `{$p}ad` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '广告标题',
`type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '类型 1图片2文字3代码',
`position` varchar(50) NOT NULL DEFAULT '' COMMENT '广告位标识 home_top首页顶部 home_side首页侧边 popup全站弹窗 list_bottom列表底部',
`content` text COMMENT '广告内容/代码',
`url` varchar(255) NOT NULL DEFAULT '' COMMENT '跳转链接',
`image` varchar(255) NOT NULL DEFAULT '' COMMENT '图片地址',
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态',
`create_at` int(11) NOT NULL DEFAULT 0 COMMENT '创建时间',
`update_at` int(11) NOT NULL DEFAULT 0 COMMENT '更新时间',
`delete_at` int(11) NOT NULL DEFAULT 0 COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_position` (`position`),
KEY `idx_status_sort` (`status`,`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站点广告'");
// 老库扩展列兜底(幂等)
BaseModel::ensureColumn($table, 'position', "varchar(50) NOT NULL DEFAULT '' COMMENT '广告位标识'");
} catch (\Throwable $e) {
// 自愈失败不影响主流程
}
}
/**
* 按广告位取启用广告(按 sort 升序)
* @param string $position 广告位标识
* @return array
*/
public static function getByPosition(string $position): array
{
self::ensureSchema();
$rows = Db::name('ad')
->where('status', 1)
->where('position', $position)
->where('delete_at', 0)
->order('sort', 'asc')
->order('id', 'desc')
->select()
->toArray();
$typeList = self::typeList();
foreach ($rows as &$row) {
$row['type_text'] = $typeList[$row['type']] ?? '图片';
}
return $rows;
}
/**
* 取全部启用广告,按 position 分组,便于前台模板随处调用
* @return array [position => [ad, ...]]
*/
public static function getSlots(): array
{
self::ensureSchema();
$positions = array_keys(self::positionList());
$slots = array_fill_keys($positions, []);
$rows = Db::name('ad')
->where('status', 1)
->where('position', 'in', $positions)
->where('delete_at', 0)
->order('sort', 'asc')
->order('id', 'desc')
->select()
->toArray();
$typeList = self::typeList();
foreach ($rows as $row) {
$row['type_text'] = $typeList[$row['type']] ?? '图片';
$slots[$row['position']][] = $row;
}
return $slots;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?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\model;
use ywxapp\model\BaseModel;
class AddonModel extends BaseModel
{
// -- 插件主表
// CREATE TABLE `addon` (
// `id` int unsigned AUTO_INCREMENT PRIMARY KEY,
// `name` varchar(50) NOT NULL COMMENT '标识符',
// `title` varchar(100) NOT NULL COMMENT '名称',
// `version` varchar(20) NOT NULL COMMENT '版本号',
// `price` decimal(10,2) NOT NULL COMMENT '价格',
// `file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
// `file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
// `status` tinyint DEFAULT 1 COMMENT '1上架 0下架'
// );
protected function getOptions(): array
{
return [
'strict' => true,
'name' => 'addon',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
'schema' => [
'id' => 'int',
'name' => 'string',
'title' => 'string',
'version' => 'string',
'price' => 'float',
'description' => 'string',
'author' => 'string',
'status' => 'tinyint',
'config' => 'text',
'file_path' => 'string',
'file_hash' => 'string',
'sort' => 'int',
'create_at' => 'int',
'update_at' => 'int',
]
];
}
// // 获取已安装插件列表
// public function getInstalledaddon(): array
// {
// return $this->where('status', '<>', self::STATUS_DISABLE)->order('create_at', 'desc')->select()->toArray();
// }
// // 检查插件是否已安装
// public function isInstalled(string $name): bool
// {
// return $this->where('name', $name)->count() > 0;
// }
}
+142
View File
@@ -0,0 +1,142 @@
<?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\model;
use think\Model;
class Attachment extends BaseModel
{
// 设置字段信息
protected $schema = [
//文件ID
'id' => 'int',
//所属用户ID
'uid' => 'int',
//所属模块 (admin, index, api)
'module' => 'string',
//文件相对路径 (如: 2026/01/21/filename.jpg)
'path' => 'string',
//文件访问URL (全路径)
'url' => 'string',
//原始文件名
'original' => 'string',
//存储文件名 (不含路径)
'name' => 'string',
//文件大小 (字节)
'size' => 'int',
//文件后缀
'ext' => 'string',
//MIME类型
'mime' => 'string',
//存储引擎 (local, alioss, qcos, qiniu)
'storage' => 'string',
//驱动特定信息 (如OSS的ETag, Bucket等)
'driver_info' => 'json',
//是否为图片
'is_image' => 'bool',
//图片宽度
'width' => 'int',
//图片高度
'height' => 'int',
//上传者IP
'upload_ip' => 'string',
//创建时间
'create_at' => 'int',
//更新时间
'update_at' => 'int',
];
// 自动写入时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
// 隐藏字段
protected $hidden = ['upload_ip', 'driver_info'];
/**
* 关联用户模型(如果存在)
* @return \think\model\relation\BelongsTo
*/
public function user()
{
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
}
// /**
// * 获取完整URL(如果数据库里的URL是相对路径,自动补全域名)
// * @param string $value
// * @return string
// */
// public function getUrlAttr($value)
// {
// if ($value && ! str_starts_with($value, 'http')) {
// return Request::domain() . '/' . ltrim($value, '/');
// }
// return $value;
// }
/**
* 运行时自愈:确保 attachment 主表存在(install.sql 为事实源)。
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'attachment');
}
/**
* 初始化:自愈建表,避免远程库缺失 wxapp_attachment 导致 1146
*/
protected function initialize()
{
parent::initialize();
self::ensureSchema();
}
/**
* 格式化文件大小
* @return string
*/
public function getFormattedSizeAttr(): string
{
$bytes = $this->getData('file_size');
if ($bytes >= 1024 * 1024 * 1024) {
return number_format($bytes / (1024 * 1024 * 1024), 2) . ' GB';
} elseif ($bytes >= 1024 * 1024) {
return number_format($bytes / (1024 * 1024), 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' B';
}
}
/**
* 设置驱动信息(自动JSON编码)
* @param mixed $value
* @return void
*/
public function setDriverInfoAttr($value)
{
$this->setAttr('driver_info', json_encode($value, JSON_UNESCAPED_UNICODE));
}
/**
* 获取驱动信息(自动JSON解码)
* @param string $value
* @return array
*/
public function getDriverInfoAttr($value)
{
return $value ? json_decode($value, true) : [];
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\concern\SoftDelete;
use ywxapp\model\BaseModel;
/**
* Backend
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendAdmin extends BaseModel
{
use SoftDelete;
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'backend_admin',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
'deleteTime' => 'delete_at',
'defaultSoftDelete' => 0,
// 'dateFormat' => 'Y-m-d H:i:s',
'append' => ['status_text', 'last_login'],
'hidden' => ['password', 'delete_at'],
'readonly' => ['id'],
];
}
/**
* 管理员角色关联
*/
public function roles()
{
return $this->belongsToMany(BackendRole::class, BackendRoleAccess::class, 'role_id', 'admin_id');
}
// 密码字段自动加密
public function setPasswordAttr($value)
{
// 使用PHP内置的password_hash,无需salt字段
if (! password_get_info($value)['algo']) {
// 如果不是已哈希的密码,则进行哈希处理
return password_hash($value, PASSWORD_DEFAULT);
}
return $value;
}
// 验证密码
public function checkPassword($password)
{
return password_verify($password, $this->password);
}
// 获取状态文本
public function getStatusTextAttr()
{
$statusMap = [0 => '禁用', 1 => '正常', 2 => '锁定'];
return $statusMap[$this->status] ?? '未知';
}
// 获取最后登录描述
public function getLastLoginAttr()
{
if (empty($this->login_time)) {
return '从未登录';
}
$diff = time() - strtotime($this->login_time);
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return floor($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return floor($diff / 3600) . '小时前';
} else {
return date('Y-m-d', strtotime($this->login_time));
}
}
// 检查账户是否被锁定
public function isLocked()
{
if ($this->status == 0) {
return true; // 已禁用
}
if ($this->fail_count >= 5 && ! empty($this->lock_time)) {
$lockUntil = strtotime($this->lock_time) + 1800; // 锁定30分钟
return time() < $lockUntil;
}
return false;
}
// 重置密码
public function resetPassword($newPassword)
{
$this->password = $newPassword; // 会触发setPasswordAttr自动加密
$this->need_reset = 0; // 标记无需重置
return $this->save();
}
// 记录登录失败(累计失败次数,超过阈值锁定)
public function recordLoginFail($ip)
{
$this->fail_count += 1;
if ($this->fail_count >= 5) {
$this->lock_time = date('Y-m-d H:i:s');
}
$this->save();
}
// 记录登录成功(重置失败计数与锁定)
public function recordLoginSuccess()
{
$this->login_time = time();
$this->fail_count = 0;
$this->lock_time = null;
$this->save();
}
// public function getAvatarAttr($value, $data)
// {
// return '/static/common/images/avatar.jpg';
// }
/**
* 获取用户有权访问的菜单树
*/
public function getAccessibleMenus()
{
$roleIds = $this->roles->column('id');
$roleNames = $this->roles->column('name');
// 超级管理员:直接可见全部后台菜单(与 getPermissionNames 的 * 语义保持一致)
$superRoleId = config('ywxapp.superAdmin', 1);
if (in_array('superadmin', $roleNames, true) || in_array($superRoleId, $roleIds, true)) {
$flatMenus = BackendPower::field('id,title,name,pid,sort,route,icon,type,addon')
->where('type', '<', '3')
->order('sort', 'asc')
->select()
->toArray();
foreach ($flatMenus as &$menu) {
// 依据权限表 addon 字段判定:空=核心后台菜单(拼 /admin/ 前缀),非空=插件菜单(完整路径直出)。
$menu['route'] = $this->buildMenuUrl($menu['route'], $menu['addon'] ?? null);
}
return BackendPower::buildTree($flatMenus);
}
if (empty($roleIds)) {
return [];
}
$permissions = BackendRolePower::where('role_id', 'in', $roleIds)
->distinct(true)
->column('power_id');
if (empty($permissions)) {
return [];
}
$flatMenus = BackendPower::field('id,title,name,pid,sort,route,icon,type,addon')
->whereIn('id', array_values($permissions))
->where('type', '<', '3') // 只获取菜单权限
->order('sort', 'asc')
->select()
->toArray();
foreach ($flatMenus as &$menu) {
// 依据权限表 addon 字段判定:空=核心后台菜单(直接拼 URL),非空=插件菜单(走 url 反转)。
$menu['route'] = $this->buildMenuUrl($menu['route'], $menu['addon'] ?? null);
}
return BackendPower::buildTree($flatMenus);
}
/**
* 获取用户所有权限标识(字符串数组)
*/
public function getAllPermissions(): array
{
$roleIds = $this->roles->column('id');
if (empty($roleIds)) {
return [];
}
return BackendRolePower::where('role_id', 'in', $roleIds)
->distinct(true)
->column('power_id');
}
/**
* 获取用户的所有权限(通过角色)
*/
public function permissions()
{
return $this->belongsToMany(BackendPower::class, BackendRolePower::class, 'power_id', 'role_id')->via('roles'); // 通过 roles 关联自动关联权限
}
/**
* 获取权限标识数组(name
* 通过 角色→权限 关联可靠地取出当前管理员拥有的权限 key
*/
public function getPermissionNames()
{
$roleIds = $this->roles->column('id');
if (empty($roleIds)) {
return [];
}
// 拥有超级管理员角色:权限恒为 *(全部权限),校验/展示均以 * 表示
$superRoleId = config('ywxapp.superAdmin', 1);
$roleNames = $this->roles->column('name');
if (in_array('superadmin', $roleNames, true) || in_array($superRoleId, $roleIds, true)) {
return ['*'];
}
$powerIds = BackendRolePower::where('role_id', 'in', $roleIds)
->distinct(true)
->column('power_id');
if (empty($powerIds)) {
return [];
}
return BackendPower::whereIn('id', $powerIds)->column('name');
}
/**
* 检查是否有某权限
*/
public function can($permissionName)
{
$names = $this->getPermissionNames();
// 权限集合含 * 表示拥有全部权限
return in_array('*', $names, true) || in_array($permissionName, $names);
}
/**
* 菜单 URL 生成
*
* 依据权限表 addon 字段判定归属:
* - addon 为空:核心后台应用内菜单,直接按 app_map 反查 backend 应用前缀拼接
* `/{prefix}/{route}.html`,不调用 url() 反转,彻底规避插件路由抢注与
* IP/域名访问差异导致的生成异常。
* - addon 非空:插件后台菜单,走 url() 反转(全限定 `addon/backend/...`
* 不会被插件路由同名规则抢注)。
* - 外部链接(http://、https:////)原样返回;空 route 返回 #。
*
* @param string|null $route 菜单路由(如 links/index、framework/index、haonav/backend/links
* @param string|null $addon 权限表 addon 字段(核心菜单为 null/空,插件菜单为插件名)
*/
protected function buildMenuUrl(?string $route, ?string $addon = null): string
{
if (! $route) {
return '#';
}
if (preg_match('#^(https?://|//)#i', $route)) {
return $route;
}
$route = ltrim($route, '/');
// 插件菜单:route 字段已是完整可访问路径(如 /appmall/backend/developer/index
// 与插件 menu.json 约定一致),直接拼后缀输出即可。
// 切勿用 url() 反转:url() 会将该字符串当成 MVC 控制器地址解析,导致被误映射到
// 核心后台 /admin/ 前缀(如 /admin/developer/index.html),而非插件真实路径。
if (! empty($addon)) {
$suffix = config('route.url_html_suffix');
$u = '/' . $route;
if ($suffix && $suffix !== '' && ! preg_match('/\.' . preg_quote(ltrim($suffix, '.'), '/') . '$/i', $u)) {
$u .= '.' . ltrim($suffix, '.');
}
return $u;
}
// 核心后台菜单:按 app_map 反查 backend 应用前缀,直接拼接,避免 url() 反转歧义
$appMap = (array) config('app.app_map');
$appPrefix = array_search('backend', $appMap, true);
if (! $appPrefix) {
$appPrefix = 'admin';
}
return '/' . $appPrefix . '/' . $route . '.html';
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
$prefix = BaseModel::currentPrefix();
foreach (['backend_admin', 'backend_log', 'backend_power', 'backend_role', 'backend_role_access', 'backend_role_power'] as $t) {
BaseModel::ensureTableFromInstall($prefix, $t);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use ywxapp\model\BaseModel;
/**
* BackendLog
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendLog extends BaseModel{
protected $name = 'backend_log';
protected $updateTime = false;
/**
* 获取用户的角色
*
* 此方法定义了用户与角色之间的多对多关系它解释了用户可以拥有多个角色,
* 同时一个角色也可以被多个用户共享这种关系通过中间表'user_role'来维护,
* 其中'user_id'关联用户的ID'role_id'关联角色的ID
*
* @return \think\model\relation\BelongsToMany
* 返回一个BelongsToMany实例,用于表示多对多的Eloquent关系
*/
public function roles()
{
return $this->belongsToMany(Role::class, 'access', 'rid','aid');
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'backend_log');
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\concern\SoftDelete;
use ywxapp\model\BaseModel;
/**
* BackendPower
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendPower extends BaseModel
{
use SoftDelete;
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'backend_power',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
'deleteTime' => 'delete_at',
'defaultSoftDelete' => 0,
'append' => ['target'],
'hidden' => ['create_at', 'update_at', 'delete_at'],
'readonly' => ['id'],
];
}
public static function onAfterRead($data)
{
// 这里可以直接使用$this访问当前模型
// if ($data->type == 2) {
// $data->append(['href', 'openType']);
// $data->openType = '_iframe';
// $data->href = $data->route;
// }
}
public static function onAfterDelete($data)
{
BackendRolePower::where('power_id', $data->id)->delete();
}
/**
* 获取器:获取状态文本
*/
public function getTargetAttr($value, $data)
{
return '_self';
}
// 自关联子菜单
public function children()
{
return $this->hasMany(BackendPower::class, 'pid', 'id')->order('sort', 'asc');
}
// 拥有该权限的角色
public function roles()
{
return $this->belongsToMany(BackendRole::class, BackendRolePower::class, 'role_id', 'power_id');
}
// 拥有该权限的用户(通过角色)
public function admins()
{
return $this->belongsToMany(BackendAdmin::class, BackendRoleAccess::class, 'admin_id', 'role_id')->via('roles');
}
/**
* 将扁平菜单数组转为树形结构
* @param array $menus 扁平菜单列表(每个元素是数组)
* @param int $parentId 父ID(默认0表示根)
* @return array 树形结构
*/
public static function buildTree(array $menus, int $parentId = 0): array
{
$branch = [];
foreach ($menus as $menu) {
if ($menu['pid'] == $parentId) {
$children = self::buildTree($menus, $menu['id']);
if (! empty($children)) {
$menu['child'] = $children;
}
$branch[] = $menu;
}
}
return $branch;
}
/**
* 【可选】从 Collection 转为树(如果你用模型查询)
*/
public static function buildTreeFromCollection(Collection $collection, int $parentId = 0): array
{
$menus = $collection->toArray();
return self::buildTree($menus, $parentId);
}
/**
* 获取权限树形结构
* @param int $parentId 父ID(默认0表示根)
* @return array 树形结构
*/
public static function getTree($parentId = 0)
{
$list = self::where('pid', $parentId)
->order('sort', 'asc')
->select()
->toArray();
foreach ($list as &$item) {
$item['children'] = self::getTree($item['id']);
}
return $list;
}
/**
* 无限分类-权限
* @param array $cate 栏目
* @param string $lefthtml 分隔符
* @param int $pid 父ID
* @param int $level 层级
* @return array
*/
public static function cateTree($cate, $name = 'title', $lefthtml = '|— ', $pid = 0, $level = 0)
{
$arr = [];
foreach ($cate as $v) {
if ($v['pid'] == $pid) {
$v['level'] = $level + 1;
$v['lefthtml'] = str_repeat($lefthtml, $level);
$v['l' . $name] = $v['lefthtml'] . lang($v[$name]);
$arr[] = $v;
$arr = array_merge($arr, self::cateTree($cate, $name, $lefthtml, $v['id'], $level + 1));
}
}
return $arr;
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'backend_power');
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\concern\SoftDelete;
use ywxapp\model\BaseModel;
/**
* BackendRole
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendRole extends BaseModel
{
use SoftDelete;
// 设置表名(think-orm 4.0name 不带前缀,自动拼 wxapp_
protected $name = 'backend_role';
// 设置主键
protected $pk = 'id';
// 自动时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
protected $deleteTime = 'delete_at';
// 隐藏字段
protected $hidden = ['password', 'delete_at'];
// 只读字段
protected $readonly = ['id'];
// 角色拥有的用户
public function admins()
{
return $this->belongsToMany(BackendAdmin::class, BackendRoleAccess::class, 'admin_id', 'role_id');
}
/**
* 角色 权限字符串(通过 role_permissions 表)
*/
public function powers()
{
// 返回的是 permission 字符串列表(不是 Menu 模型)
return $this->belongsToMany(BackendPower::class, BackendRolePower::class, 'power_id', 'role_id')->field('power_id');
}
// 【推荐】角色 → 菜单模型(通过中间表 role_permissions 关联 menus
public function menus()
{
return $this->belongsToMany(BackendPower::class, BackendRolePower::class, 'power_id', 'role_id', 'permission');
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'backend_role');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-08-06 22:05:48
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-10 09:44:50
* @Description:
* @FilePath: \ywxapp_dev\ywxapp\model\BackendRoleAccess.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\Pivot;
use ywxapp\model\BaseModel;
/**
* BackendRoleAccess
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendRoleAccess extends Pivot{
protected $name = 'backend_role_access';
// 自动时间戳 (create_at 已统一为 int 时间戳)
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = false;
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'backend_role_access');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\Model;
use ywxapp\model\BaseModel;
use think\model\Pivot;
/**
* BackendRolePower
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendRolePower extends Pivot
{
// 设置表名
protected $name = 'backend_role_power';
protected $pk = ['role_id', 'power_id'];
// 自动时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = false;
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'backend_role_power');
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\facade\Config;
use think\facade\Db;
use think\Model;
/**
* BaseModel
*
* 内聚了 SchemaGuard 数据库结构自愈引擎(纯通用能力,不含任何业务表清单)。
* 各模型在自己的 ensureSchema() 里调用本类静态方法确保「自己这张表」,
* 建表责任与触发点绑定到模型本身,核心库不再按业务域聚合自愈。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BaseModel extends Model
{
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
protected function getBaseOptions(): array
{
return [
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 将扁平的父子结构数组转换为带层级缩进的树形下拉数据
* @param array $cate 数据集(含 id/pid 字段)
* @param string $name 用于展示的字段名
* @param string $lefthtml 层级缩进符号
* @param int $pid 父级 ID
* @param int $level 当前层级
* @return array
*/
public static function cateTree($cate, $name = 'title', $lefthtml = '|— ', $pid = 0, $level = 0)
{
$arr = [];
foreach ($cate as $v) {
if (($v['pid'] ?? 0) == $pid) {
$v['level'] = $level;
$v[$name] = str_repeat($lefthtml, $level) . ($v[$name] ?? '');
$arr[] = $v;
$arr = array_merge($arr, self::cateTree($cate, $name, $lefthtml, $v['id'], $level + 1));
}
}
return $arr;
}
public function __construct(array $data = [])
{
parent::__construct($data);
$this->applyOptions();
}
/**
* 将子类 getOptions() 返回的模型配置落实到 think\Model 属性,
* 使 name/strict/schema/autoWriteTimestamp/readonly 等真正生效(之前是死代码)。
*/
protected function applyOptions(): void
{
if (!method_exists($this, 'getOptions')) {
return;
}
foreach ($this->getOptions() as $key => $value) {
if ($value === null) {
continue;
}
switch ($key) {
case 'name':
// think-orm 4.0 语义:$name 是不带前缀的表名,
// 框架会根据 database.prefix 自动拼接完整表名。
// 之前误写成 $this->table(含前缀语义),导致不拼前缀、
// 模型查询裸名表而自愈建的是带前缀表,引发 1146。
$this->name = $value;
$this->table = null;
break;
case 'strict':
$this->strict = (bool)$value;
break;
case 'schema':
$this->schema = $value;
break;
case 'autoWriteTimestamp':
$this->autoWriteTimestamp = $value;
break;
case 'createTime':
$this->createTime = $value;
break;
case 'updateTime':
$this->updateTime = $value;
break;
case 'readonly':
$this->readonly = $value;
break;
case 'hidden':
$this->hidden = $value;
break;
case 'append':
$this->append = $value;
break;
case 'dateFormat':
$this->dateFormat = $value;
break;
case 'deleteTime':
$this->deleteTime = $value;
break;
}
}
}
protected $tenantField = 'tenant_id';
// 自动添加 tenant_id 到查询和保存
public static function onAfterRead($model)
{
$user = request()->auth ?? null;
if ($user && $model->hasField('tenant_id')) {
if ($model->tenant_id != $user['tenant_id']) {
abort(403, '无权访问此数据');
}
}
}
public static function onBeforeWrite($model)
{
$user = request()->auth ?? null;
if ($user && $model->hasField('tenant_id') && ! $model->tenant_id) {
$model->tenant_id = $user['tenant_id'];
}
}
/* ---------------------------------------------------------------------
* 数据库结构自愈引擎(原 SchemaGuard,已内聚到 BaseModel
* 唯一事实源是 public/install/install.sql;模型通过 ensureTableFromInstall()
* 从中提取 DDL 建表,避免 DDL 漂移。
* ------------------------------------------------------------------- */
/**
* 表是否存在(已带前缀的完整表名)
*/
public static function tableExists(string $table): bool
{
try {
return !empty(Db::query("SHOW TABLES LIKE '{$table}'"));
} catch (\Throwable $e) {
return false;
}
}
/**
* 表不存在则创建(已带前缀的完整表名 + 完整 CREATE SQL
*/
public static function ensureTable(string $table, string $sql): void
{
try {
if (!self::tableExists($table)) {
Db::execute($sql);
}
} catch (\Throwable $e) {
// 忽略(如权限不足),由后续业务报错暴露
}
}
/**
* 确保 id 列为自增主键(老库修复:id 定义成 NOT NULL 但无 PRIMARY KEY/AUTO_INCREMENT
* 时,模型 create() 不带 id 会报 1364 Field 'id' doesn't have a default value)。
* @param string $table 已带前缀的完整表名
* @param string $column 主键列名,默认 id
*/
public static function ensureAutoIncrementPk(string $table, string $column = 'id'): void
{
try {
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if (empty($cols)) {
return;
}
$col = $cols[0];
$extra = strtolower((string)($col['Extra'] ?? ''));
$key = strtoupper((string)($col['Key'] ?? ''));
if (strpos($extra, 'auto_increment') !== false) {
return; // 已是自增
}
$type = (string)($col['Type'] ?? 'int unsigned');
if ($key !== 'PRI') {
// 无主键:一并加主键 + 自增
Db::execute("ALTER TABLE `{$table}` MODIFY `{$column}` {$type} NOT NULL AUTO_INCREMENT, ADD PRIMARY KEY (`{$column}`)");
} else {
Db::execute("ALTER TABLE `{$table}` MODIFY `{$column}` {$type} NOT NULL AUTO_INCREMENT");
}
} catch (\Throwable $e) {
// 忽略(如权限不足),由后续业务报错暴露
}
}
/**
* 列不存在则追加(MySQL 不支持 ADD COLUMN IF NOT EXISTS,故先探测)
* @param string $table 已带前缀的完整表名
*/
public static function ensureColumn(string $table, string $column, string $def): void
{
try {
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if (empty($cols)) {
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$def}");
}
} catch (\Throwable $e) {
// 自愈失败不应静默吞掉,记录日志便于排查(如 ALTER 权限不足)
try {
\think\facade\Log::error("[BaseModel] ensureColumn failed: {$table}.{$column} - " . $e->getMessage());
} catch (\Throwable $e2) {
// 日志也失败则彻底忽略
}
}
}
/**
* 取得运行时表前缀(CLI / 多应用路由下 Db::getConfig 可能为空,需兜底到配置)。
* 模型壳调用 ensureTableFromInstall() 时统一使用本方法取前缀,避免修错无前缀表。
*/
public static function currentPrefix(): string
{
$prefix = Config::get('database.connections.mysql.prefix', '');
if ($prefix === '') {
$prefix = Db::getConfig('prefix') ?: '';
}
return $prefix;
}
/**
* install.sql 提取指定表的 CREATE TABLE 语句并执行建表(单一事实源)。
* @param string $prefix 运行时表前缀
* @param string $table 不含前缀的表名(如 backend / member_profile
*/
public static function ensureTableFromInstall(string $prefix, string $table): void
{
$p = $prefix;
$sqlFile = root_path() . 'public/install/install.sql';
if (!is_file($sqlFile)) {
return;
}
$content = file_get_contents($sqlFile);
$pattern = '/CREATE TABLE IF NOT EXISTS `__PREFIX__' . preg_quote($table, '/') . '`\s*\(.*?\)\s*ENGINE=[^;]*;/s';
if (!preg_match($pattern, $content, $m)) {
return;
}
$ddl = str_replace('__PREFIX__', $p, $m[0]);
self::ensureTable($p . $table, $ddl);
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace ywxapp\model;
use ywxapp\model\BaseModel;
use think\facade\Db;
use think\model\concern\SoftDelete;
/**
* 充值卡密
*/
class Card extends BaseModel
{
use SoftDelete;
protected $name = 'card';
protected $deleteTime = 'delete_at';
protected $defaultSoftDelete = 0;
// 时间戳自动写入
protected $autoWriteTimestamp = true;
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
// 卡密状态
const STATUS_UNSOLD = 0; // 未售
const STATUS_SOLD = 1; // 已售
const STATUS_USED = 2; // 已用(已兑换)
/**
* 运行时自愈:确保 card 主表存在(install.sql 为事实源)。
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'card');
}
/**
* 初始化:自愈建表,避免远程库缺失 wxapp_card 导致 1146
*/
protected function initialize()
{
parent::initialize();
self::ensureSchema();
}
/**
* 兑换卡密(会员用卡号+密码充值余额)
*
* @param int $uid 会员ID
* @param string $cardno 卡号
* @param string $password 密码
* @return array ['success'=>bool,'msg'=>string,'data'=>array]
*/
public static function redeem(int $uid, string $cardno, string $password): array
{
if ($uid <= 0 || $cardno === '' || $password === '') {
return ['success' => false, 'msg' => '参数不完整'];
}
$card = self::where('cardno', $cardno)->find();
if (empty($card)) {
return ['success' => false, 'msg' => '卡密不存在'];
}
if ($card->delete_at > 0) {
return ['success' => false, 'msg' => '卡密已失效'];
}
if ((int)$card->status === self::STATUS_USED) {
return ['success' => false, 'msg' => '该卡密已被使用'];
}
// 密码校验(存储若为明文,按需改为 password_verify
if ((string)$card->password !== (string)$password) {
return ['success' => false, 'msg' => '卡号或密码错误'];
}
$amount = (float)$card->amount;
if ($amount <= 0) {
return ['success' => false, 'msg' => '卡密面值异常'];
}
Db::startTrans();
try {
// 1) 卡密标记已用,绑定会员
$card->status = self::STATUS_USED;
$card->use_time = time();
$card->uid = $uid;
$card->save();
// 2) 会员钱包加余额 + 累计充值(无钱包则自动创建)
$wallet = Db::name('member_wallets')->where('uid', $uid)->find();
if (empty($wallet)) {
Db::name('member_wallets')->insert([
'uid' => $uid,
'balance' => $amount,
'total_recharge' => $amount,
'create_at' => time(),
'update_at' => time(),
]);
} else {
Db::name('member_wallets')
->where('uid', $uid)
->inc('balance', $amount)
->inc('total_recharge', $amount)
->update(['update_at' => time()]);
}
// 3) 充值流水
Db::name('member_bill')->insert([
'uid' => $uid,
'type' => 1, // 充值
'amount' => $amount,
'currency' => 1, // 人民币
'channel' => 'card',
'order_no' => 'CARD' . date('YmdHis') . $uid . mt_rand(100, 999),
'status' => 1, // 成功
'description' => '卡密充值:' . $cardno,
'create_at' => time(),
'update_at' => time(),
]);
Db::commit();
return [
'success' => true,
'msg' => '兑换成功,已充值 ¥' . number_format($amount, 2),
'data' => ['amount' => $amount],
];
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
/**
* 批量生成卡密
*
* @param int $count 生成数量
* @param float $amount 面值
* @param string $prefix 卡号前缀(如 YX
* @return array 生成的卡号列表
*/
public static function generateBatch(int $count, float $amount, string $prefix = ''): array
{
$count = max(1, min(200, $count)); // 单次上限保护
$batchNo = date('YmdHis') . mt_rand(1000, 9999);
$list = [];
$rows = [];
for ($i = 0; $i < $count; $i++) {
$cardno = ($prefix ?: 'YX') . strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 16));
$password = strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 8));
$list[] = ['cardno' => $cardno, 'password' => $password];
$rows[] = [
'cardno' => $cardno,
'password' => $password,
'amount' => $amount,
'status' => self::STATUS_UNSOLD,
'batch_no' => $batchNo,
'create_at' => time(),
'update_at' => time(),
];
}
self::insertAll($rows);
return $list;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
/**
* 聊天
*/
class Chat extends BaseModel
{
// 设置表名(think-orm 4.0name 不带前缀,自动拼 wxapp_
protected $name = 'chat';
protected $schema = [
'id' => 'int',
'from_uid' => 'int',
'to_uid' => 'int',
'type' => 'int',
'content' => 'string',
'create_at' => 'int',
'update_at' => 'int',
];
}
// CREATE TABLE `chat_users` (
// `id` int(11) NOT NULL AUTO_INCREMENT,
// `username` varchar(50) NOT NULL,
// `password` varchar(255) NOT NULL,
// `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
// PRIMARY KEY (`id`)
// );
// CREATE TABLE `chat_messages` (
// `id` int(11) NOT NULL AUTO_INCREMENT,
// `from_user_id` int(11) NOT NULL,
// `to_user_id` int(11) NOT NULL,
// `message` text NOT NULL,
// `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
// PRIMARY KEY (`id`)
// );
+18
View File
@@ -0,0 +1,18 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
/**
* 聊天消息
*/
class ChatMessage extends \ywxapp\BaseController
{
}
+47
View File
@@ -0,0 +1,47 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
/**
* Configure
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Configure extends BaseModel
{
public function getTitleAttr($value)
{
return lang($value);
}
public function getGroupAttr($value)
{
return lang($value);
}
/**
* 运行时自愈:确保 configure 主表存在(install.sql 为事实源)。
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'configure');
}
/**
* 初始化:自愈建表,避免远程库缺失 wxapp_configure 导致 1146
*/
protected function initialize()
{
parent::initialize();
self::ensureSchema();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
/**
* Email
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Email extends BaseModel{
// 设置表名(think-orm 4.0name 不带前缀,自动拼 wxapp_
protected $name = 'email';
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ywxapp\model;
use ywxapp\model\BaseModel;
use think\model\concern\SoftDelete;
/**
* 站点帮助
*/
class Help extends BaseModel
{
use SoftDelete;
protected $name = 'help';
protected $deleteTime = 'delete_at';
protected $defaultSoftDelete = 0;
// 时间戳自动写入
protected $autoWriteTimestamp = true;
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
/**
* 运行时自愈:确保 help 主表存在(install.sql 已含 category/view_count 等列,为事实源)。
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'help');
}
/**
* 模型初始化时自愈 help 表结构(category / view_count 等扩展列由 install.sql 统一提供)。
* 集中在此处,前后台读写共用同一模型,避免各自漏调自愈导致 1054 缺列。
*/
protected function initialize()
{
parent::initialize();
self::ensureSchema();
}
}
+64
View File
@@ -0,0 +1,64 @@
<?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\model;
use think\model\concern\SoftDelete;
use ywxapp\model\BaseModel;
class Links extends BaseModel
{
use SoftDelete;
// 软删除时间字段(与 autoWriteTimestamp=int 保持一致,类型为 int
protected $deleteTime = 'delete_at';
/**
* 模型配置
* @return array
*/
protected function getOptions(): array
{
return [
'strict' => true,
'name' => 'links',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
];
}
/**
* 运行时自愈:确保 links 主表存在(install.sql 为事实源)。
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'links');
}
/**
* 初始化:自愈建表,避免远程库缺失 wxapp_links 导致 1146
*/
protected function initialize()
{
parent::initialize();
self::ensureSchema();
}
/**
* 获取器:状态文本
*/
public function getStatusTextAttr($value, $data)
{
$status = [0 => '禁用', 1 => '启用'];
return $status[$data['status']] ?? '未知';
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace ywxapp\model;
use ywxapp\model\BaseModel;
use think\facade\Db;
use think\model\concern\SoftDelete;
/**
* 勋章中心
*/
class Medal extends BaseModel
{
use SoftDelete;
protected $name = 'medal';
protected $deleteTime = 'delete_at';
protected $defaultSoftDelete = 0;
// 时间戳自动写入
protected $autoWriteTimestamp = true;
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
/**
* 控制器初始化(模型实例方法,非静态)
*/
protected function initialize()
{
parent::initialize();
self::ensureSchema();
}
/**
* 授予勋章(幂等:同一用户同一勋章仅记录一次)
* @return array [bool $ok, string $msg]
*/
public static function grant(int $uid, int $medalId): array
{
self::ensureSchema(); // 确保 user_medal 关联表已就绪(静态入口也可能在未实例化模型时被调用)
if ($uid <= 0) {
return [false, '用户未登录'];
}
$medal = self::where('id', $medalId)->where('status', 1)->find();
if (!$medal) {
return [false, '勋章不存在或未启用'];
}
$exists = Db::name('member_medal')
->where('uid', $uid)
->where('medal_id', $medalId)
->find();
if ($exists) {
return [true, '已拥有该勋章'];
}
Db::name('member_medal')->insert([
'uid' => $uid,
'medal_id' => $medalId,
'create_at' => time(),
]);
return [true, '恭喜获得勋章:' . $medal->title];
}
/**
* 取某用户拥有的勋章列表(含勋章信息)
*/
public static function getUserMedals(int $uid): array
{
self::ensureSchema(); // 确保 user_medal 关联表已就绪(静态入口也可能在未实例化模型时被调用)
if ($uid <= 0) {
return [];
}
return Db::name('member_medal')
->alias('um')
->join('medal m', 'm.id = um.medal_id')
->where('um.uid', $uid)
->where('m.delete_at', 0)
->field('m.id,m.title,m.image,m.description,um.create_at')
->order('um.create_at', 'desc')
->select()
->toArray();
}
/**
* 表自愈:确保勋章相关表(medal / member_medal)已就绪。
*/
public static function ensureSchema(): void
{
$prefix = BaseModel::currentPrefix();
BaseModel::ensureTableFromInstall($prefix, 'medal');
BaseModel::ensureTableFromInstall($prefix, 'member_medal');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\concern\SoftDelete;
use ywxapp\model\BaseModel;
/**
* MemberGroup
*
* @author ywxapp <admin@ywxapp.cn>
*/
class MemberGroup extends BaseModel
{
use SoftDelete;
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'member_group',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
'deleteTime' => 'delete_at',
'defaultSoftDelete' => 0,
// 'dateFormat' => 'Y-m-d H:i:s',
'append' => [],
'hidden' => ['create_at', 'update_at', 'delete_at'],
'readonly' => ['id'],
];
}
// 角色拥有的用户
public function users()
{
return $this->belongsToMany(MemberUser::class, MemberGroupAccess::class, 'uid', 'gid');
}
// 角色拥有的权限
public function rules()
{
return $this->belongsToMany(MemberRule::class, GroupRule::class, 'rid', 'gid');
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'member_group');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\Pivot;
use ywxapp\model\BaseModel;
/**
* MemberGroupAccess
*
* @author ywxapp <admin@ywxapp.cn>
*/
class MemberGroupAccess extends Pivot
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'member_group_access',
'autoWriteTimestamp' => 'int', // create_at 已统一为 int 时间戳
'createTime' => 'create_at',
'updateTime' => false,
// 'deleteTime' => 'delete_at',
// 'defaultSoftDelete' => 0,
// 'dateFormat' => 'Y-m-d H:i:s',
'append' => ['is_parent'],
'hidden' => ['password', 'create_at', 'update_at', 'delete_at'],
'readonly' => ['id'],
];
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'member_group_access');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?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\model;
use think\Model;
use ywxapp\model\BaseModel;
class MemberGroupRule extends Model
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'member_group_rule',
'autoWriteTimestamp' => 'int', // create_at 已统一为 int 时间戳
'createTime' => 'create_at',
'updateTime' => false,
// 'deleteTime' => 'delete_at',
// 'defaultSoftDelete' => 0,
// 'dateFormat' => 'Y-m-d H:i:s',
'append' => ['is_parent'],
'hidden' => ['password', 'create_at', 'update_at', 'delete_at'],
'readonly' => ['id'],
];
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'member_group_rule');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use ywxapp\model\BaseModel;
/**
* 用户日志
*/
class MemberLog extends BaseModel
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'member_log',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => false,
// 'deleteTime' => 'delete_at',
// 'defaultSoftDelete' => 0,
// 'dateFormat' => 'Y-m-d H:i:s',
'append' => ['is_parent'],
'hidden' => ['password', 'create_at', 'update_at', 'delete_at'],
'readonly' => ['id'],
];
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146
*/
public static function ensureSchema(): void
{
BaseModel::ensureTableFromInstall(BaseModel::currentPrefix(), 'member_log');
}
}

Some files were not shown because too many files have changed in this diff Show More