Files
YwxAppThink/app/backend/library/SystemInfo.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

256 lines
8.6 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\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];
}
}