// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\appmall\controller\backend; use think\facade\Db; use think\facade\View; use Exception; use ywxapp\controller\BackendBase; use ywxapp\library\StreamZipResponse; /** * 主框架版本管理(运营后台,中心站) * * 上传核心包 zip → 入库(草稿)→ 发布(status=1)→ 客户端可见可下载。 * 原 upgrade 插件功能,已合并进 market(应用服务中心)。 */ class Framework extends BackendBase { /** * 版本列表 / 页面 */ public function index() { if ($this->request->isAjax()) { $this->ensurePatchColumns(); $status = $this->request->param('status', ''); $query = Db::name('appmall_framework_list'); if ($status !== '') { $query->where('status', (int) $status); } $list = $query->order('id', 'desc') ->paginate([ 'page' => (int) $this->request->param('page', 1), 'list_rows' => (int) $this->request->param('limit', 10), ]); $this->result->setCount($list->total())->success($list->items(), '获取成功'); } return View::fetch('framework/index'); } /** * 上传并登记一个主框架核心包(zip),默认草稿态 * * 同一版本号对应一行记录:整包(type=0)写 file_path/file_hash, * 增量补丁(type=1)写 patch_path/patch_hash/patch_from,二者可先后上传共存。 */ public function upload() { try { $this->ensurePatchColumns(); $version = trim(input('version', '')); $title = trim(input('title', '')); $changelog = input('changelog', ''); $type = (int) input('type', 0); // 0=整包 1=补丁 2=完整安装包 $fromVer = trim(input('from_version', '')); if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) { return $this->result->error('版本号格式不正确(需 x.y.z)'); } if ($type === 1 && $fromVer === '') { return $this->result->error('增量补丁必须填写「基础版本」'); } if ($type === 1 && !preg_match('/^\d+\.\d+\.\d+$/', $fromVer)) { return $this->result->error('基础版本号格式不正确(需 x.y.z)'); } $file = $this->request->file('file'); if (empty($file)) { return $this->result->error('请上传核心包 zip'); } if (strtolower(substr($file->getOriginalName(), -4)) !== '.zip') { return $this->result->error('仅支持 zip 格式'); } // 整包需校验 ywxapp 核心结构;补丁只校验为合法 zip(含变更文件即可) $tmp = $file->getRealPath(); $zip = new \ZipArchive(); if ($zip->open($tmp) !== true) { return $this->result->error('zip 包无法打开'); } if ($type === 0) { $hasCore = false; for ($i = 0; $i < $zip->numFiles; $i++) { $nm = $zip->statIndex($i)['name']; if (preg_match('#^(ywxapp/)?(service|controller|library|traits)/#', $nm)) { $hasCore = true; break; } } $zip->close(); if (!$hasCore) { return $this->result->error('核心包结构不正确:应包含 ywxapp 核心目录(service/controller/...)'); } } elseif ($type === 2) { // 完整安装包:必须含 config/ 与占位符 .env,且不得夹带 runtime/vendor/data 或真实 .env.* $hasConfig = false; $hasEnv = false; $bad = false; for ($i = 0; $i < $zip->numFiles; $i++) { $nm = $zip->statIndex($i)['name']; if ($nm === 'config' || strpos($nm, 'config/') === 0) { $hasConfig = true; } if ($nm === '.env') { $hasEnv = true; } if (preg_match('#^(runtime/|vendor/|data/|\.env\.)#', $nm)) { $bad = true; break; } } $zip->close(); if ($bad) { return $this->result->error('安装包结构不正确:不应包含 runtime/vendor/data 或真实 .env.* 文件'); } if (!$hasConfig || !$hasEnv) { return $this->result->error('安装包结构不正确:必须包含 config/ 目录与 .env 占位符'); } } else { $zip->close(); } $dir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $saveName = 'ywxapp-' . $version . ($type === 1 ? '-patch' : ($type === 2 ? '-install' : '')) . '.zip'; $file->move($dir, $saveName); $absPath = realpath($dir . $saveName); if (!$absPath || !is_file($absPath)) { return $this->result->error('核心包保存失败'); } $hash = hash_file('sha256', $absPath); $exist = Db::name('appmall_framework_list')->where('version', $version)->find(); // 公共字段:标题/日志仅在填写时覆盖,避免后传的包把先前信息清空 $data = [ 'version' => $version, 'status' => 0, // 重新上传任一包均回到草稿,需再次发布 'update_at' => time(), ]; if ($title !== '' || !$exist) { $data['title'] = $title ?: ('主框架 ' . $version); } if (trim((string) $changelog) !== '' || !$exist) { $data['changelog'] = $changelog; } if ($type === 1) { // 增量补丁:只更新 patch 字段,不动整包 $data['patch_path'] = $absPath; $data['patch_hash'] = $hash; $data['patch_from'] = $fromVer; $data['from_version'] = $fromVer; // 兼容旧客户端镜像 } elseif ($type === 2) { // 完整安装包:只更新 install 字段,与整包/补丁独立共存 $data['install_path'] = $absPath; $data['install_hash'] = $hash; } else { // 整包:只更新整包字段,不动补丁 $data['file_path'] = $absPath; $data['file_hash'] = $hash; } if ($exist) { Db::name('appmall_framework_list')->where('id', $exist['id'])->update($data); $id = $exist['id']; // type 兼容标志:有整包即 0,仅补丁为 1 $hasFull = ($type === 0) || !empty($exist['file_path']); Db::name('appmall_framework_list')->where('id', $id)->update(['type' => $hasFull ? 0 : 1]); } else { $data['type'] = $type; $data['create_at'] = time(); $id = Db::name('appmall_framework_list')->insertGetId($data); } $tip = $type === 1 ? '增量补丁已上传' : ($type === 2 ? '完整安装包已上传' : '整包已上传'); return $this->result->success(['id' => $id], $tip . ',请点击「发布」使其对外可下载'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 发布:将某版本置为已发布(status=1),客户端即可检测到 */ public function publish() { try { $id = (int) input('id'); if ($id <= 0) { return $this->result->error('缺少版本ID'); } $row = Db::name('appmall_framework_list')->where('id', $id)->find(); if (empty($row)) { return $this->result->error('版本不存在'); } if (empty($row['file_path']) && empty($row['patch_path'])) { return $this->result->error('该版本尚未上传任何包,无法发布'); } Db::name('appmall_framework_list')->where('id', $id)->update(['status' => 1, 'update_at' => time()]); $tip = empty($row['file_path']) ? (empty($row['patch_path']) ? (empty($row['install_path']) ? '已发布' : '已发布(完整安装包,可供全新部署下载)') : '已发布(仅增量补丁:只有停在 ' . ($row['patch_from'] ?: $row['from_version']) . ' 的客户端可升级,建议补传整包)') : (empty($row['patch_path']) ? '已发布(整包)' : '已发布(整包 + 增量补丁)'); return $this->result->success([], $tip); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 删除版本(同时删除整包与补丁物理文件) */ public function delete() { try { $id = (int) input('id'); if ($id <= 0) { return $this->result->error('缺少版本ID'); } $row = Db::name('appmall_framework_list')->where('id', $id)->find(); foreach (['file_path', 'patch_path', 'install_path'] as $col) { if ($row && !empty($row[$col]) && is_file($row[$col])) { @unlink($row[$col]); } } Db::name('appmall_framework_list')->where('id', $id)->delete(); return $this->result->success([], '已删除'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 下载核心包(供运营本地校验) * ?type=patch 下载增量补丁,默认整包 */ public function download() { $id = (int) input('id'); $kind = input('type', 'full'); $kind = in_array($kind, ['patch', 'install'], true) ? $kind : 'full'; $row = Db::name('appmall_framework_list')->where('id', $id)->find(); if ($kind === 'patch') { $path = $row['patch_path'] ?? ''; $suffix = '-patch'; } elseif ($kind === 'install') { $path = $row['install_path'] ?? ''; $suffix = '-install'; } else { $path = $row['file_path'] ?? ''; $suffix = ''; } if (empty($row) || empty($path) || !is_file($path)) { return json(['code' => 0, 'msg' => '文件不存在']); } return new StreamZipResponse($path, 'ywxapp-' . $row['version'] . $suffix . '.zip'); } /** * 兼容已安装库:在线补齐 patch_path / patch_hash / patch_from 列 */ private function ensurePatchColumns(): void { $table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list'; try { $defs = [ '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}"); } } // 历史数据迁移:旧「补丁行」(type=1 且 patch_path 为空) 把整包字段挪到补丁字段 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 } } }