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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+559
View File
@@ -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,
]);
}
}
}
+255
View File
@@ -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];
}
}