Files

590 lines
23 KiB
PHP
Raw Permalink 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 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;
}
}