安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
560 lines
21 KiB
PHP
560 lines
21 KiB
PHP
<?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 app\backend\library;
|
||
|
||
use think\facade\Db;
|
||
use ywxapp\model\BackendPower;
|
||
|
||
/**
|
||
* 核心后台「新增菜单」注册表(请求级自愈)
|
||
*
|
||
* 为什么需要它:核心后台的菜单就是 backend_power 里的一条记录,
|
||
* 如果只在「访问某个页面时」才补录节点,那菜单本身不存在 ⇒ 点不进去 ⇒ 永远补不上,
|
||
* 新功能上线后管理员根本看不到入口。
|
||
*
|
||
* 因此与 appmall 侧栏菜单同构(AppmallMenu::ensure()),由
|
||
* BackendSchemaMiddleware 在每个 /admin/* 请求上幂等补录:菜单随请求出现,
|
||
* 老库升级也不用手工去权限管理里加节点。
|
||
*
|
||
* 控制器侧的 EnsuresPower trait 调的是同一个入口,属双保险(进程内 memo,不重复查库)。
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class MenuRegistry
|
||
{
|
||
/**
|
||
* 进程内只补录一次
|
||
* @var bool
|
||
*/
|
||
protected static $checked = false;
|
||
|
||
/**
|
||
* 进程内只迁移一次
|
||
* @var bool
|
||
*/
|
||
protected static $migrated = false;
|
||
|
||
/**
|
||
* 补录全部新增菜单(幂等)
|
||
* @return void
|
||
*/
|
||
public static function ensure(): void
|
||
{
|
||
if (self::$checked) {
|
||
return;
|
||
}
|
||
// 先标记:即使中途失败也不在本次请求内反复重试
|
||
self::$checked = true;
|
||
|
||
// 先做结构调整(顶层目录拆分),再补录新菜单
|
||
self::migrate();
|
||
|
||
foreach (self::menus() as $menu) {
|
||
try {
|
||
// 菜单按所属分组挂:配置类进「系统管理」,运维类进「数据运维」
|
||
$pid = self::groupPid((string) ($menu['group'] ?? 'system'));
|
||
self::ensureMenu($menu, $pid);
|
||
} catch (\Throwable $e) {
|
||
// 单个菜单失败不影响其它菜单
|
||
}
|
||
}
|
||
|
||
// 只补按钮的菜单(菜单节点在 install.sql 已有种子,但按钮缺失)
|
||
self::ensureScoreButtons();
|
||
}
|
||
|
||
/**
|
||
* 补录「积分规则」的按钮权限节点(幂等)
|
||
*
|
||
* install.sql 早期只种了 score:index,缺 score:add / edit / del / restore / destroy,
|
||
* 结果后台新增、编辑、删除按钮被前端权限过滤隐藏,规则根本没法维护。
|
||
* 老库不会重跑 install.sql,故在此自愈,节点定义与 install.sql 种子保持一致。
|
||
*
|
||
* @return void
|
||
*/
|
||
protected static function ensureScoreButtons(): void
|
||
{
|
||
try {
|
||
$menu = Db::name('backend_power')->where('name', 'score:index')->find();
|
||
if (! $menu) {
|
||
return;
|
||
}
|
||
|
||
$pid = (int) $menu['id'];
|
||
$buttons = [
|
||
'add' => ['title' => '新增规则', 'route' => 'score/save'],
|
||
'edit' => ['title' => '编辑规则', 'route' => 'score/edit'],
|
||
'del' => ['title' => '删除规则', 'route' => 'score/delete'],
|
||
'restore' => ['title' => '恢复规则', 'route' => 'score/restore'],
|
||
'destroy' => ['title' => '彻底删除规则', 'route' => 'score/delete'],
|
||
];
|
||
|
||
$now = time();
|
||
$newIds = [];
|
||
foreach ($buttons as $act => $cfg) {
|
||
$node = 'score:' . $act;
|
||
if (Db::name('backend_power')->where('name', $node)->find()) {
|
||
continue;
|
||
}
|
||
$newIds[] = (int) Db::name('backend_power')->insertGetId([
|
||
'pid' => $pid,
|
||
'title' => $cfg['title'],
|
||
'name' => $node,
|
||
'type' => 3,
|
||
'route' => $cfg['route'],
|
||
'icon' => '',
|
||
'sort' => 0,
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
}
|
||
|
||
if ($newIds) {
|
||
self::grantToAllRoles($newIds);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 静默:补录失败不应让后台打不开
|
||
}
|
||
}
|
||
|
||
/* ============================ 结构调整(一次性迁移) ============================ */
|
||
|
||
/**
|
||
* 顶层目录拆分:「营运」→「内容运营」+「交易营销」(幂等、只跑一次)
|
||
*
|
||
* 为什么要在这里做而不是只改 install.sql:
|
||
* install.sql 只影响全新安装,老库不会重跑,必须有一条自愈路径才能生效。
|
||
*
|
||
* 两个关键约束:
|
||
* 1. 「营运」节点是原地改名成「内容运营」的,id 不变 ⇒ 角色既有的授权不失效;
|
||
* 2. 新建的「交易营销」目录 id 是新的,角色授权表里没有它,
|
||
* 非超管账号会因为父节点不在授权列表里而整个分支都看不到,
|
||
* 所以迁移末尾要把新节点补授权给所有角色(见 grantToAllRoles())。
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function migrate(): void
|
||
{
|
||
if (self::$migrated) {
|
||
return;
|
||
}
|
||
self::$migrated = true;
|
||
|
||
try {
|
||
// 完成标记:分组标题存在即视为已拆分。
|
||
// 之后管理员手工调顺序 / 改名不再被本方法覆盖。
|
||
if (Db::name('backend_power')->where('name', 'trade.group.goods')->value('id')) {
|
||
return;
|
||
}
|
||
|
||
$now = time();
|
||
|
||
// 1) 内容运营:优先复用原「营运」节点(保住 id 与既有角色授权)
|
||
$contentId = (int) Db::name('backend_power')->where('name', 'content')->value('id');
|
||
if ($contentId <= 0) {
|
||
$operate = Db::name('backend_power')->where('name', 'operate')->find();
|
||
if ($operate) {
|
||
Db::name('backend_power')->where('id', (int) $operate['id'])->update([
|
||
'title' => '内容运营',
|
||
'name' => 'content',
|
||
'route' => '/content',
|
||
'icon' => 'layui-icon layui-icon-speaker',
|
||
'sort' => 4,
|
||
'update_at' => $now,
|
||
]);
|
||
$contentId = (int) $operate['id'];
|
||
} else {
|
||
$contentId = (int) Db::name('backend_power')->insertGetId([
|
||
'pid' => 0,
|
||
'title' => '内容运营',
|
||
'name' => 'content',
|
||
'type' => 1,
|
||
'route' => '/content',
|
||
'icon' => 'layui-icon layui-icon-speaker',
|
||
'sort' => 4,
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
}
|
||
}
|
||
|
||
// 2) 交易营销:新顶层目录
|
||
$tradeId = (int) Db::name('backend_power')->where('name', 'trade')->value('id');
|
||
if ($tradeId <= 0) {
|
||
$tradeId = (int) Db::name('backend_power')->insertGetId([
|
||
'pid' => 0,
|
||
'title' => '交易营销',
|
||
'name' => 'trade',
|
||
'type' => 1,
|
||
'route' => '/trade',
|
||
'icon' => 'layui-icon layui-icon-cart-simple',
|
||
'sort' => 5,
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
}
|
||
|
||
// 3) 菜单重挂到新目录并重排 sort
|
||
$plan = self::splitPlan();
|
||
foreach ($plan['content'] as $name => $sort) {
|
||
Db::name('backend_power')->where('name', $name)
|
||
->update(['pid' => $contentId, 'sort' => $sort, 'update_at' => $now]);
|
||
}
|
||
foreach ($plan['trade'] as $name => $sort) {
|
||
Db::name('backend_power')->where('name', $name)
|
||
->update(['pid' => $tradeId, 'sort' => $sort, 'update_at' => $now]);
|
||
}
|
||
|
||
// 4) 交易营销内的分组标题(type=5,只做视觉分组,不可点击)
|
||
$newIds = [];
|
||
foreach (self::groupTitles() as $name => $cfg) {
|
||
$id = (int) Db::name('backend_power')->where('name', $name)->value('id');
|
||
if ($id > 0) {
|
||
continue;
|
||
}
|
||
$newIds[] = (int) Db::name('backend_power')->insertGetId([
|
||
'pid' => $tradeId,
|
||
'title' => $cfg['title'],
|
||
'name' => $name,
|
||
'type' => BackendPower::TYPE_GROUP_TITLE,
|
||
'route' => '',
|
||
'icon' => '',
|
||
'sort' => $cfg['sort'],
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
}
|
||
|
||
// 5) 新节点补授权给所有角色
|
||
self::grantToAllRoles(array_merge([$tradeId], $newIds));
|
||
} catch (\Throwable $e) {
|
||
// 结构调整失败不应让后台打不开:保持静默
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 拆分方案:[菜单 name => 新 sort]
|
||
*
|
||
* 与 public/install/install.sql 的 backend_power 种子保持一致,
|
||
* 保证「全新安装」与「老库自愈」两条路径结果相同。
|
||
*
|
||
* @return array
|
||
*/
|
||
protected static function splitPlan(): array
|
||
{
|
||
return [
|
||
// 站点内容与展示类
|
||
'content' => [
|
||
'links:index' => 1,
|
||
'notice:index' => 2,
|
||
'ad:index' => 3,
|
||
'help:index' => 4,
|
||
'spider:index' => 5,
|
||
],
|
||
// 交易、激励与服务类(内部再用分组标题切成三组)
|
||
'trade' => [
|
||
'card:index' => 2,
|
||
'score:index' => 3,
|
||
'task:index' => 11,
|
||
'prop:index' => 12,
|
||
'medal:index' => 13,
|
||
'sms:index' => 21,
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 目录内分组标题定义(name => [标题, sort])
|
||
*
|
||
* 目前只在「交易营销」内使用:7 个菜单跨越交易 / 激励 / 服务三类,
|
||
* 直接用标题分组比再建一层目录更轻(layui 侧边栏不支持三级展开)。
|
||
*
|
||
* @return array
|
||
*/
|
||
protected static function groupTitles(): array
|
||
{
|
||
return [
|
||
'trade.group.goods' => ['title' => '交易中心', 'sort' => 0],
|
||
'trade.group.mission' => ['title' => '互动激励', 'sort' => 10],
|
||
'trade.group.service' => ['title' => '基础服务', 'sort' => 20],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 把新增节点补授权给所有角色(幂等)
|
||
*
|
||
* 菜单是按授权 id 取树的:父目录不在授权列表里,整条分支都会丢,
|
||
* 所以新建顶层目录 / 分组标题必须同步授权,否则非超管账号会「看不见但确实存在」。
|
||
*
|
||
* @param array $powerIds
|
||
* @return void
|
||
*/
|
||
protected static function grantToAllRoles(array $powerIds): void
|
||
{
|
||
$powerIds = array_values(array_unique(array_filter(array_map('intval', $powerIds))));
|
||
if (! $powerIds) {
|
||
return;
|
||
}
|
||
$now = time();
|
||
$roles = Db::name('backend_role')->column('id');
|
||
foreach ($roles as $roleId) {
|
||
$owned = Db::name('backend_role_power')
|
||
->where('role_id', (int) $roleId)
|
||
->whereIn('power_id', $powerIds)
|
||
->column('power_id');
|
||
$missing = array_diff($powerIds, array_map('intval', $owned));
|
||
foreach ($missing as $powerId) {
|
||
Db::name('backend_role_power')->insert([
|
||
'role_id' => (int) $roleId,
|
||
'power_id' => (int) $powerId,
|
||
'create_at' => $now,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 顶层分组定义(分组名 => 目录节点)
|
||
*
|
||
* 与 public/install/install.sql 的 backend_power 种子保持一致:
|
||
* name/sort/route/icon 三处对齐,保证全新安装与老库自愈结果相同。
|
||
*
|
||
* @return array
|
||
*/
|
||
public static function groups(): array
|
||
{
|
||
return [
|
||
'system' => [
|
||
'title' => '系统管理',
|
||
'name' => 'system',
|
||
'route' => '/system',
|
||
'icon' => 'layui-icon layui-icon-set',
|
||
'sort' => 6,
|
||
],
|
||
'tools' => [
|
||
'title' => '数据运维',
|
||
'name' => 'tools',
|
||
'route' => '/tools',
|
||
'icon' => 'layui-icon layui-icon-engine',
|
||
'sort' => 7,
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 取分组目录节点 ID(不存在则补建,幂等)
|
||
*/
|
||
protected static function groupPid(string $group): int
|
||
{
|
||
try {
|
||
$cfg = self::groups()[$group] ?? null;
|
||
if (! $cfg) {
|
||
return 0;
|
||
}
|
||
$id = Db::name('backend_power')->where('name', $cfg['name'])->value('id');
|
||
if ($id) {
|
||
return (int) $id;
|
||
}
|
||
$now = time();
|
||
return (int) Db::name('backend_power')->insertGetId([
|
||
'pid' => 0,
|
||
'title' => $cfg['title'],
|
||
'name' => $cfg['name'],
|
||
'type' => 1,
|
||
'route' => $cfg['route'],
|
||
'icon' => $cfg['icon'],
|
||
'sort' => $cfg['sort'],
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
return 0; // 权限表不可用:静默
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 新增菜单定义(权限前缀 => 配置)
|
||
*
|
||
* @return array
|
||
*/
|
||
public static function menus(): array
|
||
{
|
||
return [
|
||
'database' => [
|
||
'title' => '数据库备份',
|
||
'route' => 'database/index',
|
||
'icon' => 'layui-icon layui-icon-set',
|
||
'group' => 'tools',
|
||
// 与 public/install/install.sql 的 backend_power 种子 sort 一致,
|
||
// 保证「全新安装」与「老库自愈」两条路径的菜单顺序相同
|
||
'sort' => 1,
|
||
'buttons' => [
|
||
'backup' => '备份',
|
||
'recovery' => '还原',
|
||
'delete' => '删除',
|
||
'optimize' => '优化',
|
||
],
|
||
],
|
||
'datatable' => [
|
||
'title' => '数据表管理',
|
||
'route' => 'datatable/index',
|
||
'icon' => 'layui-icon layui-icon-template-1',
|
||
'group' => 'tools',
|
||
'sort' => 2,
|
||
'buttons' => [
|
||
'create' => '新建',
|
||
'structure' => '结构',
|
||
'optimize' => '优化',
|
||
'export' => '导出安装SQL',
|
||
'drop' => '删除',
|
||
],
|
||
],
|
||
'adminlog' => [
|
||
'title' => '操作日志',
|
||
'route' => 'adminlog/index',
|
||
'icon' => 'layui-icon layui-icon-log',
|
||
'group' => 'tools',
|
||
'sort' => 4,
|
||
'buttons' => [
|
||
'delete' => '删除',
|
||
'clean' => '清理',
|
||
],
|
||
],
|
||
'attachment' => [
|
||
'title' => '附件管理',
|
||
'route' => 'attachment/index',
|
||
'icon' => 'layui-icon layui-icon-picture',
|
||
'group' => 'tools',
|
||
'sort' => 3,
|
||
'buttons' => [
|
||
'delete' => '删除',
|
||
],
|
||
],
|
||
'hook' => [
|
||
'title' => '模板钩子',
|
||
'route' => 'hook/index',
|
||
'icon' => 'layui-icon layui-icon-template-1',
|
||
'group' => 'tools',
|
||
// 与 install.sql 种子 sort 一致(数据运维下第 6 项)
|
||
'sort' => 6,
|
||
// 只读诊断页,没有按钮权限
|
||
'buttons' => [],
|
||
],
|
||
'framework' => [
|
||
'title' => '框架升级',
|
||
'route' => 'framework/index',
|
||
'icon' => 'layui-icon layui-icon-refresh',
|
||
'group' => 'system',
|
||
// 与 install.sql 种子 sort 一致(系统管理下第 6 项)
|
||
'sort' => 6,
|
||
// 控制台页也用这两个动作(检查更新 / 立即升级),
|
||
// 老库菜单已存在 ⇒ 这里只补按钮权限节点。
|
||
'buttons' => [
|
||
'check' => '检查更新',
|
||
'upgrade' => '执行升级',
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 补录单个菜单 + 其按钮节点
|
||
*
|
||
* @param array $menu
|
||
* @param int $pid 「系统管理」目录节点 ID
|
||
* @return void
|
||
*/
|
||
protected static function ensureMenu(array $menu, int $pid): void
|
||
{
|
||
$name = $menu['route'];
|
||
$name = explode('/', $name)[0] . ':index';
|
||
|
||
$prefix = explode(':', $name)[0];
|
||
$exists = Db::name('backend_power')->where('name', $name)->find();
|
||
if ($exists) {
|
||
// 菜单已存在(含手工调整过的排序 / 改名):菜单本身不动,
|
||
// 但按钮节点要继续补,否则后来新增的按钮(如导出)在老库上永远没有权限节点。
|
||
self::ensureButtons((int) $exists['id'], $prefix, $menu['buttons']);
|
||
return;
|
||
}
|
||
|
||
$now = time();
|
||
$id = (int) Db::name('backend_power')->insertGetId([
|
||
'pid' => $pid,
|
||
'title' => $menu['title'],
|
||
'name' => $name,
|
||
'type' => 2,
|
||
'route' => $menu['route'],
|
||
'icon' => $menu['icon'],
|
||
'sort' => (int) ($menu['sort'] ?? 100),
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
|
||
self::ensureButtons($id, $prefix, $menu['buttons'], $now);
|
||
}
|
||
|
||
/**
|
||
* 补录按钮节点(幂等:只补缺失的,已存在的不动)
|
||
*
|
||
* @param int $pid 菜单节点 ID
|
||
* @param string $prefix 权限前缀,如 datatable
|
||
* @param array $buttons [动作 => 中文名]
|
||
*/
|
||
protected static function ensureButtons(int $pid, string $prefix, array $buttons, int $now = 0): void
|
||
{
|
||
if ($pid <= 0 || ! $buttons) {
|
||
return;
|
||
}
|
||
$now = $now ?: time();
|
||
|
||
foreach ($buttons as $act => $label) {
|
||
$node = $prefix . ':' . $act;
|
||
if (Db::name('backend_power')->where('name', $node)->find()) {
|
||
continue; // 已存在(可能手工改过名),不再覆盖
|
||
}
|
||
Db::name('backend_power')->insert([
|
||
'pid' => $pid,
|
||
'title' => $label,
|
||
'name' => $node,
|
||
'type' => 3,
|
||
'route' => $prefix . '/' . $act,
|
||
'icon' => '',
|
||
'sort' => 1,
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
}
|
||
}
|
||
}
|