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