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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,559 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?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\library\AdminLogger;
|
||||
use ywxapp\library\DbBackup;
|
||||
use ywxapp\model\CommonAttachment;
|
||||
|
||||
/**
|
||||
* 系统信息采集与缓存清理
|
||||
*
|
||||
* 从 System 控制器里抽出来,供「系统信息」页与「控制台」首页共用一份数据,
|
||||
* 避免同一套巡检逻辑在两个控制器里各写一遍(改一处、忘一处)。
|
||||
*
|
||||
* 只读采集为主;唯一的写操作 cleanRuntime() 只删 runtime/cache 与 runtime/temp,
|
||||
* 绝不碰 runtime/log(审计日志要留着)与业务文件。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class SystemInfo
|
||||
{
|
||||
/**
|
||||
* 采集环境 / 目录 / 磁盘 / 统计信息
|
||||
*
|
||||
* 任何一项失败都不抛异常(巡检页不能因为一张表缺失就 500)。
|
||||
*
|
||||
* @return array{env:array,dirs:array,disk:array,stat:array}
|
||||
*/
|
||||
public static function collect(): array
|
||||
{
|
||||
$root = app()->getRootPath();
|
||||
|
||||
/* ---------------- 运行环境 ---------------- */
|
||||
$mysql = '-';
|
||||
try {
|
||||
$ver = Db::query('SELECT VERSION() AS v');
|
||||
$mysql = $ver[0]['v'] ?? '-';
|
||||
} catch (\Throwable $e) {
|
||||
$mysql = '读取失败';
|
||||
}
|
||||
|
||||
$disable = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
|
||||
$env = [
|
||||
['YwxApp 版本', (string) config('ywxapp.version', '-')],
|
||||
['ThinkPHP 版本', app()->version()],
|
||||
['PHP 版本', PHP_VERSION . '(' . PHP_SAPI . ')'],
|
||||
['MySQL 版本', $mysql],
|
||||
['操作系统', PHP_OS . ' ' . php_uname('r')],
|
||||
['服务器时间', date('Y-m-d H:i:s') . '(' . (string) config('app.default_timezone', date_default_timezone_get()) . ')'],
|
||||
['上传限制', ini_get('upload_max_filesize') . ' / POST ' . ini_get('post_max_size')],
|
||||
['内存限制', ini_get('memory_limit')],
|
||||
['脚本超时', ini_get('max_execution_time') . ' 秒'],
|
||||
['禁用函数', $disable ? implode(', ', array_slice($disable, 0, 6)) . (count($disable) > 6 ? ' …' : '') : '无'],
|
||||
];
|
||||
|
||||
/* ---------------- 目录可写 ---------------- */
|
||||
$dirs = [
|
||||
'runtime' => $root . 'runtime',
|
||||
'public/storage' => public_path() . 'storage',
|
||||
'config' => $root . 'config',
|
||||
'addon' => $root . 'addon',
|
||||
];
|
||||
$dirList = [];
|
||||
foreach ($dirs as $label => $path) {
|
||||
$dirList[] = [
|
||||
'name' => $label,
|
||||
'exists' => is_dir($path),
|
||||
'writable' => is_dir($path) && is_writable($path),
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------- 磁盘 ---------------- */
|
||||
$diskTotal = @disk_total_space($root) ?: 0;
|
||||
$diskFree = @disk_free_space($root) ?: 0;
|
||||
$disk = [
|
||||
'total' => $diskTotal,
|
||||
'free' => $diskFree,
|
||||
'used' => $diskTotal - $diskFree,
|
||||
'total_text' => self::formatBytes($diskTotal),
|
||||
'free_text' => self::formatBytes($diskFree),
|
||||
'used_text' => self::formatBytes($diskTotal - $diskFree),
|
||||
'percent' => $diskTotal > 0 ? round(($diskTotal - $diskFree) / $diskTotal * 100, 1) : 0,
|
||||
];
|
||||
|
||||
/* ---------------- 运行统计 ---------------- */
|
||||
$dbSize = 0;
|
||||
try {
|
||||
foreach ((new DbBackup())->tables() as $t) {
|
||||
$dbSize += (int) ($t['size'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 统计失败不阻断
|
||||
}
|
||||
$attachSize = 0;
|
||||
try {
|
||||
$attachSize = (int) CommonAttachment::where('delete_at', 0)->sum('size');
|
||||
} catch (\Throwable $e) {
|
||||
$attachSize = 0;
|
||||
}
|
||||
|
||||
$stat = [
|
||||
'admin' => self::safeCount('backend_admin'),
|
||||
'attachment' => self::safeCount('common_attachment', function ($q) {
|
||||
$q->where('delete_at', 0);
|
||||
}),
|
||||
'attach_size' => $attachSize,
|
||||
'attach_size_text' => self::formatBytes($attachSize),
|
||||
'log_week' => self::safeCount('backend_log', function ($q) {
|
||||
$q->where('create_at', '>', time() - 7 * 86400);
|
||||
}),
|
||||
'login_fail' => self::safeCount('backend_log', function ($q) {
|
||||
$q->where('action', '登录失败')->where('create_at', '>', time() - 7 * 86400);
|
||||
}),
|
||||
'db_size' => $dbSize,
|
||||
'db_size_text' => self::formatBytes($dbSize),
|
||||
'addon' => count(self::addonDirs()),
|
||||
];
|
||||
|
||||
return [
|
||||
'env' => $env,
|
||||
'dirs' => $dirList,
|
||||
'disk' => $disk,
|
||||
'stat' => $stat,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 runtime 缓存:cache / temp / all(不动 log)
|
||||
*
|
||||
* @param string $type
|
||||
* @return int 删除项数
|
||||
* @throws \InvalidArgumentException 未知类型
|
||||
*/
|
||||
public static function cleanRuntime(string $type): int
|
||||
{
|
||||
$targets = [
|
||||
'cache' => ['cache'], // 数据缓存
|
||||
'temp' => ['temp'], // 模板编译缓存
|
||||
'all' => ['cache', 'temp'],
|
||||
];
|
||||
if (! isset($targets[$type])) {
|
||||
throw new \InvalidArgumentException('未知清理类型:' . $type);
|
||||
}
|
||||
|
||||
$root = runtime_path();
|
||||
$files = 0;
|
||||
foreach ($targets[$type] as $dir) {
|
||||
$files += self::clearDir($root . $dir);
|
||||
}
|
||||
|
||||
// 路由缓存文件在 runtime 根目录,清 all 时一并删掉(下次请求自动重建)
|
||||
if ($type === 'all') {
|
||||
$routeCache = $root . 'route_list.php';
|
||||
if (is_file($routeCache)) {
|
||||
@unlink($routeCache);
|
||||
$files++;
|
||||
}
|
||||
}
|
||||
|
||||
AdminLogger::write('console/clean', '清理类型:' . $type . ',删除 ' . $files . ' 项');
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全计数:表缺失 / 查询异常时返回 0,不阻断巡检页
|
||||
*
|
||||
* @param string $table
|
||||
* @param callable|null $where
|
||||
* @return int
|
||||
*/
|
||||
public static function safeCount(string $table, callable $where = null): int
|
||||
{
|
||||
try {
|
||||
$q = Db::name($table);
|
||||
if ($where) {
|
||||
$where($q);
|
||||
}
|
||||
return (int) $q->count();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件目录清单(一级目录即插件名)
|
||||
* @return array
|
||||
*/
|
||||
public static function addonDirs(): array
|
||||
{
|
||||
$base = app()->getRootPath() . 'addon';
|
||||
if (! is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
$dirs = glob($base . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [];
|
||||
return array_map('basename', $dirs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录下的内容(保留目录本身)
|
||||
*
|
||||
* @param string $dir
|
||||
* @return int 删除项数
|
||||
*/
|
||||
protected static function clearDir(string $dir): int
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
if ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
} else {
|
||||
@unlink($item->getPathname());
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数格式化
|
||||
*
|
||||
* 注意:disk_total_space() 返回 float,参数必须收 float,
|
||||
* 否则严格类型下直接 TypeError(页面 500)。
|
||||
*
|
||||
* @param float $bytes
|
||||
* @return string
|
||||
*/
|
||||
public static function formatBytes(float $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user