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

+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];
}
}