Files
YwxAppThink/ywxapp/library/Menu.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

285 lines
9.4 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>
// +----------------------------------------------------------------------
namespace ywxapp\library;
// use fast\Tree;
use think\Exception;
use think\facade\Db;
use think\facade\Log;
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)
{
// 防护①:插件名为空时直接拒绝。否则下面的 name LIKE ':%' 会命中
// 所有 addon 为空的【核心后台菜单】,造成后台菜单与权限节点被清空。
if ($name === '' || $name === null) {
Log::error('[Menu] 拒绝删除菜单:插件名为空(会误删核心后台菜单)');
return false;
}
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
// 防护②:只删本插件节点(addon 列匹配 或 name 为「插件名:」前缀,兼容历史数据 addon 为空),
// 不依赖上游 $ids 的正确性
$rows = BackendPower::where('id', 'in', $ids)
->where(function ($q) use ($name) {
$q->where('addon', $name)->whereOr('name', 'like', $name . ':%');
})
->select();
if ($rows->isEmpty()) {
return false;
}
// 防护③:删除前落快照日志,误删后可按日志审计/恢复
Log::warning('[Menu] 删除插件菜单', [
'addon' => $name,
'count' => count($rows),
'snapshot' => $rows->toArray(),
]);
BackendPower::destroy($rows->column('id'));
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];
// 防护①:强制限定只删本插件的节点。核心后台菜单 addon 为空,
// 一旦条件意外放宽就会连核心菜单一起删掉(后台菜单与权限节点丢失),
// 故此处固定加上 addon 限定,不依赖上游 $ids 的正确性。
$where[] = ['addon', '=', $name];
if ($menus) {
//必须是旧版本中的菜单,可排除用户自主创建的菜单
$where[] = ['name', 'in', $menus];
}
// 防护②:删除前落快照日志,误删后可按日志审计/恢复
$removing = BackendPower::where($where)->select()->toArray();
if ($removing) {
Log::warning('[Menu] 插件菜单升级:删除旧菜单节点', [
'addon' => $name,
'count' => count($removing),
'snapshot' => $removing,
]);
}
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;
}
}