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

+456
View File
@@ -0,0 +1,456 @@
<?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;
}
}