安全(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(市场 / 开发者中心 / 审核 / 分润)
457 lines
14 KiB
PHP
457 lines
14 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>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare (strict_types = 1);
|
||
|
||
namespace ywxapp\library;
|
||
|
||
use think\facade\Db;
|
||
|
||
/**
|
||
* 数据库备份 / 还原(纯 PHP 实现)
|
||
*
|
||
* 运行环境禁用了 exec / shell_exec,无法调用 mysqldump、mysql 命令行,
|
||
* 因此这里全部走 PDO:
|
||
* 备份:SHOW CREATE TABLE 导出结构 + 分块 SELECT 生成 INSERT,边生成边写文件(支持分卷)
|
||
* 还原:流式读取 .sql,按分号切句逐条执行
|
||
* 优化:OPTIMIZE TABLE
|
||
*
|
||
* 备份落地结构:runtime/backup/<备份名>/<备份名>-1.sql、<备份名>-2.sql …
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class DbBackup
|
||
{
|
||
/**
|
||
* 备份根目录
|
||
*/
|
||
protected string $dir;
|
||
|
||
/**
|
||
* 单卷最大字节数
|
||
*/
|
||
protected int $partSize;
|
||
|
||
/**
|
||
* 每次读取的数据行数
|
||
*/
|
||
protected int $chunk;
|
||
|
||
/**
|
||
* @param string|null $dir 备份根目录,默认 runtime/backup
|
||
* @param int $partSize 单卷字节数,默认 2MB
|
||
* @param int $chunk 每次取数行数
|
||
*/
|
||
public function __construct(?string $dir = null, int $partSize = 2097152, int $chunk = 500)
|
||
{
|
||
$this->dir = $dir ?: runtime_path() . 'backup';
|
||
$this->partSize = max(65536, $partSize);
|
||
$this->chunk = max(50, $chunk);
|
||
}
|
||
|
||
/**
|
||
* 备份根目录
|
||
*/
|
||
public function dir(): string
|
||
{
|
||
return $this->dir;
|
||
}
|
||
|
||
/**
|
||
* 数据表清单(不含视图)
|
||
*
|
||
* @return array{name:string,rows:int,size:int,comment:string,engine:string}[]
|
||
*/
|
||
public function tables(): array
|
||
{
|
||
$prefix = $this->prefix();
|
||
try {
|
||
$rows = Db::query('SHOW TABLE STATUS');
|
||
} catch (\Throwable $e) {
|
||
return [];
|
||
}
|
||
$list = [];
|
||
foreach ($rows as $row) {
|
||
$row = array_change_key_case($row, CASE_LOWER);
|
||
$list[] = [
|
||
'name' => $row['name'] ?? '',
|
||
'rows' => (int) ($row['rows'] ?? 0),
|
||
'size' => (int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0)),
|
||
'comment' => (string) ($row['comment'] ?? ''),
|
||
'engine' => (string) ($row['engine'] ?? ''),
|
||
'prefix' => $prefix,
|
||
];
|
||
}
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 执行备份
|
||
*
|
||
* @param array $tables 表名(不带前缀也可);空数组=全部表
|
||
* @param string|null $name 备份名,默认按时间生成
|
||
* @return array{name:string,files:array,size:int,tables:int,rows:int}
|
||
*/
|
||
public function backup(array $tables = [], ?string $name = null): array
|
||
{
|
||
$all = $this->tables();
|
||
$map = [];
|
||
foreach ($all as $t) {
|
||
$map[$t['name']] = $t;
|
||
}
|
||
// 允许用户传不带前缀的表名
|
||
$chosen = [];
|
||
foreach ($tables as $t) {
|
||
$t = (string) $t;
|
||
if (isset($map[$t])) {
|
||
$chosen[] = $t;
|
||
} else {
|
||
$full = $this->prefix() . $t;
|
||
if (isset($map[$full])) {
|
||
$chosen[] = $full;
|
||
}
|
||
}
|
||
}
|
||
if ($chosen === []) {
|
||
$chosen = array_column($all, 'name');
|
||
}
|
||
|
||
$name = $name ?: date('Ymd_His') . '_' . substr(md5(uniqid('', true)), 0, 6);
|
||
$name = preg_replace('/[^a-zA-Z0-9_\-]/', '', $name) ?: date('Ymd_His');
|
||
$dir = $this->dir . DIRECTORY_SEPARATOR . $name;
|
||
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||
throw new \RuntimeException('备份目录不可写:' . $dir);
|
||
}
|
||
|
||
$files = [];
|
||
$part = 1;
|
||
$size = 0;
|
||
$rows = 0;
|
||
$fh = null;
|
||
$cur = '';
|
||
|
||
$open = function () use (&$fh, &$part, &$cur, &$files, $dir, $name): void {
|
||
$cur = $dir . DIRECTORY_SEPARATOR . $name . '-' . $part . '.sql';
|
||
$fh = fopen($cur, 'wb');
|
||
if ($fh === false) {
|
||
throw new \RuntimeException('无法写入备份文件:' . $cur);
|
||
}
|
||
fwrite($fh, $this->header($part));
|
||
$files[] = basename($cur);
|
||
};
|
||
// 写入并保证分卷:超出 partSize 就换卷
|
||
$write = function (string $sql) use (&$fh, &$part, &$size, $open): void {
|
||
if ($fh === null) {
|
||
$open();
|
||
}
|
||
$len = strlen($sql);
|
||
if ($this->currentSize($fh) > 0 && $this->currentSize($fh) + $len > $this->partSize) {
|
||
fclose($fh);
|
||
$part++;
|
||
$open();
|
||
}
|
||
fwrite($fh, $sql);
|
||
};
|
||
|
||
try {
|
||
foreach ($chosen as $table) {
|
||
// 1) 表结构
|
||
$create = Db::query('SHOW CREATE TABLE `' . $table . '`');
|
||
$create = array_change_key_case($create[0] ?? [], CASE_LOWER);
|
||
if (empty($create['create table'])) {
|
||
continue;
|
||
}
|
||
$write("-- -----------------------------\n");
|
||
$write("-- 表结构:`{$table}`\n");
|
||
$write("-- -----------------------------\n");
|
||
$write("DROP TABLE IF EXISTS `{$table}`;\n");
|
||
$write($create['create table'] . ";\n\n");
|
||
|
||
// 2) 数据:分块读取,避免大表撑爆内存
|
||
$offset = 0;
|
||
$hasField = false;
|
||
while (true) {
|
||
$data = Db::query('SELECT * FROM `' . $table . '` LIMIT ' . $offset . ',' . $this->chunk);
|
||
if (empty($data)) {
|
||
break;
|
||
}
|
||
if (!$hasField) {
|
||
$write("-- -----------------------------\n");
|
||
$write("-- 数据:`{$table}`\n");
|
||
$write("-- -----------------------------\n");
|
||
$hasField = true;
|
||
}
|
||
$values = [];
|
||
foreach ($data as $row) {
|
||
$vals = [];
|
||
foreach ($row as $v) {
|
||
$vals[] = $this->quote($v);
|
||
}
|
||
$values[] = '(' . implode(', ', $vals) . ')';
|
||
}
|
||
$write("INSERT INTO `{$table}` VALUES\n" . implode(",\n", $values) . ";\n");
|
||
$rows += count($data);
|
||
$offset += $this->chunk;
|
||
if (count($data) < $this->chunk) {
|
||
break;
|
||
}
|
||
}
|
||
$write("\n");
|
||
}
|
||
$write("SET FOREIGN_KEY_CHECKS=1;\n");
|
||
$write("-- 备份结束 " . date('Y-m-d H:i:s') . "\n");
|
||
} finally {
|
||
if (is_resource($fh)) {
|
||
fclose($fh);
|
||
}
|
||
}
|
||
|
||
foreach ($files as $f) {
|
||
$size += (int) filesize($dir . DIRECTORY_SEPARATOR . $f);
|
||
}
|
||
|
||
return [
|
||
'name' => $name,
|
||
'files' => $files,
|
||
'size' => $size,
|
||
'tables' => count($chosen),
|
||
'rows' => $rows,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 备份历史(按备份名聚合,多分卷合并为一条)
|
||
*
|
||
* @return array{name:string,files:array,size:int,time:int,parts:int}[]
|
||
*/
|
||
public function backups(): array
|
||
{
|
||
if (!is_dir($this->dir)) {
|
||
return [];
|
||
}
|
||
$list = [];
|
||
foreach (glob($this->dir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||
$name = basename($dir);
|
||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: [];
|
||
if (!$files) {
|
||
continue;
|
||
}
|
||
sort($files);
|
||
$size = 0;
|
||
$time = 0;
|
||
$names = [];
|
||
foreach ($files as $f) {
|
||
$size += (int) filesize($f);
|
||
$time = max($time, (int) filemtime($f));
|
||
$names[] = basename($f);
|
||
}
|
||
$list[] = [
|
||
'name' => $name,
|
||
'files' => $names,
|
||
'parts' => count($names),
|
||
'size' => $size,
|
||
'time' => $time,
|
||
];
|
||
}
|
||
usort($list, fn($a, $b) => $b['time'] <=> $a['time']);
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 还原备份
|
||
*
|
||
* @return array{files:int,sql:int}
|
||
*/
|
||
public function restore(string $name): array
|
||
{
|
||
$dir = $this->safePath($name);
|
||
if ($dir === null || !is_dir($dir)) {
|
||
throw new \RuntimeException('备份不存在:' . $name);
|
||
}
|
||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: [];
|
||
sort($files);
|
||
|
||
$filesCount = 0;
|
||
$sqlCount = 0;
|
||
foreach ($files as $file) {
|
||
$filesCount++;
|
||
$sqlCount += $this->executeFile($file);
|
||
}
|
||
return ['files' => $filesCount, 'sql' => $sqlCount];
|
||
}
|
||
|
||
/**
|
||
* 删除备份
|
||
*/
|
||
public function delete(string $name): bool
|
||
{
|
||
$dir = $this->safePath($name);
|
||
if ($dir === null || !is_dir($dir)) {
|
||
return false;
|
||
}
|
||
foreach (glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: [] as $f) {
|
||
@unlink($f);
|
||
}
|
||
return @rmdir($dir);
|
||
}
|
||
|
||
/**
|
||
* 备份文件绝对路径(下载用),非法名返回 null
|
||
*/
|
||
public function fileOf(string $name, string $file = ''): ?string
|
||
{
|
||
$dir = $this->safePath($name);
|
||
if ($dir === null || !is_dir($dir)) {
|
||
return null;
|
||
}
|
||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: [];
|
||
sort($files);
|
||
if ($file !== '') {
|
||
$target = $dir . DIRECTORY_SEPARATOR . basename($file);
|
||
return is_file($target) ? $target : null;
|
||
}
|
||
return $files[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 优化表
|
||
*
|
||
* @return array{table:string,result:string}[]
|
||
*/
|
||
public function optimize(array $tables): array
|
||
{
|
||
$out = [];
|
||
foreach ($tables as $table) {
|
||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $table) ?: '';
|
||
if ($table === '') {
|
||
continue;
|
||
}
|
||
try {
|
||
Db::execute('OPTIMIZE TABLE `' . $table . '`');
|
||
$out[] = ['table' => $table, 'result' => 'ok'];
|
||
} catch (\Throwable $e) {
|
||
$out[] = ['table' => $table, 'result' => $e->getMessage()];
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/* ============================ 内部方法 ============================ */
|
||
|
||
/**
|
||
* 备份目录(防路径穿越:只允许 [a-zA-Z0-9_-])
|
||
*/
|
||
protected function safePath(string $name): ?string
|
||
{
|
||
$name = basename($name);
|
||
if ($name === '' || preg_match('/[^a-zA-Z0-9_\-]/', $name)) {
|
||
return null;
|
||
}
|
||
return $this->dir . DIRECTORY_SEPARATOR . $name;
|
||
}
|
||
|
||
/**
|
||
* 分卷文件头
|
||
*/
|
||
protected function header(int $part): string
|
||
{
|
||
return "-- YwxApp 数据库备份\n"
|
||
. "-- 生成时间:" . date('Y-m-d H:i:s') . "\n"
|
||
. "-- 分卷:第 {$part} 卷\n"
|
||
. "SET NAMES utf8mb4;\n"
|
||
. "SET FOREIGN_KEY_CHECKS=0;\n"
|
||
. "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n\n";
|
||
}
|
||
|
||
/**
|
||
* 当前分卷已写字节数
|
||
*
|
||
* @param resource $fh
|
||
*/
|
||
protected function currentSize($fh): int
|
||
{
|
||
return is_resource($fh) ? (int) ftell($fh) : 0;
|
||
}
|
||
|
||
/**
|
||
* 值转义:NULL / 数字 / 二进制 / 字符串
|
||
*/
|
||
protected function quote(mixed $value): string
|
||
{
|
||
if ($value === null) {
|
||
return 'NULL';
|
||
}
|
||
if (is_bool($value)) {
|
||
return $value ? '1' : '0';
|
||
}
|
||
if (is_int($value) || is_float($value)) {
|
||
return (string) $value;
|
||
}
|
||
$value = (string) $value;
|
||
// 含控制字符的按二进制处理,避免写入时截断
|
||
if ($value !== '' && preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value)) {
|
||
return '0x' . bin2hex($value);
|
||
}
|
||
return $this->pdoQuote($value);
|
||
}
|
||
|
||
protected function pdoQuote(string $value): string
|
||
{
|
||
try {
|
||
$pdo = Db::connect()->getPdo();
|
||
$q = $pdo->quote($value);
|
||
return $q === false ? "'" . addslashes($value) . "'" : $q;
|
||
} catch (\Throwable $e) {
|
||
return "'" . addslashes($value) . "'";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 流式执行一个 SQL 文件
|
||
*/
|
||
protected function executeFile(string $file): int
|
||
{
|
||
$fh = fopen($file, 'rb');
|
||
if ($fh === false) {
|
||
throw new \RuntimeException('无法读取:' . $file);
|
||
}
|
||
$count = 0;
|
||
$buffer = '';
|
||
while (($line = fgets($fh)) !== false) {
|
||
$line = rtrim($line, "\r\n");
|
||
$trim = ltrim($line);
|
||
if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#') || str_starts_with($trim, '/*')) {
|
||
continue;
|
||
}
|
||
// 存储过程/触发器依赖 DELIMITER,本实现不支持,直接跳过声明行
|
||
if (str_starts_with(strtoupper($trim), 'DELIMITER')) {
|
||
continue;
|
||
}
|
||
$buffer .= $line . "\n";
|
||
if (substr($trim, -1) === ';' && !str_ends_with($trim, '\\;')) {
|
||
$sql = rtrim(rtrim($buffer), ';');
|
||
$buffer = '';
|
||
if ($sql === '') {
|
||
continue;
|
||
}
|
||
Db::execute($sql);
|
||
$count++;
|
||
}
|
||
}
|
||
fclose($fh);
|
||
return $count;
|
||
}
|
||
|
||
protected function prefix(): string
|
||
{
|
||
$prefix = (string) config('database.connections.mysql.prefix', '');
|
||
return $prefix;
|
||
}
|
||
}
|