CRMEB/backend/crmeb/module/BaseModuleInstaller.php
吴昊天 997659de67 feat(db): 适配 MySQL 5.7 安装与建表
- 安装 SQL 及安装器统一使用 utf8mb4_general_ci,移除 MySQL 8 专属 0900 排序规则
- JSON 字段移除表达式默认值,改为 DEFAULT NULL,兼容 MySQL 5.7
- 124 张表统一显式 ROW_FORMAT=DYNAMIC,避免依赖 innodb_default_row_format
- 安装程序最低版本调整为 MySQL 5.7,连接字符集改为 utf8mb4
- 同步 README、帮助文档、数据库规范与模块 Installer
2026-10-23 14:16:21 +08:00

1835 lines
68 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace crmeb\module;
use crmeb\interfaces\ModuleInstallerInterface;
use crmeb\plugin\PluginManifest;
use crmeb\services\CacheService;
use think\facade\Db;
/**
* 模块安装器基类
* 封装建表、菜单、种子的通用逻辑,子类只需实现抽象方法
*
* 本类位于 crmeb 核心层,是模块生命周期基础设施(DDL/DML/菜单管理),
* 供 modules/ 与 driver/ 各 Installer 继承使用,符合三层架构依赖铁律
* (driver 层不可调用 app 层,故基类必须下沉到 crmeb 层)。
*
* Db::table/Db::query/Db::execute 用于建表、菜单管理等 DDL/DML 操作,
* 属于 Installer 生命周期范畴,保留直接调用。
*/
abstract class BaseModuleInstaller implements ModuleInstallerInterface
{
/**
* 核心共享表黑名单(cleanTable 拒绝清理)
*
* 这些表为全站共享数据(菜单/配置/权限/用户/中台商品与订单/页面链接/模块基础设施),
* 不归属任何单一模块。子类 Installer 传这些表名一律拒绝,
* 防止二开插件误传表名或恶意清空核心数据。
* 模块自建表(如 file_manage 的 system_file / system_file_info)不在名单内。
*/
protected const CORE_SHARED_TABLES = [
// 菜单 / 配置 / 权限
'system_menus', 'system_config', 'system_config_tab', 'system_admin', 'system_role',
// 用户
'user', 'user_bill', 'user_group',
// 中台商品 / 订单
'product_center', 'order_center', 'order_item', 'store_cart',
// DIY 页面链接
'page_categroy', 'page_link',
// 模块基础设施
'module_install_log', 'system_file_md5',
// 插件间共享表准入登记表(协议基础设施,插件禁碰)
'shared_table_registry',
];
/**
* 插件间共享表准入目录(白名单)
*
* 与 CORE_SHARED_TABLES(app 层独占、插件禁碰)不同,目录内的表允许多个插件
* 共用:各插件通过 sharedTables() 声明准入,安装/启用时登记到
* eb_shared_table_registry,卸载时释放;DROP 必须走 dropSharedTableIfUnclaimed()
* 仲裁,仅当无任何插件占用时才允许删除。
*
* 多插件共用的表必须先入目录才允许声明共享;目录外表一律视为插件自建表,
* 由建表插件全权负责。键为表名(不含前缀),值为说明。
*/
protected const SHARED_TABLE_CATALOG = [
// 活动通用表:type 区分活动类型(1=秒杀),由秒杀插件负责建表
'store_activity' => '活动通用表(type 区分活动类型,多活动插件共用)',
];
protected ?string $ownerPluginId = null;
/** @var array<string, bool> */
protected array $ownershipColumnCache = [];
/** @var bool|null */
protected ?bool $menuKeyColumnSupported = null;
/**
* 数据库表前缀
* @var string
*/
protected string $prefix;
/**
* 字符集
* @var string
*/
protected string $charset;
/**
* 表排序规则
* @var string
*/
protected string $collation;
public function __construct()
{
$this->prefix = (string) config('database.connections.mysql.prefix', 'eb_');
$this->charset = (string) config('database.connections.mysql.charset', 'utf8mb4');
$this->collation = $this->charset === 'utf8mb4' ? 'utf8mb4_general_ci' : 'utf8_general_ci';
}
// ============== 安装流程 ==============
/**
* 安装
*
* 注意:runMigrate() 包含 CREATE TABLE 等 DDL 语句,MySQL 会在执行 DDL 时
* 隐式提交当前事务。因此本方法将 DDL 放在事务外执行,业务数据操作(菜单、
* 种子、安装日志)放在事务内执行,避免 commit/rollback 异常。
*
* @return array
*/
public function install(): array
{
if ($this->isInstalled()) {
return ['status' => 'skipped', 'message' => '模块已安装,跳过'];
}
$steps = [];
// 1. 前置检查
$check = $this->checkRequirements();
if (!empty($check['errors'])) {
throw new \RuntimeException('前置检查失败: ' . implode('; ', $check['errors']));
}
// 2. 建表(DDL,必须在事务外执行)
$this->runMigrate();
$this->ensureSharedOwnershipColumns();
$steps[] = '数据库表创建完成';
// 3. 插入菜单、model_checkbox 配置、种子、安装日志等业务数据(在事务内执行)
$ownershipWatermarks = $this->captureSharedResourceWatermarks();
Db::startTrans();
try {
$menuCount = $this->insertMenus();
$steps[] = "插入 {$menuCount} 个后台菜单";
// 将模块加入 model_checkbox 显示配置,确保后台菜单不会被过滤掉
$this->addToModelCheckbox();
$seedCount = $this->seed();
if ($seedCount > 0) {
$steps[] = "写入 {$seedCount} 条初始数据";
}
// 共享表准入登记(uk_table_plugin 幂等,失败随事务回滚)
$this->claimSharedTables();
$this->claimSharedResources($ownershipWatermarks);
if ($this->installDriverRecord()) {
$steps[] = '写入驱动字典';
}
$this->logInstall();
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return ['status' => 'success', 'steps' => $steps];
}
// ============== 卸载流程 ==============
/**
* 卸载
*
* 即使插件已被标记为未安装(status=0),也会执行菜单清理,
* 防止旧版遗留菜单未被删除的情况。
*
* 注意:rollbackMigrate() 包含 DROP TABLE 等 DDL 语句,MySQL 会在执行 DDL 时
* 隐式提交当前事务。因此本方法将 DDL 放在事务外执行,业务数据操作(清理数据、
* 移除菜单、卸载日志)放在事务内执行,避免 commit/rollback 异常。
*
* @return array
*/
public function uninstall(): array
{
// 旧版本数据库可能没有公共资源归属字段,必须在事务外先补齐。
$this->ensureSharedOwnershipColumns();
$isInstalled = $this->isInstalled();
if (!$isInstalled) {
$menuCount = $this->removeMenus();
$this->removeFromModelCheckbox();
if ($menuCount > 0) {
return ['status' => 'success', 'steps' => ["清理 {$menuCount} 个残留菜单"]];
}
return ['status' => 'skipped', 'message' => '模块未安装,跳过'];
}
$steps = [];
// 1. 清理业务数据、移除菜单、记录卸载日志(在事务内执行)
Db::startTrans();
try {
$cleanCount = $this->cleanData();
if ($cleanCount > 0) {
$steps[] = "清理 {$cleanCount} 条业务数据";
}
$menuCount = $this->removeMenus();
$steps[] = "移除 {$menuCount} 个后台菜单";
// 释放共享表准入登记(先于 rollbackMigrate 仲裁,失败随事务回滚)
$this->releaseSharedTables();
$ownedCount = $this->removeOwnedSharedResources();
if ($ownedCount > 0) {
$steps[] = "清理 {$ownedCount} 条插件所属公共资源";
}
if ($this->removeDriverRecord()) {
$steps[] = '删除驱动字典记录';
}
$this->logUninstall();
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
// 2. 从 model_checkbox 显示配置中移除该模块(事务外)
$this->removeFromModelCheckbox();
// 3. 删除表(DDL,必须在事务外执行)
$this->rollbackMigrate();
$steps[] = '数据库表删除完成';
// 4. 备份插件文件到 backup/ 目录(延迟执行,避免自移异常)
// $this->backupPluginFiles();
// $steps[] = '插件文件已备份';
return ['status' => 'success', 'steps' => $steps];
}
/**
* 是否已安装
* @return bool
*/
public function isInstalled(): bool
{
return Db::table($this->prefix . 'module_install_log')
->where('module_key', $this->getKey())
->where('status', 1)
->count() > 0;
}
/**
* 安装前置检查
* @return array
*/
public function checkRequirements(): array
{
return ['errors' => []];
}
// ============== 子类实现的抽象方法 ==============
/**
* 建表
* @return void
*/
abstract protected function runMigrate(): void;
/**
* 回滚建表
* @return void
*/
abstract protected function rollbackMigrate(): void;
/**
* 获取菜单配置
* @return array
*/
abstract protected function getMenuConfig(): array;
// ============== 子类可选重写 ==============
/**
* 获取本插件的消息通知声明;无消息时保持空数组。
* @return array
*/
protected function getNoticeDefinitions(): array
{
return [];
}
/**
* [TEMPORARY] 导出通知种子定义(归属标识 + getNoticeDefinitions 结果)。
*
* TEMPORARY-PLUGIN-NOTICE-REFRESH:临时功能——供插件列表"刷新消息模板"按钮经
* app 层 ModuleInstaller::refreshNotices() 调用,以代码定义覆盖式更新消息模板,
* 后续随该按钮一并移除。仅暴露受保护成员,不含任何业务逻辑。
*
* @return array{owner: string, definitions: array}
*/
public function exportNoticeSeed(): array
{
return [
'owner' => $this->getOwnerPluginId(),
'definitions' => $this->getNoticeDefinitions(),
];
}
/**
* 获取本插件的通知渠道声明;无渠道时保持空数组。
* @return array
*/
protected function getNoticeChannelDefinitions(): array
{
return [];
}
/**
* 种子数据
* @return int
*/
protected function seed(): int
{
return 0;
}
/**
* 清理业务数据
* @return int
*/
protected function cleanData(): int
{
return 0;
}
/**
* 启用时回调
* 默认实现:重新插入菜单、将模块加入 model_checkbox 显示配置,
* 并幂等补跑 seed()——插件升级新增种子数据(如 DIY 页面链接)时,
* 无需重装,执行一次禁用/启用即可落库
* 子类可重写以部署驱动文件等,需调用 parent::onEnable()
* @return void
*/
public function onEnable(): void
{
// 旧站点可能直接升级代码后执行启用,先在事务外补齐菜单归属与稳定标识。
$this->ensureSharedOwnershipColumns();
$this->insertMenus();
$this->addToModelCheckbox();
$this->seed();
// 存量站点禁用→启用一次即可补登共享表准入(幂等)
$this->claimSharedTables();
}
/**
* 禁用时回调
* 默认实现:隐藏菜单(is_show=0, is_show_path=0),保留数据库记录;
* 并将模块从 model_checkbox 显示配置中移除
* 子类可重写以移除驱动文件等,需调用 parent::onDisable()
* @return void
*/
public function onDisable(): void
{
$this->ensureSharedOwnershipColumns();
$this->hideMenus();
$this->removeFromModelCheckbox();
}
// ============== 插件间共享表准入协议 ==============
/**
* 子类声明本插件使用的插件间共享表(准入声明)
*
* 返回 SHARED_TABLE_CATALOG 内的表名列表;声明后 install()/onEnable() 会自动
* 登记,uninstall() 自动释放。目录外表声明即抛异常,禁止私自把自建表包装成共享表。
* @return array 表名列表(不含前缀),默认空
*/
protected function sharedTables(): array
{
return [];
}
/**
* 登记共享表准入(install/onEnable 时调用,幂等)
*
* 校验声明的表必须在准入目录内,按 (table_name, owner_plugin_id) 唯一键
* ON DUPLICATE KEY UPDATE 幂等写入;存量站点可反复执行补登。
* @return void
*/
protected function claimSharedTables(): void
{
$tables = $this->sharedTables();
if (empty($tables)) {
return;
}
$this->ensureSharedTableRegistry();
$owner = $this->getOwnerPluginId();
$now = time();
foreach ($tables as $name) {
$name = (string) $name;
// 准入校验:目录外表拒绝登记
if (!isset(self::SHARED_TABLE_CATALOG[$name])) {
throw new \RuntimeException(
"sharedTables() 声明的表 {$name} 不在共享表准入目录(BaseModuleInstaller::SHARED_TABLE_CATALOG),禁止共享"
);
}
Db::table($this->table('shared_table_registry'))->duplicate([
'add_time' => $now,
])->insert([
'table_name' => $name,
'owner_plugin_id' => $owner,
'add_time' => $now,
]);
}
}
/**
* 释放本插件全部共享表准入登记(uninstall 时调用)
* @return int 删除登记条数
*/
protected function releaseSharedTables(): int
{
if (!$this->hasTable('shared_table_registry')) {
return 0;
}
return (int) Db::table($this->table('shared_table_registry'))
->where('owner_plugin_id', $this->getOwnerPluginId())
->delete();
}
/**
* 共享表仲裁式删除(rollbackMigrate 中替代裸 DROP)
*
* 仲裁规则:先释放本插件登记,登记表中仍有其他插件占用该表则拒绝删除;
* 无任何占用者时才执行 DROP。目录外表禁止通过本方法删除。
* @param string $name 共享表名(不含前缀)
* @return bool 是否实际执行了 DROP
*/
protected function dropSharedTableIfUnclaimed(string $name): bool
{
// 仅准入目录内的表允许仲裁删除
if (!isset(self::SHARED_TABLE_CATALOG[$name])) {
throw new \RuntimeException(
"dropSharedTableIfUnclaimed() 仅允许仲裁共享表准入目录内的表:{$name} 不在目录中"
);
}
// 幂等:表不存在(旧版已删或从未建表)直接跳过
if (!$this->hasTable($name)) {
return false;
}
// 释放本插件登记后,仍有其他插件占用则保守保留共享表
$this->releaseSharedTables();
if ($this->hasTable('shared_table_registry')
&& Db::table($this->table('shared_table_registry'))
->where('table_name', $name)
->count() > 0) {
return false;
}
$this->exec("DROP TABLE IF EXISTS `{$this->table($name)}`;");
return true;
}
/**
* 幂等创建共享表准入登记表(存量站点兜底,DDL 必须在事务外执行)
* 新安装库由 crmeb_table.sql 创建;旧库升级时在此补齐。
* @return void
*/
protected function ensureSharedTableRegistry(): void
{
if ($this->hasTable('shared_table_registry')) {
return;
}
$this->exec("CREATE TABLE IF NOT EXISTS `{$this->table('shared_table_registry')}` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`table_name` varchar(64) NOT NULL DEFAULT '' COMMENT '共享表名(不含前缀)',
`owner_plugin_id` varchar(96) NOT NULL DEFAULT '' COMMENT '占用插件复合标识,如 module:trade:seckill',
`add_time` int NOT NULL DEFAULT 0 COMMENT '登记时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_table_plugin` (`table_name`,`owner_plugin_id`),
KEY `idx_table_name` (`table_name`)
) ENGINE=InnoDB DEFAULT CHARSET={$this->charset} COLLATE={$this->collation} COMMENT='插件间共享表准入登记表';");
}
// ============== 通用工具方法 ==============
/**
* 获取完整表名
* @param string $name
* @return string
*/
protected function table(string $name): string
{
return $this->prefix . $name;
}
/**
* 判断表是否存在
* @param string $name
* @return bool
*/
protected function hasTable(string $name): bool
{
$database = config('database.connections.mysql.database');
$sql = "SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ? LIMIT 1";
$result = Db::query($sql, [$database, $this->table($name)]);
return !empty($result);
}
/**
* 执行建表 SQL
* @param string $sql
* @return void
*/
protected function exec(string $sql): void
{
// 显式设置客户端连接字符集,防止 COMMENT 中的中文因连接字符集不匹配而乱码
Db::execute("SET NAMES '{$this->charset}'");
Db::execute($sql);
}
/**
* 插入或更新插件菜单
*
* 新库按 menu_key 幂等写入;旧库或历史无归属菜单先按旧菜单身份匹配,
* 命中后认领为本插件资源并补齐 menu_key。已存在菜单会同步声明属性,
* 因此插件升级修改路由、父级、排序后无需先卸载。
*
* @return int 实际新增或更新的菜单数
*/
protected function insertMenus(): int
{
$menus = array_map(fn (array $menu): array => $this->normalizeMenu($menu), $this->getMenuConfig());
$count = 0;
$nameToId = [];
$menuKeys = [];
// 同一插件内菜单稳定标识必须唯一,冲突时在写库前直接失败。
foreach ($menus as $menu) {
$menuKey = $menu['menu_key'];
if (isset($menuKeys[$menuKey])) {
throw new \RuntimeException(
"插件 {$this->getOwnerPluginId()} 存在重复菜单标识 {$menuKey}:"
. "{$menuKeys[$menuKey]} / {$menu['menu_name']}"
);
}
$menuKeys[$menuKey] = $menu['menu_name'];
}
foreach ($menus as $menu) {
$menu['pid'] = $this->resolveMenuParentId($menu, $nameToId);
$menuName = $menu['menu_name'];
// 新数据按稳定键查找;存量数据再按旧身份兼容认领。
$existing = (clone $this->menuQuery($menu))->find();
if (!$existing && $this->supportsMenuKeyColumn()) {
$legacyQuery = clone $this->legacyMenuQuery($menu);
if ($this->supportsOwnershipColumn('system_menus')) {
$legacyQuery->whereIn('owner_plugin_id', ['', $this->getOwnerPluginId()]);
}
$existing = $legacyQuery->find();
}
if ($existing) {
$updates = $this->getMenuUpdates($existing, $menu);
if (!empty($updates)) {
Db::table($this->prefix . 'system_menus')
->where('id', $existing['id'])
->update($updates);
$count++;
}
$nameToId[$menuName] = (int) $existing['id'];
continue;
}
if ($this->supportsOwnershipColumn('system_menus')) {
$menu['owner_plugin_id'] = $this->getOwnerPluginId();
}
// 只保留表中存在的字段,避免 insert 报错
static $allowedFields = [
'pid','icon','menu_name','module','controller','action',
'api_url','methods','params','sort','is_show','is_show_path',
'access','menu_path','path','auth_type','header','is_header',
'unique_auth','menu_key','is_del','mark','owner_plugin_id',
];
if (!$this->supportsMenuKeyColumn()) {
unset($menu['menu_key']);
}
unset($menu['key'], $menu['parent_key']);
$menu = array_intersect_key($menu, array_fill_keys($allowedFields, true));
$id = (int) Db::table($this->prefix . 'system_menus')->insertGetId($menu);
$nameToId[$menuName] = $id;
$count++;
}
return $count;
}
/**
* 补齐菜单默认值并生成本插件不可变的稳定标识。
*
* 可通过 key 显式声明可读的插件内局部标识;未声明时根据权限类型、
* 权限码、接口、控制器动作和声明名称生成稳定哈希。menu_path 不参与哈希,
* 避免动态配置分类 ID 回填后菜单身份发生变化。
*
* @param array $menu 菜单声明
* @return array
*/
protected function normalizeMenu(array $menu): array
{
$menu = array_merge([
'pid' => 0,
'menu_name' => '',
'controller' => '',
'action' => '',
'module' => 'admin',
'api_url' => '',
'methods' => '',
'params' => '[]',
'menu_path' => '',
'path' => '',
'unique_auth' => '',
'header' => '',
'is_header' => 0,
'mark' => '',
'icon' => '',
'sort' => 0,
'is_show' => 1,
'is_show_path' => 1,
'access' => 1,
'auth_type' => 1,
'is_del' => 0,
], $menu);
if ($menu['menu_name'] === '') {
throw new \RuntimeException("插件 {$this->getOwnerPluginId()} 的菜单声明缺少 menu_name");
}
if (is_array($menu['params'])) {
$menu['params'] = json_encode($menu['params'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
}
$localKey = trim((string)($menu['key'] ?? ''));
if ($localKey !== '') {
if (!preg_match('/^[a-z0-9][a-z0-9._-]{0,63}$/', $localKey)) {
throw new \RuntimeException("插件菜单 key 非法:{$localKey},仅允许小写字母、数字、点、下划线和短横线");
}
$menu['menu_key'] = $this->getOwnerPluginId() . ':menu:' . $localKey;
} else {
$identity = [
(string)(int)$menu['auth_type'],
(string)$menu['unique_auth'],
(string)$menu['api_url'],
strtoupper((string)$menu['methods']),
(string)$menu['controller'],
(string)$menu['action'],
(string)$menu['menu_name'],
];
$menu['menu_key'] = $this->getOwnerPluginId() . ':menu:' . sha1(implode("\x1F", $identity));
}
return $menu;
}
/**
* 解析菜单父级。
*
* parent_key 用于引用系统或其他已存在菜单的稳定标识;pid=@菜单名仅保留为
* 同批次插件菜单的兼容语法。父级不存在时必须中止安装,禁止静默挂到顶级。
*
* @param array $menu 菜单声明
* @param array<string, int> $nameToId 本批次已写入菜单映射
* @return int
*/
protected function resolveMenuParentId(array $menu, array $nameToId): int
{
$parentKey = trim((string)($menu['parent_key'] ?? ''));
if ($parentKey !== '') {
return $this->getMenuPidByKey($parentKey);
}
$pid = $menu['pid'] ?? 0;
if (is_string($pid) && str_starts_with($pid, '@')) {
$parentName = substr($pid, 1);
if (!isset($nameToId[$parentName])) {
throw new \RuntimeException(
"插件 {$this->getOwnerPluginId()} 菜单 {$menu['menu_name']} 引用的父菜单 @{$parentName} 尚未写入"
);
}
return $nameToId[$parentName];
}
return (int)$pid;
}
/**
* 计算已存在插件菜单需要同步的字段。
*
* @param array $existing 数据库菜单
* @param array $menu 标准化菜单声明
* @return array
*/
protected function getMenuUpdates(array $existing, array $menu): array
{
$fields = [
'pid', 'icon', 'menu_name', 'module', 'controller', 'action', 'api_url',
'methods', 'params', 'sort', 'is_show', 'is_show_path', 'access',
'menu_path', 'path', 'auth_type', 'header', 'is_header', 'unique_auth',
'is_del', 'mark',
];
if ($this->supportsMenuKeyColumn()) {
$fields[] = 'menu_key';
}
if ($this->supportsOwnershipColumn('system_menus')) {
$menu['owner_plugin_id'] = $this->getOwnerPluginId();
$fields[] = 'owner_plugin_id';
}
$updates = [];
foreach ($fields as $field) {
if (!array_key_exists($field, $existing) || (string)$existing[$field] !== (string)$menu[$field]) {
$updates[$field] = $menu[$field];
}
}
return $updates;
}
/**
* 根据菜单唯一性规则构建查询
*
* 菜单类型与唯一键对应关系(与核心权限校验 SystemRoleServices::verifyAuth 一致):
* - 菜单(auth_type=1):menu_name + menu_path
* - 接口权限(auth_type=2):menu_name + api_url + methods,verifyAuth 仅识别此类节点管控接口
* - 操作按钮(auth_type=3):menu_name + unique_auth,仅做展示层,不参与接口鉴权
*
* ⚠️ 模块菜单中带 api_url 的权限项必须声明 auth_type=2,否则接口不会被
* verifyAuth 纳入管控(未注册即放行)。
*
* @param array $menu 菜单配置项
* @return \think\db\Query
*/
protected function menuQuery(array $menu): \think\db\Query
{
$menu = $this->normalizeMenu($menu);
if ($this->supportsMenuKeyColumn()) {
return Db::table($this->prefix . 'system_menus')
->where('menu_key', $menu['menu_key']);
}
return $this->legacyMenuQuery($menu);
}
/**
* 按历史菜单字段构建兼容查询,仅用于认领/清理升级前的无归属菜单。
*
* 菜单类型语义以核心权限校验(SystemRoleServices::verifyAuth)为准:
* auth_type=1 菜单、auth_type=2 接口权限(api_url+methods,仅此类参与接口鉴权)、
* auth_type=3 操作按钮(unique_auth,仅展示层)。
*
* @param array $menu 菜单配置项
* @return \think\db\Query
*/
protected function legacyMenuQuery(array $menu): \think\db\Query
{
$query = Db::table($this->prefix . 'system_menus')
->where('menu_name', $menu['menu_name'] ?? '');
$authType = (int)($menu['auth_type'] ?? 1);
$menuPath = $menu['menu_path'] ?? '';
$uniqueAuth = $menu['unique_auth'] ?? '';
$apiUrl = $menu['api_url'] ?? '';
$methods = $menu['methods'] ?? '';
$query->where('auth_type', $authType);
if ($authType === 1 && !empty($menuPath)) {
// 菜单:按 menu_name + menu_path 唯一
$query->where('menu_path', $menuPath);
} elseif ($authType === 2 && !empty($apiUrl)) {
// 接口权限:按 menu_name + api_url + methods 唯一
$query->where('api_url', $apiUrl);
if ($methods !== '') {
$query->where('methods', $methods);
}
} elseif ($authType === 3 && !empty($uniqueAuth)) {
// 操作按钮:按 menu_name + unique_auth 唯一
$query->where('unique_auth', $uniqueAuth);
} elseif (!empty($uniqueAuth)) {
// 历史节点字段不完整时以权限标识收窄,禁止仅按中文名匹配。
$query->where('unique_auth', $uniqueAuth);
} elseif (!empty($apiUrl)) {
$query->where('api_url', $apiUrl);
if ($methods !== '') {
$query->where('methods', $methods);
}
} elseif (!empty($menu['controller']) || !empty($menu['action'])) {
$query->where('controller', $menu['controller'] ?? '')
->where('action', $menu['action'] ?? '');
}
return $query;
}
/**
* 删除菜单(物理删除,用于卸载时清除菜单记录)
*
* 新数据按 owner_plugin_id 清理;升级前的无归属菜单仅按完整历史身份兼容匹配,
* 不再使用单独 api_url 或 controller/action 的宽泛条件,避免误删核心或其他插件菜单。
* 收集全部匹配 ID 后一次性物理删除。
*
* @return int
*/
protected function removeMenus(): int
{
$menus = $this->getMenuConfig();
$deleteIds = [];
$supportsOwnership = $this->supportsOwnershipColumn('system_menus');
$owner = $this->getOwnerPluginId();
if ($supportsOwnership) {
// 新数据直接按插件归属清理,不依赖菜单名称或路由。
$deleteIds = Db::table($this->prefix . 'system_menus')
->where('owner_plugin_id', $owner)
->column('id');
}
foreach ($menus as $menu) {
if (!empty($menu['menu_name'])) {
$query = clone $this->legacyMenuQuery($menu);
if ($supportsOwnership) {
// 仅兼容升级前的无归属菜单,避免碰到其他插件拥有的同路由菜单。
$query->where('owner_plugin_id', '');
}
$ids = $query->column('id');
$deleteIds = array_merge($deleteIds, $ids);
}
}
$deleteIds = array_unique($deleteIds);
if (empty($deleteIds)) {
return 0;
}
return Db::table($this->prefix . 'system_menus')
->whereIn('id', $deleteIds)
->delete();
}
/**
* 恢复被软删除的菜单(将 is_del 置回 0)
* 按菜单唯一性规则匹配,避免误恢复其他模块的同名菜单
* @return int
*/
protected function restoreMenus(): int
{
$menus = $this->getMenuConfig();
$count = 0;
foreach ($menus as $menu) {
$count += (clone $this->menuQuery($menu))->update(['is_del' => 0]);
}
return $count;
}
/**
* 隐藏菜单(设置 is_show = 0、is_show_path = 0,用于禁用时隐藏)
* 注意:is_show_path 必须同步置 0,否则菜单查询(is_show_path = 1)仍会返回该菜单
* 按菜单唯一性规则匹配,避免误隐藏其他模块的同名菜单
* @return int
*/
protected function hideMenus(): int
{
$menus = $this->getMenuConfig();
$count = 0;
foreach ($menus as $menu) {
$count += (clone $this->menuQuery($menu))
->update(['is_show' => 0, 'is_show_path' => 0]);
}
return $count;
}
/**
* 显示菜单(设置 is_show = 1、is_show_path = 1,用于启用时展示)
* 注意:is_show_path 必须同步置 1,否则菜单查询(is_show_path = 1)无法返回该菜单
* 按菜单唯一性规则匹配,避免误显示其他模块的同名菜单
* @return int
*/
protected function showMenus(): int
{
$menus = $this->getMenuConfig();
$count = 0;
foreach ($menus as $menu) {
$count += (clone $this->menuQuery($menu))
->update(['is_show' => 1, 'is_show_path' => 1]);
}
return $count;
}
/**
* 记录安装日志
* 安装后默认启用(enabled=1),与前端"启用显示菜单、禁用隐藏菜单"逻辑一致
* @return void
*/
protected function logInstall(): void
{
Db::table($this->prefix . 'module_install_log')->duplicate([
'module_name' => $this->getName(),
'module_type' => $this->getType(),
'version' => $this->getVersion(),
'status' => 1,
'enabled' => 1,
'install_time' => time(),
'uninstall_time' => 0,
])->insert([
'module_key' => $this->getKey(),
'module_name' => $this->getName(),
'module_type' => $this->getType(),
'version' => $this->getVersion(),
'status' => 1,
'enabled' => 1,
'install_time' => time(),
'uninstall_time' => 0,
]);
}
/**
* 记录卸载日志
* @return void
*/
protected function logUninstall(): void
{
Db::table($this->prefix . 'module_install_log')
->where('module_key', $this->getKey())
->where('status', 1)
->update([
'status' => 0,
'uninstall_time' => time(),
]);
}
/**
* 模块版本号
* @return string
*/
protected function getVersion(): string
{
return '1.0.0';
}
/**
* 获取 model_checkbox 配置中的模块 key
*
* 子类可重写此方法,返回对应模块在 `eb_system_config.model_checkbox`
* 中的标识,例如 'seckill'、'bargain'、'combination'。
* 返回空字符串表示该模块不受 model_checkbox 控制。
*
* @return string
*/
protected function getModelCheckboxKey(): string
{
return '';
}
/**
* 将模块加入 model_checkbox 显示配置
*
* 安装/启用时调用,确保后台菜单不会被 searchNoModelAttr 过滤掉。
*
* @return void
*/
protected function addToModelCheckbox(): void
{
$key = $this->getModelCheckboxKey();
if ($key === '') {
return;
}
$config = Db::table($this->prefix . 'system_config')
->where('menu_name', 'model_checkbox')
->find();
if (!$config) {
return;
}
$value = json_decode($config['value'] ?? '[]', true);
if (!is_array($value)) {
$value = [];
}
if (in_array($key, $value, true)) {
return;
}
$value[] = $key;
Db::table($this->prefix . 'system_config')
->where('menu_name', 'model_checkbox')
->update(['value' => json_encode($value, JSON_UNESCAPED_UNICODE)]);
$this->clearModelCheckboxCache();
}
/**
* 将模块从 model_checkbox 显示配置中移除
*
* 卸载/禁用时调用,避免模块已卸载后菜单仍被展示。
*
* @return void
*/
protected function removeFromModelCheckbox(): void
{
$key = $this->getModelCheckboxKey();
if ($key === '') {
return;
}
$config = Db::table($this->prefix . 'system_config')
->where('menu_name', 'model_checkbox')
->find();
if (!$config) {
return;
}
$value = json_decode($config['value'] ?? '[]', true);
if (!is_array($value)) {
return;
}
$value = array_values(array_diff($value, [$key]));
Db::table($this->prefix . 'system_config')
->where('menu_name', 'model_checkbox')
->update(['value' => json_encode($value, JSON_UNESCAPED_UNICODE)]);
$this->clearModelCheckboxCache();
}
/**
* 清除 model_checkbox 配置缓存
* @return void
*/
protected function clearModelCheckboxCache(): void
{
try {
CacheService::delete('system_config_model_checkbox');
} catch (\Throwable $e) {
// 缓存清除失败不影响主流程
}
}
/**
* 根据菜单名和 controller 查询父菜单 ID
* 仅为第三方旧插件保留;新插件必须使用 parent_key/getMenuPidByKey。
* @param string $menuName 菜单名称
* @param string $controller 控制器标识
* @return int
* @deprecated 使用 getMenuPidByKey()
*/
protected function getMenuPid(string $menuName, string $controller = ''): int
{
$menu = Db::table($this->prefix . 'system_menus')
->where('menu_name', $menuName)
->where('controller', $controller)
->where('is_del', 0)
->find();
return $menu ? (int) $menu['id'] : 0;
}
/**
* 根据不可变菜单标识查询父菜单 ID。
*
* core:<unique_auth> 在存量库尚未执行 menu_key 迁移时,会安全回退到
* unique_auth 查询;仍不存在则抛出异常,避免插件菜单错误挂到根节点。
*
* @param string $menuKey 菜单稳定标识
* @return int
*/
protected function getMenuPidByKey(string $menuKey): int
{
$menu = null;
if ($this->supportsMenuKeyColumn()) {
$menu = Db::table($this->prefix . 'system_menus')
->where('menu_key', $menuKey)
->where('is_del', 0)
->find();
}
if (!$menu && str_starts_with($menuKey, 'core:')) {
$uniqueAuth = substr($menuKey, strlen('core:'));
if ($uniqueAuth !== '' && !str_starts_with($uniqueAuth, 'menu:')) {
$query = Db::table($this->prefix . 'system_menus')
->where('unique_auth', $uniqueAuth)
->where('is_del', 0);
if ($this->supportsOwnershipColumn('system_menus')) {
$query->where('owner_plugin_id', '');
}
$menu = $query->find();
}
}
if (!$menu) {
throw new \RuntimeException("插件 {$this->getOwnerPluginId()} 依赖的父菜单不存在:{$menuKey}");
}
return (int)$menu['id'];
}
/**
* 统计指定类型的已安装模块数量
* @param string $type 模块类型
* @return int
*/
protected function countInstalledByType(string $type): int
{
return (int) Db::table($this->prefix . 'module_install_log')
->where('module_type', $type)
->where('status', 1)
->count();
}
/**
* 模块自有表白名单(子类重写声明)
*
* cleanTable() 仅允许清理本清单内的表,未声明一律拒绝(deny-by-default)。
* "模块拥有哪些表"是模块自己的知识:清单应覆盖模块 runMigrate() 建的全部业务表;
* 共享表仅在模块只按条件清理自身数据时声明(如 store_activity 按 type 区分归属)。
* @return array 表名列表(不含前缀)
*/
protected function getOwnedTables(): array
{
return [];
}
/**
* 清理指定业务表数据
* @param string $name 表名(不含前缀),必须在 getOwnedTables() 白名单内,且不得为核心共享表
* @param array $where 删除条件,空数组则全表清空
* @return int 删除行数
*/
protected function cleanTable(string $name, array $where = []): int
{
// 第一道防线:核心共享表绝对禁止,防止子类将核心表误声明为自有表
if (in_array($name, self::CORE_SHARED_TABLES, true)) {
throw new \Exception("cleanTable() 禁止清理核心共享表:{$name},模块数据清理仅允许针对模块自建表");
}
// 第二道防线:表必须已在模块自有表白名单中声明,未声明一律拒绝
if (!in_array($name, $this->getOwnedTables(), true)) {
throw new \Exception("cleanTable() 表 {$name} 未在 getOwnedTables() 中声明为模块自有表,禁止清理");
}
// 第三道防线:插件间共享表禁止无条件全表清空,必须携带归属条件(如 type)只清理本插件数据行
if (isset(self::SHARED_TABLE_CATALOG[$name]) && empty($where)) {
throw new \Exception("cleanTable() 禁止无条件清空插件间共享表:{$name},必须传入归属条件只清理本插件数据");
}
// 兼容旧版本、升级中断或人工删表后的部分安装状态。
// rollbackMigrate() 本身通常使用 DROP TABLE IF EXISTS,数据清理也应保持幂等。
if (!$this->hasTable($name)) {
return 0;
}
$query = Db::table($this->table($name));
if (!empty($where)) {
$query->where($where);
return (int) $query->delete();
}
// 无条件清空全表时使用强制删除,避免 ThinkPHP "delete without condition" 拦截
return (int) $query->delete(true);
}
// ============== DIY 页面链接辅助(链接选择器数据) ==============
/**
* 确保页面链接分类存在(幂等),返回末级分类 id
*
* 页面链接分类树(eb_page_categroy)是共享核心数据,插件只拥有自己的末级分类。
* 按"名称路径"逐级定位,缺失时创建,全程不硬编码分类 id。
* 例:['商城页面', '营销链接', '秒杀链接'] → 返回"秒杀链接"分类 id
*
* @param array $pathNames 从顶层到末级的分类名路径
* @param int $sort 末级分类排序(仅创建时生效)
* @param string $type 末级分类类型(仅创建时生效):link 静态链接列表;
* seckill/bargain/combination/integral 等动态商品选择分类,
* 前端链接选择器按该 type 渲染对应的商品列表
* @return int 末级分类 id;路径为空时返回 0
*/
protected function ensurePageLinkCategory(array $pathNames, int $sort = 0, string $type = 'link'): int
{
if (empty($pathNames)) {
return 0;
}
$pid = 0;
$last = count($pathNames) - 1;
$cateId = 0;
foreach ($pathNames as $index => $name) {
$existing = Db::table($this->table('page_categroy'))
->where('pid', $pid)
->where('name', $name)
->find();
if ($existing) {
$cateId = (int) $existing['id'];
$pid = $cateId;
continue;
}
// 中间层级为共享分组,统一按静态链接分类创建;末级分类使用调用方指定的 type
// (动态数据分类如"秒杀商品"由各活动模块随安装创建,而非核心预置)
$cateId = (int) Db::table($this->table('page_categroy'))->insertGetId([
'pid' => $pid,
'type' => $index === $last ? $type : 'link',
'name' => $name,
'sort' => $index === $last ? $sort : 0,
'status' => 1,
'add_time' => time(),
]);
$pid = $cateId;
}
return $cateId;
}
/**
* 批量写入页面链接(幂等,按 cate_id + url 查重)
*
* @param int $cateId 分类 id
* @param array $links 链接配置,形如
* [['name' => '秒杀详情', 'url' => '/pages/...', 'param' => 'id,time_id', 'example' => '', 'sort' => 0], ...]
* @return int 实际新增条数
*/
protected function seedPageLinks(int $cateId, array $links): int
{
if ($cateId <= 0 || empty($links)) {
return 0;
}
$count = 0;
$now = time();
foreach ($links as $link) {
$url = (string) ($link['url'] ?? '');
if ($url === '') {
continue;
}
// 幂等:url 全局查重——老站点预置在共享分类(如个人中心)的插件链接不重复插入
$exists = Db::table($this->table('page_link'))
->where('url', $url)
->find();
if ($exists) {
continue;
}
Db::table($this->table('page_link'))->insert([
'cate_id' => $cateId,
'type' => 1,
'name' => (string) ($link['name'] ?? ''),
'url' => $url,
'param' => (string) ($link['param'] ?? ''),
'example' => (string) ($link['example'] ?? ''),
'status' => 1,
'sort' => (int) ($link['sort'] ?? 0),
'add_time' => $now,
]);
$count++;
}
return $count;
}
/**
* 清理插件页面链接及独有分类(卸载时调用)
*
* 清理边界:
* - 链接:按 url 白名单精确删除(覆盖挂载在独有分类与共享分类两种位置的插件链接)
* - 分类:仅删除本插件独有命名空间(按分类名精确匹配),且分类下已无链接时才删除,
* 避免误删商家在插件分类下自建的链接
*
* @param array $categoryNames 本插件独有分类名列表(如 ['秒杀链接'])
* @param array $urls 本插件 seed 的链接 url 白名单
* @return int 清理条数(链接 + 分类)
*/
protected function cleanPageLinks(array $categoryNames, array $urls): int
{
if ((!$this->hasTable('page_link') || !$this->hasTable('page_categroy'))
|| (empty($categoryNames) && empty($urls))) {
return 0;
}
$count = 0;
// 1. 按 url 白名单删除插件链接:url 为插件页面路径,语义上仅由本插件 seed 写入
if (!empty($urls)) {
$count += Db::table($this->table('page_link'))
->whereIn('url', $urls)
->delete();
}
// 2. 删除已空的独有分类(仍有链接说明商家自建过数据,保留分类避免孤儿数据)
foreach ($categoryNames as $name) {
$category = Db::table($this->table('page_categroy'))
->where('name', $name)
->find();
if (!$category) {
continue;
}
$remaining = Db::table($this->table('page_link'))
->where('cate_id', $category['id'])
->count();
if ($remaining > 0) {
continue;
}
$count += Db::table($this->table('page_categroy'))
->where('id', $category['id'])
->delete();
}
return $count;
}
/** 获取当前 Installer 对应的复合插件标识。 */
protected function getOwnerPluginId(): string
{
if ($this->ownerPluginId !== null) {
return $this->ownerPluginId;
}
if (str_starts_with($this->getKey(), 'module:') || str_starts_with($this->getKey(), 'driver:')) {
return $this->ownerPluginId = $this->getKey();
}
try {
$reflection = new \ReflectionClass($this);
$manifestFile = dirname((string) $reflection->getFileName()) . '/plugin.json';
if (is_file($manifestFile)) {
$manifest = \crmeb\plugin\PluginManifest::fromFile($manifestFile, root_path());
return $this->ownerPluginId = $manifest->identity->canonicalId();
}
} catch (\Throwable $e) {
}
return $this->ownerPluginId = $this->getKey();
}
/**
* 驱动安装时把 plugin.json 元数据写入统一驱动字典。
* 目录只负责安装前发现,运行时不再扫描目录。
*/
protected function installDriverRecord(): bool
{
if ($this->getType() !== 'driver') {
return false;
}
$manifest = $this->getPluginManifest();
$driver = $manifest->meta['driver'] ?? [];
if (!is_array($driver) || empty($driver['driver_key'])) {
throw new \RuntimeException("驱动插件缺少 driver 元数据: {$this->getKey()}");
}
$json = static fn ($value): string => json_encode(
is_array($value) ? $value : [],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
) ?: '[]';
$namespace = 'driver\\' . $manifest->identity->category . '\\' . basename($manifest->basePath);
$now = time();
$row = [
'driver_type' => $manifest->identity->category,
'driver_key' => (string) $driver['driver_key'],
'driver_name' => (string) ($driver['name'] ?? $manifest->name),
'class_name' => (string) ($driver['class_name'] ?? 'Driver'),
'namespace' => $namespace,
'owner_plugin_id' => $manifest->identity->canonicalId(),
'icon' => (string) ($driver['icon'] ?? ''),
'description' => (string) ($manifest->meta['description'] ?? ''),
'is_builtin' => !empty($driver['is_builtin']) ? 1 : 0,
'capabilities' => $json($driver['capabilities'] ?? []),
'config_fields' => $json($driver['config_fields'] ?? []),
'help_doc' => $json($driver['help_doc'] ?? []),
'domain_template' => (string) ($driver['domain_template'] ?? ''),
'bucket_fields' => $json($driver['bucket_fields'] ?? []),
'thumb_watermark_config' => $json($driver['thumb_watermark_config'] ?? []),
'platforms' => $json($driver['platforms'] ?? []),
'sort' => (int) ($driver['sort'] ?? 0),
'status' => !empty($driver['is_builtin']) ? 1 : 0,
'add_time' => $now,
'update_time' => $now,
];
Db::table($this->table('driver'))->duplicate([
'driver_name', 'class_name', 'namespace', 'owner_plugin_id', 'icon', 'description',
'is_builtin', 'capabilities', 'config_fields', 'help_doc', 'domain_template',
'bucket_fields', 'thumb_watermark_config', 'platforms', 'sort', 'update_time',
])->insert($row);
return true;
}
/** 驱动卸载时删除当前插件拥有的字典记录,并兼容升级前的无归属旧记录。 */
protected function removeDriverRecord(): bool
{
if ($this->getType() !== 'driver') {
return false;
}
$count = (int) Db::table($this->table('driver'))
->where('owner_plugin_id', $this->getOwnerPluginId())
->delete();
$manifest = $this->getPluginManifest();
$driver = $manifest->meta['driver'] ?? [];
if (is_array($driver) && !empty($driver['driver_key'])) {
$count += (int) Db::table($this->table('driver'))
->where('owner_plugin_id', '')
->where('driver_type', $manifest->identity->category)
->where('driver_key', (string) $driver['driver_key'])
->delete();
}
return $count > 0;
}
protected function getPluginManifest(): PluginManifest
{
$reflection = new \ReflectionClass($this);
$manifestFile = dirname((string) $reflection->getFileName()) . '/plugin.json';
if (!is_file($manifestFile)) {
throw new \RuntimeException("插件清单不存在: {$manifestFile}");
}
return PluginManifest::fromFile($manifestFile, root_path());
}
/** @return array<string, int> */
protected function captureSharedResourceWatermarks(): array
{
$watermarks = [];
foreach (['system_menus', 'system_config', 'system_config_tab'] as $table) {
if ($this->supportsOwnershipColumn($table)) {
$watermarks[$table] = (int) Db::table($this->table($table))->max('id');
}
}
return $watermarks;
}
/** seed() 中新增的公共配置资源统一补上插件所有权。 */
protected function claimSharedResources(array $watermarks): void
{
$owner = $this->getOwnerPluginId();
foreach ($watermarks as $table => $maxId) {
Db::table($this->table($table))
->where('id', '>', $maxId)
->where('owner_plugin_id', '')
->update(['owner_plugin_id' => $owner]);
}
}
protected function removeOwnedSharedResources(): int
{
$owner = $this->getOwnerPluginId();
$count = 0;
foreach (['system_config', 'system_config_tab', 'system_menus'] as $table) {
if ($this->supportsOwnershipColumn($table)) {
$count += (int) Db::table($this->table($table))
->where('owner_plugin_id', $owner)
->delete();
}
}
return $count;
}
protected function supportsOwnershipColumn(string $table): bool
{
if (isset($this->ownershipColumnCache[$table])) {
return $this->ownershipColumnCache[$table];
}
try {
$database = (string) config('database.connections.mysql.database');
$exists = Db::query(
'SELECT 1 FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = ? LIMIT 1',
[$database, $this->table($table), 'owner_plugin_id']
);
return $this->ownershipColumnCache[$table] = !empty($exists);
} catch (\Throwable $e) {
return $this->ownershipColumnCache[$table] = false;
}
}
/**
* 判断菜单稳定标识字段是否存在。
*
* @return bool
*/
protected function supportsMenuKeyColumn(): bool
{
if ($this->menuKeyColumnSupported !== null) {
return $this->menuKeyColumnSupported;
}
try {
$database = (string)config('database.connections.mysql.database');
$exists = Db::query(
'SELECT 1 FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = ? LIMIT 1',
[$database, $this->table('system_menus'), 'menu_key']
);
return $this->menuKeyColumnSupported = !empty($exists);
} catch (\Throwable $e) {
return $this->menuKeyColumnSupported = false;
}
}
/**
* 为存量数据库补齐插件公共资源归属字段。
*
* 新安装库已由 crmeb_table.sql 创建这些字段;这里用于兼容从旧版本直接升级、
* 未完整执行历史 SQL 的站点。DDL 必须在安装/卸载事务开始前执行。
*/
protected function ensureSharedOwnershipColumns(): void
{
$definitions = [
'system_menus' => "varchar(96) NOT NULL DEFAULT '' COMMENT '所属插件复合标识,空为核心菜单' AFTER `mark`",
'system_config' => "varchar(96) NOT NULL DEFAULT '' COMMENT '所属插件复合标识,空为核心配置' AFTER `link_value`",
'system_config_tab' => "varchar(96) NOT NULL DEFAULT '' COMMENT '所属插件复合标识,空为核心分组' AFTER `menus_id`",
'agreement' => "varchar(96) NOT NULL DEFAULT '' COMMENT '所属插件复合标识,空为系统协议' AFTER `type`",
];
foreach ($definitions as $table => $definition) {
$fullTable = $this->table($table);
if (!$this->supportsOwnershipColumn($table)) {
Db::execute("ALTER TABLE `{$fullTable}` ADD COLUMN `owner_plugin_id` {$definition}");
$this->ownershipColumnCache[$table] = true;
}
$database = (string) config('database.connections.mysql.database');
$indexes = Db::query(
'SELECT 1 FROM information_schema.statistics'
. ' WHERE table_schema = ? AND table_name = ? AND index_name = ? LIMIT 1',
[$database, $fullTable, 'idx_owner_plugin_id']
);
if (empty($indexes)) {
Db::execute("ALTER TABLE `{$fullTable}` ADD INDEX `idx_owner_plugin_id` (`owner_plugin_id`)");
}
}
// 协议表插件化改造:补齐 AI 提示词字段并改造唯一索引,供插件 seed 协议使用
$this->ensureAgreementColumns();
// 菜单稳定标识:旧库回填并建立唯一约束,供全部插件按 key 幂等管理。
$this->ensureMenuKeyInfrastructure();
// 共享表准入登记表兜底建表(存量站点升级,DDL 幂等)
$this->ensureSharedTableRegistry();
}
/**
* 为存量数据库补齐菜单稳定标识及唯一索引。
*
* 核心菜单在 unique_auth 全局唯一时使用可读的 core:<unique_auth>;
* 其余核心菜单与插件菜单使用稳定字段哈希。历史重复行保留最早记录的
* 标准 key,其余行改为 legacy key,确保添加唯一索引不会中断升级。
*
* @return void
*/
protected function ensureMenuKeyInfrastructure(): void
{
$fullTable = $this->table('system_menus');
if (!$this->supportsMenuKeyColumn()) {
Db::execute(
"ALTER TABLE `{$fullTable}` ADD COLUMN `menu_key` varchar(190) "
. "NOT NULL DEFAULT '' COMMENT '菜单稳定标识,系统生成且不可修改' AFTER `unique_auth`"
);
$this->menuKeyColumnSupported = true;
}
$identitySql = "CONCAT_WS(CHAR(31), CAST(m.`auth_type` AS CHAR), m.`unique_auth`, m.`api_url`, "
. "UPPER(m.`methods`), m.`controller`, m.`action`, m.`menu_name`)";
Db::execute(
"UPDATE `{$fullTable}` AS m "
. "LEFT JOIN (SELECT `unique_auth`, COUNT(*) AS total FROM `{$fullTable}` "
. "WHERE `unique_auth` <> '' GROUP BY `unique_auth`) AS u ON u.`unique_auth` = m.`unique_auth` "
. "SET m.`menu_key` = CASE "
. "WHEN m.`owner_plugin_id` = '' AND m.`unique_auth` <> '' AND u.`total` = 1 "
. "THEN CONCAT('core:', m.`unique_auth`) "
. "WHEN m.`owner_plugin_id` = '' THEN CONCAT('core:menu:', SHA1({$identitySql})) "
. "ELSE CONCAT(m.`owner_plugin_id`, ':menu:', SHA1({$identitySql})) END "
. "WHERE m.`menu_key` = ''"
);
// 历史重复菜单先分配仅用于清理的 legacy key,保证唯一索引可安全建立。
Db::execute(
"UPDATE `{$fullTable}` AS m INNER JOIN ("
. "SELECT `menu_key`, MIN(`id`) AS keep_id FROM `{$fullTable}` "
. "WHERE `menu_key` <> '' GROUP BY `menu_key` HAVING COUNT(*) > 1"
. ") AS d ON d.`menu_key` = m.`menu_key` AND m.`id` <> d.`keep_id` "
. "SET m.`menu_key` = CONCAT('legacy:menu:', m.`id`, ':', SHA1(m.`menu_key`))"
);
$database = (string)config('database.connections.mysql.database');
$index = Db::query(
'SELECT 1 FROM information_schema.statistics'
. ' WHERE table_schema = ? AND table_name = ? AND index_name = ? LIMIT 1',
[$database, $fullTable, 'uk_menu_key']
);
if (empty($index)) {
// NULLIF 允许极端中断场景暂存空 key,所有正常写入仍受唯一约束。
Db::execute("ALTER TABLE `{$fullTable}` ADD UNIQUE KEY `uk_menu_key` ((NULLIF(`menu_key`, ''))) ");
}
}
/**
* 补齐协议表插件化改造所需字段与索引(幂等,DDL 在安装/卸载事务外执行)
*
* 存量站点升级内容:
* - 补 system_prompt / user_prompt 字段(AI 写作提示词,随协议数据管理)
* - 原 type 单列唯一索引改造为 (type, owner_plugin_id) 复合唯一索引,
* 允许不同插件复用相同协议类型值
*
* @return void
*/
protected function ensureAgreementColumns(): void
{
if (!$this->hasTable('agreement')) {
return;
}
$database = (string) config('database.connections.mysql.database');
$fullTable = $this->table('agreement');
// 补齐 AI 提示词字段
$columns = Db::query(
'SELECT column_name FROM information_schema.columns WHERE table_schema = ? AND table_name = ?',
[$database, $fullTable]
);
$names = array_map(static fn ($row): string => (string) $row['COLUMN_NAME'], $columns);
if (!in_array('system_prompt', $names, true)) {
Db::execute("ALTER TABLE `{$fullTable}` ADD COLUMN `system_prompt` text COMMENT '系统AI提示词(AI写作的角色设定与格式要求)' AFTER `content`");
}
if (!in_array('user_prompt', $names, true)) {
Db::execute("ALTER TABLE `{$fullTable}` ADD COLUMN `user_prompt` text COMMENT '用户提示词(AI写作输入框默认提示词)' AFTER `system_prompt`");
}
// 原 type 单列唯一索引改造为 (type, owner_plugin_id) 复合唯一索引
$compositeExists = Db::query(
'SELECT 1 FROM information_schema.statistics'
. ' WHERE table_schema = ? AND table_name = ? AND index_name = ? LIMIT 1',
[$database, $fullTable, 'uk_type_plugin']
);
if (empty($compositeExists)) {
// 删除旧的单列唯一索引(历史索引名为 type,仅当其确实建在 type 列上时删除)
$legacy = Db::query(
"SELECT 1 FROM information_schema.statistics"
. " WHERE table_schema = ? AND table_name = ? AND index_name = 'type' AND column_name = 'type' LIMIT 1",
[$database, $fullTable]
);
if (!empty($legacy)) {
Db::execute("ALTER TABLE `{$fullTable}` DROP INDEX `type`");
}
Db::execute("ALTER TABLE `{$fullTable}` ADD UNIQUE KEY `uk_type_plugin` (`type`,`owner_plugin_id`)");
}
}
// ============== 插件协议种子辅助(需要协议的插件在 seed() 中调用) ==============
/**
* 批量写入插件协议种子(幂等)
*
* 幂等与存量数据迁移规则:
* - 已存在本插件同 type 协议:跳过(保留商家修改过的内容)
* - 存在同 type 的无归属记录(旧版核心预置数据):认领——补插件归属与 AI 提示词,
* 不覆盖商家已修改的协议内容
* - 均不存在:插入带默认内容的新记录
*
* @param array $agreements 每项形如:
* ['type' => 2, 'title' => '代理商协议', 'content' => '<h1>...</h1>',
* 'system_prompt' => '...', 'user_prompt' => '...', 'status' => 1, 'sort' => 0]
* @return int 实际写入/认领条数
*/
protected function seedAgreements(array $agreements): int
{
if (!$this->hasTable('agreement') || empty($agreements)) {
return 0;
}
$owner = $this->getOwnerPluginId();
$now = time();
$count = 0;
foreach ($agreements as $item) {
$type = (int) ($item['type'] ?? 0);
if ($type <= 0) {
continue;
}
$data = [
'title' => (string) ($item['title'] ?? ''),
'content' => (string) ($item['content'] ?? ''),
'system_prompt' => (string) ($item['system_prompt'] ?? ''),
'user_prompt' => (string) ($item['user_prompt'] ?? ''),
'status' => (int) ($item['status'] ?? 1),
];
// 本插件已有同类型协议:跳过,避免覆盖商家已修改的内容
$owned = Db::table($this->table('agreement'))
->where('type', $type)
->where('owner_plugin_id', $owner)
->find();
if ($owned) {
continue;
}
// 旧版核心预置的同类型无归属记录:认领归属并补齐 AI 提示词,协议内容保持不动
$legacy = Db::table($this->table('agreement'))
->where('type', $type)
->where('owner_plugin_id', '')
->find();
if ($legacy) {
Db::table($this->table('agreement'))
->where('id', $legacy['id'])
->update([
'owner_plugin_id' => $owner,
'system_prompt' => $data['system_prompt'],
'user_prompt' => $data['user_prompt'],
]);
$count++;
continue;
}
// 全新插入
Db::table($this->table('agreement'))->insert(array_merge($data, [
'type' => $type,
'owner_plugin_id' => $owner,
'sort' => (int) ($item['sort'] ?? 0),
'add_time' => $now,
]));
$count++;
}
return $count;
}
/**
* 卸载时清理插件协议数据(cleanData 中调用)
*
* 清理边界:
* - 本插件拥有的协议(owner_plugin_id 匹配,必删)
* - 无归属但 type 在本插件白名单内的旧数据(存量站点未重装插件即卸载的兜底)
*
* @param array $types 本插件使用的协议类型值白名单(如 [2, 8])
* @return int 清理条数
*/
protected function cleanAgreements(array $types = []): int
{
if (!$this->hasTable('agreement')) {
return 0;
}
$count = (int) Db::table($this->table('agreement'))
->where('owner_plugin_id', $this->getOwnerPluginId())
->delete();
if (!empty($types)) {
$count += (int) Db::table($this->table('agreement'))
->where('owner_plugin_id', '')
->whereIn('type', $types)
->delete();
}
return $count;
}
/**
* 软删除菜单(用于系统原生菜单,卸载时不物理删除)
* 按 menu_name + controller 匹配,将 is_del 置为 1
* @return int
*/
protected function softDeleteMenus(): int
{
$menus = $this->getMenuConfig();
$count = 0;
foreach ($menus as $menu) {
$count += Db::table($this->prefix . 'system_menus')
->where('menu_name', $menu['menu_name'])
->where('controller', $menu['controller'] ?? '')
->update(['is_del' => 1]);
}
return $count;
}
/**
* 备份插件文件到 backup/ 目录
*
* 卸载时自动将 modules/ 下的插件目录移动到 backend/backup/ 下,
* 备份目录名格式:{路径}_{时间戳}(如 agent_20260720_153000、product_normal_20260720_153000)。
*
* 使用 register_shutdown_function 延迟执行,避免文件移动时 PHP 进程锁冲突。
*
* @return void
*/
protected function backupPluginFiles(): void
{
$ref = new \ReflectionClass($this);
$moduleDir = dirname($ref->getFileName());
$rootPath = app()->getRootPath();
$backupDir = $rootPath . 'backup' . DIRECTORY_SEPARATOR;
if (!is_dir($backupDir)) {
@mkdir($backupDir, 0755, true);
}
// 计算相对于 backend/ 的路径,将 / 替换为 _,追加时间戳
$relativePath = ltrim(substr($moduleDir, strlen(rtrim($rootPath, DIRECTORY_SEPARATOR))), DIRECTORY_SEPARATOR);
$backupName = str_replace(DIRECTORY_SEPARATOR, '_', $relativePath) . '_' . date('Ymd_His');
$backupPath = $backupDir . $backupName;
// 延迟执行,避免文件移动影响当前请求
register_shutdown_function(function () use ($moduleDir, $backupPath) {
if (is_dir($moduleDir) && !is_dir($backupPath)) {
rename($moduleDir, $backupPath);
}
});
}
}