Files
YwxAppThink/app/backend/library/MenuRegistry.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

560 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace 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,
]);
}
}
}