chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+589
View File
@@ -0,0 +1,589 @@
<?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\service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Db;
use think\facade\Log;
/**
* 主框架在线升级客户端服务
*
* 对接中心站 upgrade 插件(/appmall/api/framework/*):
* - checkVersion() 检测是否有新版本
* - download() 下载核心包 zip 到本地临时目录
* - apply() 备份当前 ywxapp/ 核心 → 解压覆盖 → 更新版本号 → 清缓存
*
* 核心目录约定为根目录下的 ywxapp/(与 AppService 中 ADDON_PATH 同级)。
*
* @package ywxapp\service
*/
class FrameworkService
{
const CACHE_KEY = 'framework_version_check';
const CACHE_EXPIRE = 600;
/** @var Client|null */
private static $httpClient = null;
public static function instance(): self
{
return new self();
}
/**
* 核心目录(ywxapp/
*/
public function coreDir(): string
{
return root_path() . 'ywxapp' . DIRECTORY_SEPARATOR;
}
/**
* 检查升级服务器是否有更新的主框架版本
*
* @return array [
* has_update, current, latest, title, changelog, release_at, error
* ]
*/
public function checkVersion(bool $force = false): array
{
$current = Config::get('ywxapp.version', '1.0.0');
$latest = $this->fetchLatest($force);
if (empty($latest)) {
return [
'has_update' => false,
'current' => $current,
'latest' => $current,
'changelog' => '',
'error' => '无法连接升级服务器(api_url=' . Config::get('ywxapp.api_url', '') . '',
];
}
$ver = $latest['version'] ?? $current;
$hasUpdate = version_compare($ver, $current, '>');
// 新协议:整包与增量补丁可同版本共存(has_full/has_patch/patch_from)。
// 旧协议兜底:仅有 type/from_version 时,type=0 视为有整包、type=1 视为仅补丁。
$legacyType = (int) ($latest['type'] ?? 0);
$hasFull = array_key_exists('has_full', $latest ?? []) ? (bool) $latest['has_full'] : ($legacyType === 0);
$hasPatch = array_key_exists('has_patch', $latest ?? []) ? (bool) $latest['has_patch'] : ($legacyType === 1);
$patchFrom = $latest['patch_from'] ?? ($latest['from_version'] ?? '');
// 择优:正好停在补丁基础版本 → 用增量补丁省带宽;否则用整包兜底
$usePatch = $hasUpdate && $hasPatch && $patchFrom !== '' && $patchFrom === $current;
$error = '';
if ($hasUpdate && !$usePatch && !$hasFull) {
$error = '新版本 ' . $ver . ' 仅提供增量补丁(基于 ' . $patchFrom . '),当前版本 '
. $current . ' 不适用,请等待整包发布';
}
return [
'has_update' => $hasUpdate,
'current' => $current,
'latest' => $ver,
'type' => $legacyType, // 兼容字段,选包请以 use_patch 为准
'from_version' => $patchFrom,
'has_full' => $hasFull,
'has_patch' => $hasPatch,
'patch_from' => $patchFrom,
'use_patch' => $usePatch,
'title' => $latest['title'] ?? '',
'changelog' => $latest['changelog'] ?? '',
'release_at' => $latest['release_at'] ?? 0,
'error' => $error,
];
}
/**
* 拉取最新版本信息(带缓存)
*/
protected function fetchLatest(bool $force): ?array
{
if (!$force) {
$cached = Cache::get(self::CACHE_KEY);
if ($cached !== null) {
return $cached;
}
}
try {
$client = $this->getClient();
$query = ['query' => ['current' => Config::get('ywxapp.version', '1.0.0')]];
// 统一基址约定:/appmall/api/framework/*(注册于插件 route/app.php 的对外 API 分支);
// 首次请求失败/响应非法时再重试一次。
try {
$resp = $client->get('/appmall/api/framework/version', $query);
$json = json_decode($resp->getBody()->getContents(), true);
} catch (TransferException $e) {
$json = null;
}
if (empty($json) || (int) ($json['code'] ?? 0) !== 1) {
$resp = $client->get('/appmall/api/framework/version', $query);
$json = json_decode($resp->getBody()->getContents(), true);
}
if (empty($json) || (int) ($json['code'] ?? 0) !== 1) {
return null;
}
$data = $json['data'] ?? [];
Cache::set(self::CACHE_KEY, $data, self::CACHE_EXPIRE);
return $data;
} catch (TransferException $e) {
Log::error('Framework version check failed: ' . $e->getMessage());
return null;
}
}
/**
* 下载指定版本主框架核心包到本地临时文件
*
* @param string $version 指定版本(留空取最新)
* @param string $type 'full'=整包(默认) 'patch'=增量补丁
* @return string 本地临时 zip 路径
* @throws \Exception
*/
public function download(string $version = '', string $type = 'full'): string
{
$isPatch = ($type === 'patch' || $type === '1');
$tmpDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR
. 'temp' . DIRECTORY_SEPARATOR;
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$tmpFile = $tmpDir . 'ywxapp-' . ($version ?: 'latest') . ($isPatch ? '-patch' : '') . '.zip';
// 取消客户端脚本执行时间限制,避免大文件下载/解压整体超时(Web 端默认 max_execution_time 通常 30s
set_time_limit(0);
$client = $this->getClient();
$query = ['type' => $isPatch ? 'patch' : 'full'];
if ($version !== '') {
$query['version'] = $version;
}
// 下载签名:与服务端 addon_download_sign 开关一致
if (Config::get('ywxapp.addon_download_sign', false)) {
$ts = time();
$secret = Config::get('ywxapp.addon_secret', '');
$query['ts'] = $ts;
$query['sign'] = md5('framework' . $version . $ts . $secret);
}
// 用 sink 流式写入临时文件(不整包入内存),并放宽超时与读取超时。
// 统一基址 /appmall/api/framework/download(插件 route/app.php 对外 API 分支);失败/404 时重试一次。
$options = [
'query' => $query,
'sink' => $tmpFile,
'timeout' => 3600,
'read_timeout' => 600,
];
try {
$resp = $client->get('/appmall/api/framework/download', $options);
} catch (TransferException $e) {
$resp = null;
}
if ($resp === null || $resp->getStatusCode() === 404) {
@unlink($tmpFile);
$resp = $client->get('/appmall/api/framework/download', $options);
}
// 服务端错误(HTTP >= 400)或返回 JSON 错误时,sink 写入的是错误内容而非 zip
if ($resp->getStatusCode() >= 400) {
$err = (string) file_get_contents($tmpFile);
@unlink($tmpFile);
throw new \Exception('下载失败(HTTP ' . $resp->getStatusCode() . '):' . mb_substr($err, 0, 200));
}
$fh = fopen($tmpFile, 'rb');
$first = $fh ? fread($fh, 1) : '';
if ($fh) {
fclose($fh);
}
if ($first === '{') {
$json = json_decode((string) file_get_contents($tmpFile), true);
@unlink($tmpFile);
throw new \Exception($json['msg'] ?? ($json['message'] ?? '下载失败'));
}
// 完整性校验:比对 Content-Length 与实际落盘字节数,不一致即判定下载被截断
$size = is_file($tmpFile) ? filesize($tmpFile) : 0;
$contentLength = (int) $resp->getHeaderLine('Content-Length');
if ($size <= 0) {
throw new \Exception('下载失败:未获取到文件内容');
}
if ($contentLength > 0 && $size !== $contentLength) {
@unlink($tmpFile);
throw new \Exception('下载文件不完整(期望 ' . $contentLength . ' 字节,实际 ' . $size . ' 字节)');
}
return $tmpFile;
}
/**
* 应用升级:备份 → 解压覆盖 → 更新版本号 → 清缓存
*
* 支持两种包 + 两种范围(由包内容自动识别):
* - 范围:包内含顶层 app/config/public/route/extend 任一 → 视为「整站包」,
* 解压到项目根目录(root_path());否则视为「核心包」(仅 ywxapp/),
* 解压到核心目录并剥离 ywxapp/ 前缀(兼容旧包)。
* - 整包(full, $type=0):备份将被覆盖的目录后整体覆盖。
* - 增量补丁(patch, $type=1):仅备份将被替换/删除的文件,覆盖变更文件,
* 并按包内 delete.list 删除已下线文件(路径相对解压根目录)。
*
* 安全:运行时/数据目录(runtime/vendor/data/public/uploads)、客户端配置
* (config/database.php、config/ywxapp.php、.env、install.lock) 一律禁止覆盖。
*
* @param string $zipFile 本地核心包路径
* @param string $newVersion 目标版本号
* @param int|string $type 0/整包 或 1/补丁
* @return array ['backup'=>string, 'version'=>string, 'mode'=>string]
* @throws \Exception
*/
public function apply(string $zipFile, string $newVersion, $type = 0): array
{
$coreDir = $this->coreDir();
if (!is_dir($coreDir)) {
throw new \Exception('核心目录不存在:' . $coreDir);
}
$isPatch = ($type === 1 || $type === 'patch');
$zip = new \ZipArchive();
if ($zip->open($zipFile) !== true) {
throw new \Exception('无法打开核心包');
}
// 判定范围:含顶层 app/config/public/route/extend 任一 → 整站包(解压到项目根);
// 否则核心包(仅 ywxapp/,解压到核心目录)。需完整扫描以收集全部顶层目录用于备份。
$isWholeSite = false;
$topDirs = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$e = $zip->getNameIndex($i);
if ($e === false) {
continue;
}
$top = explode('/', trim($e, '/'))[0] ?? '';
if ($top !== '') {
$topDirs[$top] = true;
}
}
$isWholeSite = (bool) array_intersect(array_keys($topDirs), ['app', 'config', 'public', 'route', 'extend']);
$baseDir = $isWholeSite ? rtrim(root_path(), '/\\') : (realpath($coreDir) ?: $coreDir);
// 整包:备份将被覆盖的目录
if (!$isPatch) {
$backupDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR
. 'backup' . DIRECTORY_SEPARATOR . ($isWholeSite ? 'site-' : 'ywxapp-') . date('YmdHis') . DIRECTORY_SEPARATOR;
if ($isWholeSite) {
// 仅备份包内涉及的顶层目录(如 app/config/extend/ywxapp/public/route),跳过用户上传数据
foreach (array_keys($topDirs) as $t) {
$src = root_path() . $t;
if (is_dir($src)) {
$this->copyDirectory($src, $backupDir . $t, ['uploads']);
}
}
} else {
$this->copyDirectory($coreDir, $backupDir);
}
} else {
// 补丁:先解析 delete.list(路径相对解压根目录:整站包为项目根,核心包为 ywxapp/)
$deleteList = [];
foreach (['delete.list', 'ywxapp/delete.list'] as $meta) {
$raw = $zip->getFromName($meta);
if ($raw !== false) {
foreach (explode("\n", $raw) as $line) {
$line = trim($line);
if ($line !== '' && $line[0] !== '#') {
$deleteList[] = $line;
}
}
break;
}
}
}
$backupDir = $backupDir ?? (root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR
. 'backup' . DIRECTORY_SEPARATOR . 'patch-' . date('YmdHis') . DIRECTORY_SEPARATOR);
// 解压覆盖(整站包不解前缀;核心包剥离 ywxapp/ 前缀 & 防 Zip Slip
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false) {
continue;
}
// 跳过补丁元数据文件
$norm = $isWholeSite ? ltrim($entry, '/') : ltrim(preg_replace('#^ywxapp/#', '', $entry), '/');
if ($isPatch && in_array($norm, ['delete.list', 'upgrade.json'], true)) {
continue;
}
$rel = $norm;
if ($rel === '' || $rel === false) {
continue;
}
// 保护客户端运行/数据/配置:禁止覆盖
$segs = explode('/', $rel);
$first = $segs[0] ?? '';
if (in_array($first, ['runtime', 'vendor', 'data'], true)
|| $rel === '.env' || $rel === 'install.lock'
|| ($first === 'public' && ($segs[1] ?? '') === 'uploads')
|| ($first === 'config' && in_array($segs[1] ?? '', ['database.php', 'ywxapp.php'], true))) {
continue;
}
if (substr($rel, -1) === '/') {
@mkdir($baseDir . DIRECTORY_SEPARATOR . rtrim($rel, '/'), 0755, true);
continue;
}
$target = $baseDir . DIRECTORY_SEPARATOR . $rel;
$realBase = realpath(dirname($target)) ?: dirname($target);
if (strpos(str_replace('\\', '/', $realBase), str_replace('\\', '/', $baseDir)) !== 0) {
$zip->close();
throw new \Exception('非法压缩包路径: ' . $entry);
}
// 补丁:仅备份将被替换的文件
if ($isPatch && is_file($target)) {
$this->backupSingle($target, $backupDir, $baseDir);
}
$content = $zip->getFromIndex($i);
if ($content === false) {
continue;
}
if (!is_dir(dirname($target))) {
@mkdir(dirname($target), 0755, true);
}
file_put_contents($target, $content);
}
// 补丁:删除已下线文件(先备份)
if ($isPatch && !empty($deleteList)) {
foreach ($deleteList as $rel) {
$target = $baseDir . DIRECTORY_SEPARATOR . ltrim($rel, '/');
if (is_file($target)) {
$this->backupSingle($target, $backupDir, $baseDir);
@unlink($target);
}
}
}
$zip->close();
// 数据库升级 SQL(包内 upgrade.sql,幂等执行,避免重复变更)
$this->applyUpgradeSql($newVersion);
// 更新版本号(写入 config/ywxapp.php
$this->updateConfigVersion($newVersion);
// 清缓存
try {
\ywxapp\service\AppService::clearAddonCache();
} catch (\Exception $e) {
// ignore
}
return [
'backup' => $backupDir,
'version' => $newVersion,
'mode' => $isPatch ? 'patch' : 'full',
];
}
/**
* 单文件备份(补丁模式用)
*/
protected function backupSingle(string $file, string $backupDir, string $baseDir = ''): void
{
$ref = $baseDir !== '' ? rtrim($baseDir, '/\\') : rtrim($this->coreDir(), '/\\');
$rel = ltrim(substr($file, strlen($ref)), DIRECTORY_SEPARATOR);
$dest = rtrim($backupDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $rel;
if (!is_dir(dirname($dest))) {
@mkdir(dirname($dest), 0755, true);
}
@copy($file, $dest);
}
/**
* 递归复制目录(用于备份),可按文件/目录 basename 排除(如 uploads
*/
protected function copyDirectory(string $src, string $dst, array $excludeNames = []): void
{
$src = rtrim($src, '/\\');
if (!is_dir($dst)) {
@mkdir($dst, 0755, true);
}
$it = new \RecursiveIteratorIterator(
new \RecursiveCallbackFilterIterator(
new \RecursiveDirectoryIterator($src, \RecursiveDirectoryIterator::SKIP_DOTS),
function ($file) use ($excludeNames) {
return !in_array($file->getFilename(), $excludeNames, true);
}
),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $item) {
$target = $dst . DIRECTORY_SEPARATOR . $it->getSubPathName();
if ($item->isDir()) {
if (!is_dir($target)) {
@mkdir($target, 0755, true);
}
} else {
if (!is_dir(dirname($target))) {
@mkdir(dirname($target), 0755, true);
}
copy($item->getRealPath(), $target);
}
}
}
/**
* 更新 config/ywxapp.php 的 version 值
*/
protected function updateConfigVersion(string $version): void
{
$file = config_path() . 'ywxapp.php';
if (!is_file($file)) {
return;
}
$content = file_get_contents($file);
$content = preg_replace(
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
'${1}' . $version . '${2}',
$content
);
file_put_contents($file, $content);
}
/**
* 执行数据库升级 SQL(框架在线升级的数据库变更能力)
*
* 约定:升级包内放置 ywxapp/upgrade.sql(整站包解压到项目根、核心包解压到 ywxapp/,
* 两者最终都位于核心目录 ywxapp/upgrade.sql 下)。apply() 解压完成后自动检测并执行。
* - 按语句拆分(忽略 -- 行注释与 /* *\/ 块注释)
* - 幂等:用 runtime/framework/sql_applied.json 记录已执行版本,重复升级不重复执行
* - 执行失败抛异常,由上层升级流程回滚/提示(文件已备份)
*
* @param string $newVersion 目标版本号(作为幂等键的一部分)
*/
protected function applyUpgradeSql(string $newVersion): void
{
$candidates = [
$this->coreDir() . 'upgrade.sql',
root_path() . 'upgrade.sql',
];
$sqlFile = '';
foreach ($candidates as $c) {
if (is_file($c)) {
$sqlFile = $c;
break;
}
}
if ($sqlFile === '') {
return;
}
// 幂等:同版本已应用则跳过
$applied = $this->loadAppliedSql();
$key = $newVersion . ':' . basename($sqlFile);
if (isset($applied[$key])) {
@unlink($sqlFile);
return;
}
$statements = $this->splitSql((string) file_get_contents($sqlFile));
if (empty($statements)) {
@unlink($sqlFile);
return;
}
try {
$db = Db::connect();
foreach ($statements as $sql) {
$db->execute($sql);
}
} catch (\Throwable $e) {
Log::error('Framework upgrade SQL failed: ' . $e->getMessage());
throw new \Exception('数据库升级失败:' . $e->getMessage());
}
$applied[$key] = date('Y-m-d H:i:s');
$this->saveAppliedSql($applied);
@unlink($sqlFile);
}
/**
* 将 SQL 文本按语句拆分为数组(忽略 -- 行注释与 /* *\/ 块注释)
*/
protected function splitSql(string $sql): array
{
$sql = preg_replace('#--[^\n]*#', '', $sql);
$sql = preg_replace('#/\*.*?\*/#s', '', $sql);
$parts = preg_split('/;\s*$/m', $sql);
$out = [];
foreach ($parts as $p) {
$p = trim($p);
if ($p !== '') {
$out[] = $p;
}
}
return $out;
}
/**
* 已执行的升级 SQL 记录文件(runtime/framework/sql_applied.json
*/
protected function appliedSqlFile(): string
{
return root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'sql_applied.json';
}
protected function loadAppliedSql(): array
{
$f = $this->appliedSqlFile();
if (!is_file($f)) {
return [];
}
$a = json_decode((string) file_get_contents($f), true);
return is_array($a) ? $a : [];
}
protected function saveAppliedSql(array $applied): void
{
$f = $this->appliedSqlFile();
if (!is_dir(dirname($f))) {
@mkdir(dirname($f), 0755, true);
}
file_put_contents($f, json_encode($applied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* 获取 HTTP 客户端(与 AddonService 共用 api_url / ssl_verify 配置)
*/
protected function getClient(): Client
{
if (self::$httpClient === null) {
self::$httpClient = new Client([
'base_uri' => rtrim(Config::get('ywxapp.api_url', 'https://api.ywxapp.cn'), '/'),
'timeout' => 60,
'connect_timeout' => 30,
'verify' => (bool) Config::get('appmall.ssl_verify', true),
'http_errors' => false,
'headers' => [
'X-REQUESTED-WITH' => 'XMLHttpRequest',
'User-Agent' => 'YwxappFramework/' . (Config::get('ywxapp.version') ?? '1.0.0'),
],
]);
}
return self::$httpClient;
}
/**
* 重置 HTTP 客户端(配置变更后调用)
*/
public static function resetHttpClient(): void
{
self::$httpClient = null;
}
}