// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\appmall\controller\api; use think\facade\Config; use think\facade\Db; use think\facade\Request; /** * 主框架升级服务 API * * 契约对齐客户端 ywxapp\service\FrameworkService: * - GET /api/framework/version 返回最新版本 + 历史版本(客户端检测更新) * - GET /api/framework/download 下载核心包 zip(可选签名校验) * 原 upgrade 插件功能,已合并进 market(应用服务中心)。 */ class Framework { /** * 版本列表 / 最新版本 */ public function version() { $this->ensureColumns(); try { $rows = Db::name('appmall_framework_list') ->where('status', 1) ->order('id', 'desc') ->limit(20) ->field('id,version,title,changelog,type,from_version,patch_from,file_path,patch_path,install_path,create_at') ->select() ->toArray(); } catch (\Exception $e) { return json(['code' => 0, 'msg' => '读取版本列表失败:' . $e->getMessage()]); } $latest = $rows[0] ?? null; $hasFull = !empty($latest['file_path']); $hasPatch = !empty($latest['patch_path']); $hasInstall = !empty($latest['install_path'] ?? ''); $patchFrom = $latest['patch_from'] ?? ''; if ($patchFrom === '' && $hasPatch) { $patchFrom = $latest['from_version'] ?? ''; } // 不向客户端泄露物理路径 foreach ($rows as &$r) { $r['has_full'] = !empty($r['file_path']); $r['has_patch'] = !empty($r['patch_path']); $r['has_install'] = !empty($r['install_path']); unset($r['file_path'], $r['patch_path'], $r['install_path']); } unset($r); return json([ 'code' => 1, 'msg' => 'success', 'data' => [ 'version' => $latest['version'] ?? '', 'title' => $latest['title'] ?? '', 'changelog' => $latest['changelog'] ?? '', // 兼容旧客户端:type 只表达「整包缺失时才是纯补丁」 'type' => $hasFull ? 0 : ($hasPatch ? 1 : (int) ($latest['type'] ?? 0)), 'from_version'=> $patchFrom, // 新协议:整包与补丁可同版本共存,客户端按自身版本择优 'has_full' => $hasFull, 'has_patch' => $hasPatch, 'has_install' => $hasInstall, 'patch_from' => $patchFrom, 'release_at' => $latest['create_at'] ?? 0, 'history' => $rows, ], ]); } /** * 兼容已安装库:补齐 type / from_version / patch_* 列,并迁移旧「补丁行」数据 */ private function ensureColumns(): void { $table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list'; try { $defs = [ 'type' => "tinyint DEFAULT 0 COMMENT '0=整包 1=补丁'", 'from_version' => "varchar(20) DEFAULT '' COMMENT '补丁基础版本'", 'patch_path' => "varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径'", 'patch_hash' => "varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值'", 'patch_from' => "varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本'", 'install_path' => "varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径'", 'install_hash' => "varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值'", ]; foreach ($defs as $col => $def) { $cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'"); if (empty($cols)) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$def}"); } } Db::execute( "UPDATE `{$table}` SET patch_path = file_path, patch_hash = file_hash, patch_from = from_version," . " file_path = '', file_hash = ''" . " WHERE type = 1 AND (patch_path = '' OR patch_path IS NULL) AND file_path <> ''" ); } catch (\Exception $e) { // ignore } } /** * 下载核心包 zip * 客户端 FrameworkService::download() GET ?version= : * - 直接返回 zip 二进制(首字节非 '{')。 * 可选签名校验:config('appmall.addon_download_sign') = true 时, * 请求需带 sign=md5('framework'.version.ts.secret) & ts(5 分钟有效期)。 */ public function download() { $version = Request::param('version', ''); // 选包:type=install/2 下安装包;type=patch/1 下补丁;type=full/0 下整包;缺省整包(缺失时回退补丁/安装包) $typeRaw = (string) Request::param('type', ''); $wantPatch = in_array($typeRaw, ['patch', '1'], true); $wantInstall = in_array($typeRaw, ['install', '2'], true); if (Config::get('ywxapp.addon_download_sign', false)) { $sign = Request::param('sign', ''); $ts = (int) Request::param('ts', 0); if (!$this->verifySign($version, $ts, $sign)) { return json(['code' => 0, 'message' => '签名校验失败']); } } // 运行时自愈:已部署的中心站若早于本版本安装,可能缺下载日志表 $this->ensureTables(); $query = Db::name('appmall_framework_list')->where('status', 1); if ($version !== '') { $query->where('version', $version); } $row = $query->order('id', 'desc')->find(); if (empty($row)) { return json(['code' => 0, 'message' => '版本包不存在']); } if ($wantInstall) { $path = $row['install_path'] ?? ''; } elseif ($wantPatch) { $path = $row['patch_path'] ?? ''; } else { $path = $row['file_path'] ?? ''; // 兼容「仅补丁/仅安装包」版本行:未显式指定时整包缺失则回退 if (($path === '' || !is_file($path)) && !empty($row['patch_path'])) { $path = $row['patch_path']; $wantPatch = true; } if (($path === '' || !is_file($path)) && !empty($row['install_path'])) { $path = $row['install_path']; $wantInstall = true; } } if (empty($path) || !is_file($path)) { $msg = $wantInstall ? '该版本无完整安装包或文件缺失' : ($wantPatch ? '该版本无增量补丁或文件缺失' : '版本包不存在或文件缺失'); return json(['code' => 0, 'message' => $msg]); } // 记录下载次数与日志(列/表缺失时忽略,不影响下载) try { Db::name('appmall_framework_list')->where('id', $row['id'])->inc('download_count')->update(); Db::name('appmall_framework_download_log')->insert([ 'version' => $row['version'], 'uid' => (int) Request::param('uid', 0), 'ip' => Request::ip(), 'create_at' => time(), ]); } catch (\Exception $e) { // ignore } $downloadName = 'ywxapp-' . $row['version'] . ($wantInstall ? '-install' : ($wantPatch ? '-patch' : '')) . '.zip'; // 流式下载响应(复用公共类):避免 ThinkPHP download() 内部 file_get_contents 将整文件读入内存, // 改用分块 fread 输出并支持 Range 续传;类内部已 set_time_limit(0)。 return new \ywxapp\library\StreamZipResponse($path, $downloadName); } /** * 运行时自愈建表:保证 appmall_framework_download_log 存在(早期安装的中心站可能缺失)。 */ private function ensureTables(): void { $prefix = \ywxapp\model\BaseModel::currentPrefix(); $tables = [ 'appmall_framework_download_log' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_framework_download_log` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `version` varchar(20) NOT NULL COMMENT '下载的版本号', `uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)', `ip` varchar(45) DEFAULT '' COMMENT '下载来源IP', `create_at` int DEFAULT 0 COMMENT '下载时间', PRIMARY KEY (`id`), KEY `idx_version` (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';", ]; foreach ($tables as $t => $sql) { try { if (empty(Db::query("SHOW TABLES LIKE '{$prefix}{$t}'"))) { Db::execute($sql); } } catch (\Throwable $e) { // 忽略(如权限不足),由后续业务报错暴露 } } } /** * 下载签名校验 */ private function verifySign(string $version, int $ts, string $sign): bool { if ($sign === '' || abs(time() - $ts) > 300) { return false; } $secret = Config::get('ywxapp.addon_secret', 'ywxapp-addon-secret-change-me'); $expect = md5('framework' . $version . $ts . $secret); return hash_equals($expect, $sign); } }