仰望星辰工作室

feat(update): 系统更新改为从 GitHub Release 拉包

Y yang1145 提交于 2026-09-30 19:55 · e3c65ea ·父提交 6a9d087
feat(update): 系统更新改为从 GitHub Release 拉包

自建更新服务器一旦下线客户就无法更新,改为直接读 GitHub Release,
并按你的决定不再保留 check.php 回落、也不再校验 authcode(等于放弃在线更新的授权校验)。

- 包来源:release 自定义 zip 附件优先(zip 根即站点根),回落自动源码包并按「所有条目共享同一顶层」
  通用剥层,以兼容 MNBT-<tag>/ 与 owner-repo-sha/ 两种顶层命名
- 通道:github 直连与 gh-proxy.com 等镜像按序尝试;元数据接口同样走镜像兜底,否则国内直连
  api.github.com 不通时明明有镜像也检查不到新版;Token 只随直连发出,不带给镜像
- 覆盖安装是破坏性操作,故先备份再还原 config.php/cf_up.php/SQ.php/install.lock 与 cookie 目录,
  并用 set_time_limit + ignore_user_abort + register_shutdown_function 保证中断路径也会还原
  改名后的后台目录;解压前拒绝绝对路径、盘符与 .. 段,地址做白名单与内网 IP 校验
- 顺带修对老在线更新的 mysqli 参数顺序(dbname 与 pwd 传反,update.sql 此前从未真正执行成功过)

tdesign 侧只改了 SPA 源码,dist 未重新构建,故该主题的控件需构建后才生效。
12 个文件变更 +1694 -161 13172193298@163.com
•MPHX/BL.php +1 -1
•MPHX/github_updater.php +973 -0
•admin/ajax.php +1 -0
•admin/api/bt.php +19 -17
•admin/api/gg.php +123 -56
•admin/api/update_setting.php +51 -0
•admin/update.php +4 -1
•docs/guide/changelog.md +43 -1
•templates/default/admin/update.php +207 -61
•templates/tdesign/admin/update.php +44 -15
•templates/tdesign/spa/src/admin/api/dashboard.js +10 -0
•templates/tdesign/spa/src/admin/views/UpdateView.vue +218 -9
变更内容
diff --git a/MPHX/BL.php b/MPHX/BL.php
index d668bdc..c645a40 100644
--- a/MPHX/BL.php
+++ b/MPHX/BL.php
@@ -1,4 +1,4 @@
 <?php
-$WEBQB='1840';
+$WEBQB='1850';
 $SQLQB='1310';
 ?>
\ No newline at end of file
diff --git a/MPHX/github_updater.php b/MPHX/github_updater.php
new file mode 100644
index 0000000..ec45b98
--- /dev/null
+++ b/MPHX/github_updater.php
@@ -0,0 +1,973 @@
+<?php
+if (!defined('IN_CRONLITE')) exit();
+/**
+ * 系统更新:从 GitHub Release 拉包覆盖更新(V1.85 起替代自建更新服务器 check.php)
+ *
+ * 为什么这样写:更新包来自站外,配置文件 cf_up.php 里又能改仓库与镜像地址,
+ * 所以这里内置域名白名单 + 解析后 IP 校验,防止配置被人改成内网地址形成 SSRF。
+ * 只定义函数,不产生任何输出。
+ */
+
+/** 站点根目录(带尾斜杠) */
+function mnbt_updater_root()
+{
+	return defined('ROOT') ? ROOT : rtrim(str_replace('\\', '/', dirname(__DIR__)), '/') . '/';
+}
+
+/** 默认配置:仓库与镜像 */
+function mnbt_updater_defaults()
+{
+	return ['repo' => '1181469655/MNBT', 'mirrors' => ['https://gh-proxy.com/'], 'github_token' => ''];
+}
+
+/** 读 cf_up.php 的 $mn_conf(页面已 include 时直接复用,避免重复读文件) */
+function mnbt_updater_read_conf()
+{
+	if (isset($GLOBALS['mn_conf']) && is_array($GLOBALS['mn_conf'])) {
+		return $GLOBALS['mn_conf'];
+	}
+	$mn_conf = [];
+	$cf = mnbt_updater_root() . 'cf_up.php';
+	if (is_file($cf)) {
+		include $cf;
+	}
+	return is_array($mn_conf) ? $mn_conf : [];
+}
+
+/** owner/repo 规范化,非法返回空串 */
+function mnbt_updater_norm_repo($repo)
+{
+	$repo = trim((string)$repo);
+	$repo = preg_replace('#^https?://github\.com/#i', '', $repo);
+	$repo = trim($repo, " \t\n\r/");
+	if (preg_match('#^([A-Za-z0-9._\-]{1,60})/([A-Za-z0-9._\-]{1,80})$#', $repo, $m)) {
+		// 仓库名带 .git 后缀时剥掉,GitHub API 不接受
+		return $m[1] . '/' . preg_replace('#\.git$#', '', $m[2]);
+	}
+	return '';
+}
+
+/** 镜像地址规范化:支持只填域名,统一补协议与尾斜杠;非法返回空串 */
+function mnbt_updater_norm_mirror($mirror)
+{
+	$mirror = trim((string)$mirror);
+	if ($mirror === '') return '';
+	if (!preg_match('#^https?://#i', $mirror)) {
+		if (!preg_match('#^[A-Za-z0-9.\-]{3,120}$#', $mirror)) return '';
+		$mirror = 'https://' . $mirror;
+	}
+	$mirror = rtrim($mirror, '/') . '/';
+	// 双引号写回 cf_up.php,出现引号/反斜杠/美元符会破坏配置文件
+	if (!preg_match('#^https?://[A-Za-z0-9.\-:/_]+$#', $mirror)) return '';
+	$host = strtolower((string)parse_url($mirror, PHP_URL_HOST));
+	if ($host === '' || strpos($host, '.') === false) return '';
+	return $mirror;
+}
+
+/** 更新配置:仓库、镜像列表、Token;缺省值兜底 */
+function mnbt_updater_config()
+{
+	$mn_conf = mnbt_updater_read_conf();
+	$def = mnbt_updater_defaults();
+	$up = isset($mn_conf['update']) && is_array($mn_conf['update']) ? $mn_conf['update'] : [];
+
+	$repo = isset($up['repo']) ? mnbt_updater_norm_repo($up['repo']) : '';
+	if ($repo === '') $repo = $def['repo'];
+
+	$mirrors = [];
+	$mirrors_set = false;
+	if (isset($up['mirrors'])) {
+		// 显式配置过就按配置走,包括被清空(只走 github 直连)
+		$mirrors_set = true;
+		$raw = is_array($up['mirrors']) ? $up['mirrors'] : preg_split('/\r\n|\r|\n/', (string)$up['mirrors']);
+		foreach ($raw as $m) {
+			$n = mnbt_updater_norm_mirror($m);
+			if ($n !== '' && !in_array($n, $mirrors, true)) $mirrors[] = $n;
+		}
+	}
+	if (!$mirrors_set && !$mirrors) $mirrors = $def['mirrors'];
+
+	$token = trim((string)($up['github_token'] ?? ''));
+	if ($token !== '' && !preg_match('#^[A-Za-z0-9._\-]{1,128}$#', $token)) $token = '';
+
+	return ['repo' => $repo, 'mirrors' => $mirrors, 'github_token' => $token];
+}
+
+/**
+ * 回写 cf_up.php 的 update 段,其余键原样保留(参照 admin/api/repair.php 的 ary_asd 写法)
+ * @param string|null $token null 表示保持原值不变
+ */
+function mnbt_updater_save_config($repo, $mirrors, $token = null)
+{
+	$repo = mnbt_updater_norm_repo($repo);
+	if ($repo === '') return ['ok' => 0, 'error' => '仓库格式不正确,应为 owner/repo'];
+
+	$clean = [];
+	foreach ((array)$mirrors as $m) {
+		$n = mnbt_updater_norm_mirror($m);
+		if ($n !== '' && !in_array($n, $clean, true)) $clean[] = $n;
+	}
+	$token_new = null;
+	if ($token !== null) {
+		$token_new = trim((string)$token);
+		if ($token_new !== '' && !preg_match('#^[A-Za-z0-9._\-]{1,128}$#', $token_new)) {
+			return ['ok' => 0, 'error' => 'Token 含非法字符,只允许字母、数字与 . _ -'];
+		}
+	}
+
+	$mn_conf = mnbt_updater_read_conf();
+	$old = isset($mn_conf['update']) && is_array($mn_conf['update']) ? $mn_conf['update'] : [];
+	$mn_conf['update'] = [
+		'repo' => $repo,
+		'mirrors' => array_values($clean),
+		'github_token' => $token_new === null ? (string)($old['github_token'] ?? '') : $token_new,
+	];
+
+	$kr_sxy = ary_asd($mn_conf);
+	if ($kr_sxy === '') return ['ok' => 0, 'error' => '配置数据为空,已取消写入'];
+	$cf = mnbt_updater_root() . 'cf_up.php';
+	if (is_file($cf) && !is_writable($cf)) return ['ok' => 0, 'error' => 'cf_up.php 不可写,请检查文件权限'];
+	$wr = @file_put_contents($cf, '<?php $mn_conf=array(' . $kr_sxy . ');?>');
+	if ($wr === false) return ['ok' => 0, 'error' => '配置文件写入失败'];
+	$GLOBALS['mn_conf'] = $mn_conf;
+	return ['ok' => 1, 'error' => ''];
+}
+
+/** 下载/接口地址的 host 是否授权:GitHub 官方域 + 配置里的镜像域 */
+function mnbt_updater_host_allowed($host, $cfg)
+{
+	$host = strtolower(trim((string)$host));
+	if ($host === '') return false;
+	// github.com / api.github.com / codeload.github.com / *.githubusercontent.com(附件与源码包的跳转目标)
+	if (preg_match('#^([a-z0-9\-]+\.)*github(usercontent)?\.com$#', $host)) return true;
+	foreach ((array)$cfg['mirrors'] as $m) {
+		$mh = strtolower((string)parse_url($m, PHP_URL_HOST));
+		if ($mh !== '' && $mh === $host) return true;
+	}
+	return false;
+}
+
+/** IP 是否为公网可路由地址 */
+function mnbt_updater_ip_public($ip)
+{
+	return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
+}
+
+/** 域名解析结果必须全是公网地址,否则视为内网打洞 */
+function mnbt_updater_host_public($host, &$err)
+{
+	static $memo = [];
+	$host = strtolower((string)$host);
+	if (isset($memo[$host])) {
+		// 同一次请求里会反复校验同一批地址,避免重复解析
+		if ($memo[$host] === true) return true;
+		$err = $memo[$host];
+		return false;
+	}
+	$ips = @gethostbynamel($host);
+	if (!is_array($ips)) $ips = [];
+	if (function_exists('dns_get_record')) {
+		$v6 = @dns_get_record($host, DNS_AAAA);
+		if (is_array($v6)) {
+			foreach ($v6 as $r) {
+				if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
+			}
+		}
+	}
+	if (!$ips) {
+		$err = '无法解析域名 ' . $host;
+		$memo[$host] = $err;
+		return false;
+	}
+	foreach ($ips as $ip) {
+		if (!mnbt_updater_ip_public($ip)) {
+			$err = '域名 ' . $host . ' 解析到内网或保留地址,已拒绝';
+			$memo[$host] = $err;
+			return false;
+		}
+	}
+	$memo[$host] = true;
+	return true;
+}
+
+/** 完整地址安全检查:协议 + 白名单 + 解析 IP */
+function mnbt_updater_url_safe($url, $cfg, &$err)
+{
+	$url = trim((string)$url);
+	if (!preg_match('#^https?://#i', $url)) {
+		$err = '仅允许 http/https 地址';
+		return false;
+	}
+	$host = (string)parse_url($url, PHP_URL_HOST);
+	if ($host === '') {
+		$err = '地址无效';
+		return false;
+	}
+	if (!mnbt_updater_host_allowed($host, $cfg)) {
+		$err = '未授权的下载地址:' . $host;
+		return false;
+	}
+	return mnbt_updater_host_public($host, $err);
+}
+
+/** GitHub API 单次请求(返回体量很小,可以进内存) */
+function mnbt_updater_api_once($url, $cfg, $send_token)
+{
+	$out = ['ok' => 0, 'json' => null, 'http' => 0, 'error' => ''];
+	$err = '';
+	if (!mnbt_updater_url_safe($url, $cfg, $err)) {
+		$out['error'] = $err;
+		return $out;
+	}
+	$headers = [
+		'User-Agent: MNBT-Updater/1.85',
+		'Accept: application/vnd.github+json',
+	];
+	// Token 只用于提速率,发给镜像就等于把凭据交给第三方,所以只有直连才带
+	if ($send_token && $cfg['github_token'] !== '') {
+		$headers[] = 'Authorization: Bearer ' . $cfg['github_token'];
+	}
+	$ch = curl_init($url);
+	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+	curl_setopt($ch, CURLOPT_HEADER, false);
+	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
+	curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
+	curl_setopt($ch, CURLOPT_TIMEOUT, 20);
+	curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+	curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+	curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+	$body = curl_exec($ch);
+	$cerr = curl_error($ch);
+	$out['http'] = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+	curl_close($ch);
+	if ($body === false) {
+		$out['error'] = $cerr !== '' ? $cerr : '接口请求失败';
+		return $out;
+	}
+	if ($out['http'] < 200 || $out['http'] >= 300) {
+		$js = json_decode((string)$body, true);
+		$msg = is_array($js) && isset($js['message']) ? (string)$js['message'] : '';
+		$out['error'] = 'HTTP ' . $out['http'] . ($msg !== '' ? '(' . $msg . ')' : '');
+		return $out;
+	}
+	$js = json_decode((string)$body, true);
+	if (!is_array($js)) {
+		$out['error'] = '接口返回内容无法解析';
+		return $out;
+	}
+	$out['ok'] = 1;
+	$out['json'] = $js;
+	return $out;
+}
+
+/**
+ * 调用 GitHub API:先直连 api.github.com,失败再按镜像顺序尝试
+ * 国内环境直连 api.github.com 常常不通,但镜像可以代理它,所以这里也要有镜像兜底
+ */
+function mnbt_updater_api_get($path, $cfg)
+{
+	$out = ['ok' => 0, 'json' => null, 'http' => 0, 'error' => ''];
+	if (!function_exists('curl_init')) {
+		$out['error'] = '服务器未启用 curl 扩展';
+		return $out;
+	}
+	$direct = 'https://api.github.com' . $path;
+	$urls = [['url' => $direct, 'token' => true]];
+	foreach ($cfg['mirrors'] as $m) {
+		$urls[] = ['url' => $m . $direct, 'token' => false];
+	}
+	$errs = [];
+	foreach ($urls as $u) {
+		$r = mnbt_updater_api_once($u['url'], $cfg, $u['token']);
+		if ($r['ok']) return $r;
+		$errs[] = ($u['token'] ? '直连' : $u['url']) . ':' . $r['error'];
+		$out['http'] = $r['http'];
+	}
+	$out['error'] = '接口请求失败(' . implode(' / ', $errs) . ')';
+	return $out;
+}
+
+/**
+ * tag 规范化成项目版本号:V1.84 => 1840
+ * 与 $WEBQB 的规则一致:主版本占千位、次版本占百十位、补丁占个位(补丁最多记 9)
+ */
+function mnbt_updater_tag_to_version($tag)
+{
+	$tag = (string)$tag;
+	if (!preg_match('/(\d+)(?:[.\-](\d+))?(?:[.\-](\d+))?/', $tag, $m)) return 0;
+	$major = (int)$m[1];
+	$minor = isset($m[2]) && $m[2] !== '' ? (int)$m[2] : 0;
+	$patch = isset($m[3]) && $m[3] !== '' ? (int)$m[3] : 0;
+	return $major * 1000 + $minor * 10 + min(9, $patch);
+}
+
+/** tag 显示名(GitHub 上的 tag 已带 V 前缀,这里只做兜底) */
+function mnbt_updater_tag_display($tag)
+{
+	$tag = trim((string)$tag);
+	if ($tag === '') return '';
+	return (preg_match('/^v/i', $tag) ? '' : 'V') . $tag;
+}
+
+/** 从 release 的 assets 里挑自定义 zip 附件 */
+function mnbt_updater_pick_asset($assets)
+{
+	if (!is_array($assets)) return ['url' => '', 'name' => '', 'size' => 0];
+	foreach ($assets as $a) {
+		if (!is_array($a)) continue;
+		$url = isset($a['browser_download_url']) ? (string)$a['browser_download_url'] : '';
+		$name = isset($a['name']) ? (string)$a['name'] : '';
+		if ($url === '') continue;
+		if (!preg_match('#\.zip$#i', $name !== '' ? $name : $url)) continue;
+		return ['url' => $url, 'name' => $name, 'size' => (int)($a['size'] ?? 0)];
+	}
+	return ['url' => '', 'name' => '', 'size' => 0];
+}
+
+/**
+ * 下载候选地址:包来源优先级为 release 自定义 zip 附件 > GitHub 自动源码包;
+ * 每个来源再按 github.com 直连、配置镜像顺序依次尝试
+ */
+function mnbt_updater_candidates($cfg, $tag, $asset_url)
+{
+	$srcs = [];
+	if ($asset_url !== '') {
+		$srcs[] = ['type' => 'asset', 'label' => 'Release 附件', 'url' => $asset_url];
+	}
+	// archive/refs/tags 会由 GitHub 跳到 codeload,legacy.zip 是 codeload 的直链形式,两种都留作兜底
+	$srcs[] = [
+		'type' => 'source',
+		'label' => 'GitHub 源码包',
+		'url' => 'https://github.com/' . $cfg['repo'] . '/archive/refs/tags/' . rawurlencode($tag) . '.zip',
+	];
+	$srcs[] = [
+		'type' => 'source',
+		'label' => 'GitHub 源码包(直连 codeload)',
+		'url' => 'https://codeload.github.com/' . $cfg['repo'] . '/legacy.zip/refs/tags/' . rawurlencode($tag),
+	];
+
+	$out = [];
+	foreach ($srcs as $s) {
+		$out[] = ['type' => $s['type'], 'label' => $s['label'], 'url' => $s['url'], 'via' => 'github', 'mirror' => ''];
+		foreach ($cfg['mirrors'] as $m) {
+			$out[] = ['type' => $s['type'], 'label' => $s['label'], 'url' => $m . $s['url'], 'via' => 'mirror', 'mirror' => $m];
+		}
+	}
+	return $out;
+}
+
+/** 检查结果缓存文件(后台首页每次加载都要问一遍,不限流会很快打满匿名请求配额) */
+function mnbt_updater_cache_file()
+{
+	$dir = mnbt_updater_root() . 'runtime/cache';
+	if (!is_dir($dir)) mnbt_updater_mkdir($dir);
+	return $dir . '/github_updater_check.json';
+}
+
+/** 检查最新版本;返回展示与下载所需的全部信息 */
+function mnbt_updater_check($force = false, $ttl = 1800)
+{
+	$cfg = mnbt_updater_config();
+	$cur = (int)($GLOBALS['WEBQB'] ?? 0);
+	$out = [
+		'ok' => 0, 'error' => '', 'repo' => $cfg['repo'], 'mirrors' => $cfg['mirrors'],
+		'has_token' => ($cfg['github_token'] !== ''), 'current' => 'V' . sprintf('%.2f', $cur / 1000),
+		'current_version' => $cur, 'tag' => '', 'latest' => '', 'version' => 0, 'name' => '', 'body' => '',
+		'asset_url' => '', 'asset_name' => '', 'asset_size' => 0, 'source' => '', 'source_label' => '',
+		'published_at' => '', 'fallback' => 0, 'can_update' => 0, 'candidates' => [],
+	];
+	if ($cfg['repo'] === '') {
+		$out['error'] = '未配置更新仓库';
+		return $out;
+	}
+
+	// 仓库或本地版本变了要重新问;失败结果最多缓存 5 分钟
+	$cf = mnbt_updater_cache_file();
+	if (!$force && is_file($cf)) {
+		$c = json_decode((string)@file_get_contents($cf), true);
+		if (is_array($c) && isset($c['ts'], $c['repo'], $c['ver'], $c['data'])
+			&& $c['repo'] === $cfg['repo'] && (int)$c['ver'] === $cur) {
+			$life = !empty($c['data']['ok']) ? min((int)($c['ttl'] ?? 1800), (int)$ttl) : min(300, (int)$ttl);
+			if (time() - (int)$c['ts'] < $life) {
+				return $c['data'];
+			}
+		}
+	}
+
+	$rel = null;
+	$err1 = '';
+	$api = mnbt_updater_api_get('/repos/' . $cfg['repo'] . '/releases/latest', $cfg);
+	if ($api['ok']) {
+		$js = $api['json'];
+		// latest 接口对非数组返回也会给 200,这里靠 tag_name 判定
+		if (isset($js['tag_name']) && mnbt_updater_tag_to_version($js['tag_name']) > 0) {
+			$rel = $js;
+		}
+	} else {
+		$err1 = $api['error'];
+	}
+	if (!is_array($rel)) {
+		// latest 不可用(未来 tag 可能不再以 V 开头,或接口限流),退化成列表取版本号最高的一个
+		$list = mnbt_updater_api_get('/repos/' . $cfg['repo'] . '/releases?per_page=20', $cfg);
+		if (!$list['ok']) {
+			$out['error'] = $list['error'] !== '' ? $list['error'] : $err1;
+			mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
+			return $out;
+		}
+		$best = null;
+		$bestv = 0;
+		foreach ($list['json'] as $js) {
+			if (!is_array($js) || empty($js['tag_name'])) continue;
+			if (!empty($js['draft']) || !empty($js['prerelease'])) continue;
+			$v = mnbt_updater_tag_to_version($js['tag_name']);
+			if ($v > $bestv) { $bestv = $v; $best = $js; }
+		}
+		if (!is_array($best)) {
+			$out['error'] = $err1 !== '' ? $err1 : 'GitHub 未找到可用的版本发布';
+			mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
+			return $out;
+		}
+		$rel = $best;
+		$out['fallback'] = 1;
+	}
+
+	$tag = (string)$rel['tag_name'];
+	$asset = mnbt_updater_pick_asset($rel['assets'] ?? []);
+	$ver = mnbt_updater_tag_to_version($tag);
+
+	$out['ok'] = 1;
+	$out['tag'] = $tag;
+	$out['latest'] = mnbt_updater_tag_display($tag);
+	$out['version'] = $ver;
+	$out['name'] = (string)($rel['name'] ?? '');
+	$out['body'] = (string)($rel['body'] ?? '');
+	$out['published_at'] = (string)($rel['published_at'] ?? '');
+	$out['asset_url'] = $asset['url'];
+	$out['asset_name'] = $asset['name'];
+	$out['asset_size'] = $asset['size'];
+	$out['can_update'] = ($ver > $cur) ? 1 : 0;
+	$out['source'] = $asset['url'] !== '' ? 'asset' : 'source';
+	$out['source_label'] = $asset['url'] !== '' ? 'Release 附件 ' . $asset['name'] : 'GitHub 自动源码包(需剥顶层目录)';
+	$out['candidates'] = mnbt_updater_candidates($cfg, $tag, $asset['url']);
+	mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
+	return $out;
+}
+
+/** 写检查结果缓存;缓存里不含 Token,只存展示与下载所需数据 */
+function mnbt_updater_cache_put($cfg, $cur, $out, $ttl = 1800)
+{
+	$cf = mnbt_updater_cache_file();
+	@file_put_contents($cf, json_encode([
+		'ts' => time(),
+		'ttl' => (int)$ttl,
+		'repo' => $cfg['repo'],
+		'ver' => (int)$cur,
+		'data' => $out,
+	], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+}
+
+/**
+ * 流式下载压缩包到临时文件:逐跳校验地址、校验 zip 魔数与字节数,失败即删除
+ * @return bool
+ */
+function mnbt_updater_download($url, $dest, $cfg, &$err)
+{
+	$err = '';
+	if (!function_exists('curl_init')) {
+		$err = '服务器未启用 curl 扩展';
+		return false;
+	}
+	if (!mnbt_updater_url_safe($url, $cfg, $err)) return false;
+	$fp = @fopen($dest, 'wb');
+	if (!$fp) {
+		$err = '无法创建临时文件 ' . $dest;
+		return false;
+	}
+	$cur = (string)$url;
+	$ok = false;
+	$heads = [];
+	for ($hop = 0; $hop < 6; $hop++) {
+		if (!mnbt_updater_url_safe($cur, $cfg, $err)) break;
+		$heads = [];
+		$ch = curl_init($cur);
+		curl_setopt($ch, CURLOPT_FILE, $fp);
+		curl_setopt($ch, CURLOPT_HEADER, false);
+		curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
+		curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
+		curl_setopt($ch, CURLOPT_TIMEOUT, 300);
+		curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+		curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+		curl_setopt($ch, CURLOPT_HTTPHEADER, [
+			// 下载请求不带 Token,避免跳转或镜像把它带出去
+			'User-Agent: MNBT-Updater/1.85',
+			'Accept: application/octet-stream',
+		]);
+		curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$heads) {
+			$len = strlen($line);
+			$p = strpos($line, ':');
+			if ($p !== false) {
+				$heads[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1));
+			}
+			return $len;
+		});
+		$res = curl_exec($ch);
+		$cerr = curl_error($ch);
+		$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+		curl_close($ch);
+		if ($res === false) {
+			$err = $cerr !== '' ? $cerr : '下载中断';
+			break;
+		}
+		if ($code >= 300 && $code < 400) {
+			$loc = $heads['location'] ?? '';
+			if ($loc === '') {
+				$err = 'HTTP ' . $code . ' 但未返回跳转地址';
+				break;
+			}
+			$next = mnbt_updater_abs_url($cur, $loc);
+			if ($next === '') {
+				$err = '跳转地址无效';
+				break;
+			}
+			$cur = $next;
+			// 跳转响应可能自带一小段 HTML,会把压缩包开头弄脏,跳一次就把文件截回空
+			rewind($fp);
+			ftruncate($fp, 0);
+			continue;
+		}
+		if ($code !== 200) {
+			$err = 'HTTP ' . $code;
+			break;
+		}
+		$ok = true;
+		break;
+	}
+	fflush($fp);
+	$bytes = ftell($fp);
+	fclose($fp);
+
+	if ($ok) {
+		$expect = (int)($heads['content-length'] ?? 0);
+		if ($expect > 0 && $expect !== $bytes) {
+			$err = '文件不完整,应为 ' . $expect . ' 字节,实际 ' . $bytes . ' 字节';
+			$ok = false;
+		} elseif ($bytes < 1024) {
+			$err = '下载内容过小(' . $bytes . ' 字节),不像有效的压缩包';
+			$ok = false;
+		} elseif (!mnbt_updater_is_zip($dest)) {
+			$err = '下载内容不是有效的 zip 压缩包(可能是镜像返回的错误页)';
+			$ok = false;
+		}
+	}
+	if (!$ok) {
+		@unlink($dest);
+	}
+	return $ok;
+}
+
+/** 相对跳转地址转绝对 */
+function mnbt_updater_abs_url($base, $loc)
+{
+	$loc = trim((string)$loc);
+	if ($loc === '') return '';
+	if (preg_match('#^https?://#i', $loc)) return $loc;
+	$p = parse_url($base);
+	if (!$p || empty($p['host'])) return '';
+	$scheme = isset($p['scheme']) ? $p['scheme'] : 'https';
+	if (strpos($loc, '/') === 0) return $scheme . '://' . $p['host'] . $loc;
+	$dir = isset($p['path']) ? substr($p['path'], 0, strrpos($p['path'], '/') + 1) : '/';
+	return $scheme . '://' . $p['host'] . $dir . $loc;
+}
+
+/** zip 魔数校验 */
+function mnbt_updater_is_zip($file)
+{
+	if (!is_file($file)) return false;
+	$fp = @fopen($file, 'rb');
+	if (!$fp) return false;
+	$magic = fread($fp, 4);
+	fclose($fp);
+	return $magic === "PK\x03\x04" || $magic === "PK\x05\x06";
+}
+
+/** zip 条目名安全校验:禁止绝对路径、盘符、反斜杠与 .. 段 */
+function mnbt_updater_entry_safe($name)
+{
+	$name = (string)$name;
+	if ($name === '' || strpos($name, "\0") !== false) return false;
+	if (strpos($name, '/') === 0) return false;
+	if (strpos($name, '\\') !== false) return false;
+	if (preg_match('#^[A-Za-z]:#', $name)) return false;
+	foreach (explode('/', $name) as $seg) {
+		if ($seg === '..') return false;
+	}
+	return true;
+}
+
+/**
+ * 解压前扫描:拒绝危险条目、识别需要剥掉的顶层目录、确认剥完之后确实是站点根
+ * @return array|null 成功返回 ['strip'=>..,'entries'=>..,'files'=>..],失败返回 null 并写 $err
+ */
+function mnbt_updater_zip_analyze($file, &$err)
+{
+	$err = '';
+	if (!class_exists('ZipArchive')) {
+		$err = '服务器未启用 Zip 扩展,无法在线更新';
+		return null;
+	}
+	$zip = new ZipArchive();
+	$op = $zip->open($file);
+	if ($op !== true) {
+		$err = '压缩包无法打开(错误码 ' . $op . '),可能下载不完整';
+		return null;
+	}
+	$n = (int)$zip->numFiles;
+	if ($n <= 0) {
+		$zip->close();
+		$err = '压缩包是空的';
+		return null;
+	}
+	$names = [];
+	$files = 0;
+	for ($i = 0; $i < $n; $i++) {
+		$e = $zip->statIndex($i);
+		$name = isset($e['name']) ? (string)$e['name'] : '';
+		if (!mnbt_updater_entry_safe($name)) {
+			$zip->close();
+			$err = '压缩包含非法路径,已中止:' . $name;
+			return null;
+		}
+		$names[] = $name;
+		if (substr($name, -1) !== '/') $files++;
+	}
+	$strip = mnbt_updater_zip_strip($names);
+	// 附件包(无顶层目录)与源码包(剥掉顶层目录后)都必须落在站点根上,否则视为包内容不对
+	if (!mnbt_updater_zip_looks_like_site($names, $strip)) {
+		$zip->close();
+		$err = '压缩包结构不符合站点根目录,已中止';
+		return null;
+	}
+	$zip->close();
+	return ['strip' => $strip, 'entries' => $n, 'files' => $files];
+}
+
+/**
+ * 顶层目录识别:GitHub 自动源码包的顶层名有两种(MNBT-1.84/ 与 1181469655-MNBT-<sha>/),
+ * 所以只按「所有条目共享同一个顶层目录」来判断,不硬编码名字
+ */
+function mnbt_updater_zip_strip($names)
+{
+	$top = null;
+	foreach ($names as $name) {
+		$pos = strpos($name, '/');
+		if ($pos === false || $pos === 0) return '';
+		$seg = substr($name, 0, $pos);
+		if ($top === null) {
+			$top = $seg;
+		} elseif ($top !== $seg) {
+			return '';
+		}
+	}
+	return $top === null ? '' : $top . '/';
+}
+
+/** 剥掉顶层目录后是否还是一个站点根 */
+function mnbt_updater_zip_looks_like_site($names, $strip)
+{
+	$need = ['MPHX/function.php', 'admin/ajax.php', 'index.php', 'cf_up.php'];
+	$len = strlen($strip);
+	foreach ($names as $name) {
+		if ($strip !== '' && strncmp($name, $strip, $len) !== 0) continue;
+		$rel = rtrim($strip === '' ? $name : substr($name, $len), '/');
+		if ($rel !== '' && in_array($rel, $need, true)) return true;
+	}
+	return false;
+}
+
+/** 逐级建目录 */
+function mnbt_updater_mkdir($dir)
+{
+	if (is_dir($dir)) return true;
+	return @mkdir($dir, 0755, true);
+}
+
+/**
+ * 逐条目解压(需要剥顶层目录时 extractTo 做不到,所以统一走这里)
+ * @return bool
+ */
+function mnbt_updater_extract($file, $root, $strip, &$err)
+{
+	$err = '';
+	$zip = new ZipArchive();
+	if ($zip->open($file) !== true) {
+		$err = '压缩包打开失败';
+		return false;
+	}
+	$n = (int)$zip->numFiles;
+	for ($i = 0; $i < $n; $i++) {
+		$e = $zip->statIndex($i);
+		$name = isset($e['name']) ? (string)$e['name'] : '';
+		if (!mnbt_updater_entry_safe($name)) {
+			$zip->close();
+			$err = '压缩包含非法路径,已中止:' . $name;
+			return false;
+		}
+		$rel = $name;
+		if ($strip !== '') {
+			if (strpos($name, $strip) !== 0) continue;
+			$rel = substr($name, strlen($strip));
+		}
+		if ($rel === '') continue;
+		$target = $root . $rel;
+		if (substr($name, -1) === '/') {
+			if (!is_dir($target) && !mnbt_updater_mkdir($target)) {
+				$zip->close();
+				$err = '目录创建失败:' . $rel;
+				return false;
+			}
+			continue;
+		}
+		$dir = dirname($target);
+		if (!is_dir($dir) && !mnbt_updater_mkdir($dir)) {
+			$zip->close();
+			$err = '目录创建失败:' . $dir;
+			return false;
+		}
+		$src = $zip->getStream($name);
+		if ($src === false) {
+			$zip->close();
+			$err = '条目读取失败:' . $rel;
+			return false;
+		}
+		$dst = @fopen($target, 'wb');
+		if (!$dst) {
+			fclose($src);
+			$zip->close();
+			$err = '文件写入失败:' . $rel;
+			return false;
+		}
+		stream_copy_to_stream($src, $dst);
+		fclose($dst);
+		fclose($src);
+	}
+	$zip->close();
+	return true;
+}
+
+/** 需要备份还原的本地文件:装完就有、包里有但不能被覆盖 */
+function mnbt_updater_local_files()
+{
+	return ['config.php', 'cf_up.php', 'MPHX/SQ.php', 'install/install.lock'];
+}
+
+/** 需要备份还原的本地目录:包里可能带着作者的数据,整目录换回来 */
+function mnbt_updater_local_dirs()
+{
+	return ['api/cookie'];
+}
+
+/** 递归删除目录(只用于更新流程自己产生的临时目录) */
+function mnbt_updater_rrmdir($dir)
+{
+	if (!is_dir($dir)) return;
+	$items = @scandir($dir);
+	if (!is_array($items)) return;
+	foreach ($items as $it) {
+		if ($it === '.' || $it === '..') continue;
+		$p = $dir . '/' . $it;
+		if (is_dir($p)) {
+			mnbt_updater_rrmdir($p);
+		} else {
+			@unlink($p);
+		}
+	}
+	@rmdir($dir);
+}
+
+/** 递归复制目录 */
+function mnbt_updater_copy_dir($src, $dst)
+{
+	if (!is_dir($src)) return;
+	if (!is_dir($dst) && !mnbt_updater_mkdir($dst)) return;
+	$items = @scandir($src);
+	if (!is_array($items)) return;
+	foreach ($items as $it) {
+		if ($it === '.' || $it === '..') continue;
+		$s = $src . '/' . $it;
+		$d = $dst . '/' . $it;
+		if (is_dir($s)) {
+			mnbt_updater_copy_dir($s, $d);
+		} else {
+			@copy($s, $d);
+		}
+	}
+}
+
+/**
+ * 备份本地文件(记录哪些原本存在,install.lock 之类要按原样决定留还是删)
+ * @return array ['dir'=>备份目录,'files'=>[相对路径=>是否已备份],'dirs'=>[...],'present'=>[...]]
+ */
+function mnbt_updater_backup_local($root, $bak_dir, &$err)
+{
+	$err = '';
+	$st = ['dir' => $bak_dir, 'files' => [], 'dirs' => [], 'present' => []];
+	if (!mnbt_updater_mkdir($bak_dir)) {
+		$err = '备份目录创建失败:' . $bak_dir;
+		return $st;
+	}
+	foreach (mnbt_updater_local_files() as $rel) {
+		$src = $root . $rel;
+		$st['present'][$rel] = is_file($src);
+		if (!is_file($src)) continue;
+		$dst = $bak_dir . '/' . $rel;
+		if (!is_dir(dirname($dst)) && !mnbt_updater_mkdir(dirname($dst))) {
+			$err = '备份目录创建失败:' . dirname($dst);
+			continue;
+		}
+		if (@copy($src, $dst)) {
+			$st['files'][$rel] = 1;
+		} else {
+			$err = '本地文件备份失败:' . $rel;
+		}
+	}
+	foreach (mnbt_updater_local_dirs() as $rel) {
+		$src = rtrim($root, '/') . '/' . $rel;
+		$st['present'][$rel] = is_dir($src);
+		if (!is_dir($src)) continue;
+		mnbt_updater_copy_dir($src, $bak_dir . '/' . $rel);
+		$st['dirs'][$rel] = 1;
+	}
+	return $st;
+}
+
+/**
+ * 还原本地文件;backup 为空数组时按「跳过备份」处理,只保证不会误判成未安装
+ * @return bool
+ */
+function mnbt_updater_restore_local($root, $backup, &$err)
+{
+	$err = '';
+	$dir = isset($backup['dir']) ? $backup['dir'] : '';
+	if ($dir === '' || !is_dir($dir)) return true;
+	foreach ($backup['files'] as $rel => $ok) {
+		if (empty($ok)) continue;
+		$bak = $dir . '/' . $rel;
+		if (is_file($bak)) {
+			$dst = $root . $rel;
+			if (!is_dir(dirname($dst)) && !mnbt_updater_mkdir(dirname($dst))) {
+				$err .= '无法还原目录:' . dirname($dst) . ';';
+				continue;
+			}
+			if (!@copy($bak, $dst)) $err .= '本地文件还原失败:' . $rel . ';';
+		}
+	}
+	// 新包带的安装锁不能让站点被判定为未安装,原本没有就必须删掉
+	$lock = $root . 'install/install.lock';
+	if (empty($backup['files']['install/install.lock']) && is_file($lock)) {
+		@unlink($lock);
+	}
+	foreach ($backup['dirs'] as $rel => $ok) {
+		if (empty($ok)) continue;
+		$target = rtrim($root, '/') . '/' . $rel;
+		mnbt_updater_rrmdir($target);
+		mnbt_updater_copy_dir($dir . '/' . $rel, $target);
+	}
+	// 站点原本没有这些目录时,包里带进来的同名内容一律清掉(例如作者打包残留的 cookie)
+	foreach ($backup['present'] as $rel => $existed) {
+		if (!empty($existed)) continue;
+		$p = rtrim($root, '/') . '/' . $rel;
+		if (is_dir($p)) {
+			mnbt_updater_rrmdir($p);
+		}
+	}
+	return $err === '';
+}
+
+/** 更新流程的兜底状态:任何中断路径(超时、致命错误、用户关页面)都要收拾残局 */
+function mnbt_updater_guard_start()
+{
+	$GLOBALS['mnbt_updater_guard'] = [
+		'running' => 1, 'root' => mnbt_updater_root(), 'zip' => '', 'bak' => '',
+		'admin_now' => '', 'admin_orig' => '', 'moved' => 0, 'backup' => null, 'restored' => 0,
+	];
+	register_shutdown_function('mnbt_updater_guard_shutdown');
+}
+
+/** 更新兜底状态的某个键 */
+function mnbt_updater_guard($key, $val)
+{
+	if (!isset($GLOBALS['mnbt_updater_guard']) || !is_array($GLOBALS['mnbt_updater_guard'])) {
+		$GLOBALS['mnbt_updater_guard'] = [];
+	}
+	$GLOBALS['mnbt_updater_guard'][$key] = $val;
+}
+
+/** 流程正常收尾:关掉兜底,避免残局处理再动一次文件 */
+function mnbt_updater_guard_done()
+{
+	$GLOBALS['mnbt_updater_guard']['running'] = 0;
+}
+
+/**
+ * 回滚/收尾:还原本地配置 → 把改名后的后台目录还原 → 清临时包与备份
+ * 成功与失败都走这里,所以逻辑必须可重复执行
+ */
+function mnbt_updater_guard_rollback()
+{
+	$g = $GLOBALS['mnbt_updater_guard'] ?? null;
+	if (!is_array($g)) return;
+	$root = !empty($g['root']) ? $g['root'] : mnbt_updater_root();
+	if (!empty($g['backup']) && empty($g['restored'])) {
+		$re = '';
+		mnbt_updater_restore_local($root, $g['backup'], $re);
+		$GLOBALS['mnbt_updater_guard']['restored'] = 1;
+	}
+	if (!empty($g['moved']) && !empty($g['admin_orig']) && !empty($g['admin_now']) && $g['admin_now'] !== $g['admin_orig']) {
+		if (!file_exists($g['admin_orig'])) {
+			@rename($g['admin_now'], $g['admin_orig']);
+		} elseif (is_dir($g['admin_orig']) && !is_dir($g['admin_now'] . '/api')) {
+			// 新包里恰好带了同名目录:先删掉空的占位目录再改回来
+			@rmdir($g['admin_orig']);
+			if (!file_exists($g['admin_orig'])) @rename($g['admin_now'], $g['admin_orig']);
+		}
+	}
+	if (!empty($g['zip']) && is_file($g['zip'])) @unlink($g['zip']);
+	if (!empty($g['bak']) && is_dir($g['bak'])) mnbt_updater_rrmdir($g['bak']);
+}
+
+/** 关机兜底:流程没正常跑完时至少把站点恢复成可访问状态 */
+function mnbt_updater_guard_shutdown()
+{
+	$g = $GLOBALS['mnbt_updater_guard'] ?? null;
+	if (!is_array($g) || empty($g['running'])) return;
+	mnbt_updater_guard_rollback();
+}
+
+/**
+ * 执行升级 SQL(只执行 update/update.sql,逻辑与旧版一致:按分号逐句跑)
+ * 注意参数顺序是 mysqli(host, user, pwd, dbname[, port]),旧代码把 dbname 和 pwd 传反了,
+ * 导致连接总是失败,这里按正确顺序传
+ * @return string 空串表示成功,否则为错误说明
+ */
+function mnbt_updater_run_sql($file, $dbconfig)
+{
+	if (!is_file($file)) return '';
+	$_sql = @file_get_contents($file);
+	if ($_sql === false || trim($_sql) === '') return '';
+	$port = !empty($dbconfig['port']) ? (int)$dbconfig['port'] : 3306;
+	try {
+		$_mysqli = new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['pwd'], $dbconfig['dbname'], $port);
+	} catch (Throwable $e) {
+		return '连接数据库出错:' . $e->getMessage();
+	}
+	if (mysqli_connect_errno()) {
+		return '连接数据库出错:' . mysqli_connect_error();
+	}
+	$_mysqli->query('set names utf8;');
+	foreach (explode(';', $_sql) as $_value) {
+		if (trim((string)$_value) === '') continue;
+		$_mysqli->query($_value);
+	}
+	$_mysqli->close();
+	return '';
+}
diff --git a/admin/ajax.php b/admin/ajax.php
index dfc590b..c475853 100644
--- a/admin/ajax.php
+++ b/admin/ajax.php
@@ -21,5 +21,6 @@ require_once './api/dd.php';
 require_once './api/gg.php';
 require_once './api/plugin.php';
 require_once './api/docker.php';
+require_once './api/update_setting.php';
 json_exit('系统指令不存在!');
 ?>
diff --git a/admin/api/bt.php b/admin/api/bt.php
index 939d384..f4e33d5 100644
--- a/admin/api/bt.php
+++ b/admin/api/bt.php
@@ -69,27 +69,29 @@ if($egn=='xgjl') {
 }
 if($egn=='mnbt') {
 	include("../MPHX/BL.php");
-	include("../MPHX/SQ.php");
-	include("../cf_up.php");
-	$gxtj = array(
-		'url' => $_SERVER['HTTP_HOST'],
-		'authcode' => $authcode,
-		'ver' => $WEBQB,
-		);
-	$result = send_post($mn_conf['aet'].'://'.$mn_conf['url'].':'.$mn_conf['port'].'/check.php',$gxtj);
-	$content=json_decode($result, true);
+	include_once("../MPHX/github_updater.php");
+	// 只走 GitHub Release 检查更新,不再回连自建服务器,也不再校验 authcode
+	$content = mnbt_updater_check();
 	$total ='V'.sprintf( "%.2f ",$WEBQB/1000);
-	if($content['code']=='0') {
-		$cl='mdi-bookmark-check';
-		$gx='您使用的已是最新版本!';
-	} elseif($content['code']=='1') {
+	if($content['ok']!=1) {
+		$cl='mdi-cloud-off';
+		$gx='暂时无法检查更新:'.$content['error'];
+	} elseif($content['can_update']==1) {
 		$cl='mdi-arrow-up-bold-circle';
 		$gx='已经有新版本推出!请前往系统管理->系统更新处进行更新';
-	} elseif($content['code']=='-1') {
-		$cl='mdi-account-off';
-		$gx='离线模式不提供更新!!!';
+	} else {
+		$cl='mdi-bookmark-check';
+		$gx='您使用的已是最新版本!';
 	}
-	exit(json_encode(['cl'=>$cl,'gx'=>$gx,'vs'=>$total,'gg'=>$content['gg']],256));
+	exit(json_encode([
+		'cl'=>$cl,
+		'gx'=>$gx,
+		'vs'=>$total,
+		'gg'=>'',
+		'newver'=>$content['latest'],
+		'cver'=>$content['current'],
+		'canupdate'=>$content['can_update'],
+	],256));
 }
 if($egn=='listbt') {
 	//宝塔列表
diff --git a/admin/api/gg.php b/admin/api/gg.php
index a92bef4..2f97c7d 100644
--- a/admin/api/gg.php
+++ b/admin/api/gg.php
@@ -6,66 +6,133 @@ if($egn=='gglist') {
 	exit($result);
 }
 if($egn=='update') {
+	// 更新包体积大、解压慢,先放宽执行时间;用户中途关页面也要把流程走完
+	@set_time_limit(600);
+	@ignore_user_abort(true);
 	include("../MPHX/BL.php");
-	include("../MPHX/SQ.php");
+	include_once("../MPHX/github_updater.php");
 	include("../cf_up.php");
-	$gxtj = array(
-	    'url' => $_SERVER['HTTP_HOST'],
-	    'authcode' => $authcode,
-	    'ver' => $WEBQB,
-	    );
-	$result = send_post($mn_conf['aet'].'://'.$mn_conf['url'].':'.$mn_conf['port'].'/check.php',$gxtj);
-	$query=json_decode($result, true);
-	if($query['code']=="1") {
-		//file_put_contents("gxwj.zip",$blh);
-		copy($query['file'],'gxwj.zip');
-		$url = $_SERVER['PHP_SELF'];
-		$filenamet= str_ireplace('ajax.php', '', $url);
-		$filename= str_ireplace('/', '', $filenamet);
-		//exit('{"code":"'.$filename.'"}');
-		if(rename('../'.$filename.'/','../admin/')) {
-			$file = "gxwj.zip";
-			$outPath = "../";
-			$zip = new ZipArchive();
-			$openRes = $zip->open($file);
-			if ($openRes === TRUE) {
-				$zip->extractTo($outPath);
-				$zip->close();
-				unlink($file);
-				rename('../admin/','../'.$filename.'/');
-				$path='../update/update.sql';
-				if(file_exists($path)) {
-					function insert($file,$database,$name,$root,$pwd) {
-						header("Content-type: text/html; charset=utf-8");
-						$_sql = file_get_contents($file);
-						$_arr = explode(';', $_sql);
-						$_mysqli = new mysqli($name,$root,$pwd,$database);
-						//第一个参数为域名,第二个为用户名,第三个为密码,第四个为数据库名字 
-						if (mysqli_connect_errno()) {
-							exit('连接数据库出错');
-						} else {
-							$_mysqli->query('set names utf8;');
-							foreach ($_arr as $_value) {
-								$_mysqli->query($_value.';');
-							}
-						}
-						$_mysqli->close();
-						$_mysqli = null;
-					}
-					insert($path,$dbconfig['user'],$dbconfig['host'],$dbconfig['dbname'],$dbconfig['pwd']);
-					unlink($path);
-					@rmdir('../update/');
-				}
-				json_exit('更新成功~请手动刷新页面');
-			} else {
-				json_exit('解压失败');
-			}
-		} else {
-			json_exit('失败');
+
+	mnbt_updater_guard_start();
+	$up_cfg = mnbt_updater_config();
+	// 用真实路径做文件操作,ROOT 万一是软链或大小写不同也不会把文件写到别处
+	$root_real = str_replace('\\', '/', realpath(rtrim(mnbt_updater_root(),'/')) ?: rtrim(mnbt_updater_root(),'/'));
+	$root_sl = $root_real.'/';					// 站点根,带尾斜杠
+	$root = $root_real;						// 站点根,不带尾斜杠
+	mnbt_updater_guard('root',$root_sl);
+
+	// 1. 检查最新版本(只走 GitHub Release,不再校验 authcode);执行更新前必须拿最新的下载地址
+	$chk = mnbt_updater_check(true);
+	if(!$chk['ok']) {
+		mnbt_updater_guard_done();
+		json_exit_error('检查更新失败:'.$chk['error']);
+	}
+	if(!$chk['can_update']) {
+		mnbt_updater_guard_done();
+		json_exit_error('当前已是最新版本('.$chk['current'].'),无需更新');
+	}
+
+	// 2. 临时目录放站点根的 runtime/temp 下(已在 .gitignore 内,且后台目录改名后依然可访问)
+	$tmp_dir = $root.'/runtime/temp/update_tmp';
+	if(!is_dir($tmp_dir) && !mnbt_updater_mkdir($tmp_dir)) {
+		mnbt_updater_guard_done();
+		json_exit_error('临时目录创建失败,请检查 runtime 目录权限');
+	}
+	$zip_file = $tmp_dir.'/gxwj-'.mt_rand(100000,999999).'.zip';
+	mnbt_updater_guard('zip',$zip_file);
+
+	// 3. 下载:Release 附件优先,回落自动源码包;每个来源按 github 直连、配置镜像顺序依次尝试
+	$used = null;
+	$tried = [];
+	foreach($chk['candidates'] as $c) {
+		$e = '';
+		if(mnbt_updater_download($c['url'],$zip_file,$up_cfg,$e)) {
+			$used = $c;
+			break;
+		}
+		$tried[] = ($c['via']==='mirror' ? $c['mirror'] : 'github 直连').':'.$e;
+	}
+	if($used === null) {
+		mnbt_updater_guard_rollback();
+		mnbt_updater_guard_done();
+		json_exit_error('更新包下载失败(已尝试 '.count($tried).' 个来源):'.implode(' / ',$tried));
+	}
+
+	// 4. 解压前校验:拒绝危险路径,识别并准备剥掉顶层目录
+	$za = mnbt_updater_zip_analyze($zip_file,$zerr);
+	if(!$za) {
+		mnbt_updater_guard_rollback();
+		mnbt_updater_guard_done();
+		json_exit_error('更新包校验失败:'.$zerr);
+	}
+
+	// 5. 备份本地文件:config.php、cf_up.php、MPHX/SQ.php、install/install.lock、api/cookie/
+	$bak_dir = $tmp_dir.'/bak_'.mt_rand(100000,999999);
+	$backup = mnbt_updater_backup_local($root_sl,$bak_dir,$bk_err);
+	mnbt_updater_guard('bak',$bak_dir);
+	mnbt_updater_guard('backup',$backup);
+	if($bk_err!=='') {
+		mnbt_updater_guard_rollback();
+		mnbt_updater_guard_done();
+		json_exit_error('本地配置备份失败:'.$bk_err);
+	}
+
+	// 6. 当前后台目录改名成标准的 admin,新包里的 admin 才能落在正确位置
+	$admin_now = str_replace('\\','/',dirname(__DIR__));
+	$admin_std = $root.'/admin';
+	mnbt_updater_guard('admin_now',$admin_std);
+	mnbt_updater_guard('admin_orig',$admin_now);
+	if(basename($admin_now)!=='admin' || strcasecmp(dirname($admin_now),$root)!==0) {
+		if(is_dir($admin_std)) {
+			mnbt_updater_guard_rollback();
+			mnbt_updater_guard_done();
+			json_exit_error('站点根目录已存在一个 admin 目录,与当前后台目录名冲突,已中止更新');
+		}
+		if(!@rename($admin_now,$admin_std)) {
+			mnbt_updater_guard_rollback();
+			mnbt_updater_guard_done();
+			json_exit_error('后台目录改名失败,已中止更新');
 		}
-	} else {
-		json_exit('无法更新');
+		mnbt_updater_guard('moved',1);
+	}
+
+	// 7. 解压覆盖到站点根(附件包 zip 根即站点根;源码包剥掉顶层目录后同样如此)
+	if(!mnbt_updater_extract($zip_file,$root_sl,$za['strip'],$ex_err)) {
+		mnbt_updater_guard_rollback();
+		mnbt_updater_guard_done();
+		json_exit_error('解压覆盖失败:'.$ex_err);
+	}
+
+	// 8. 升级 SQL:只执行包里带的 update/update.sql,位置在剥顶层目录后不变
+	$sql_msg = '';
+	$sql_file = $root.'/update/update.sql';
+	if(is_file($sql_file)) {
+		$sql_msg = mnbt_updater_run_sql($sql_file,$dbconfig);
+		if($sql_msg==='') {
+			// 执行成功才删,出错时保留文件让管理员可以手工再跑一次
+			@unlink($sql_file);
+			@rmdir($root.'/update/');
+		}
+	}
+
+	// 9. 还原本地配置与安装锁、把后台目录改回原名、清临时文件
+	mnbt_updater_guard_rollback();
+	mnbt_updater_guard_done();
+
+	$via_label = $used['via']==='mirror' ? $used['mirror'] : 'github 直连';
+	if(function_exists('logjl')) {
+		logjl($user ?? '', '系统更新', '更新到 '.$chk['latest'].'('.$used['label'].',来源 '.$via_label.')', $sql_msg===''?'更新成功':'更新成功但升级SQL有误', $DB);
+	}
+	if($sql_msg!=='') {
+		json_exit_error('文件已更新,但升级 SQL 执行失败:'.$sql_msg.'(update/update.sql 已保留,可手工执行)', [
+			'ver' => $chk['latest'], 'tag' => $chk['tag'],
+			'source' => $used['label'], 'via' => $via_label, 'strip' => $za['strip'], 'files' => $za['files'],
+		]);
 	}
+	json_exit('更新成功~请手动刷新页面', [
+		'qk' => 1, 'ver' => $chk['latest'], 'tag' => $chk['tag'],
+		'source' => $used['label'], 'via' => $via_label, 'strip' => $za['strip'], 'files' => $za['files'],
+	]);
 	return;
 }
 return;
diff --git a/admin/api/update_setting.php b/admin/api/update_setting.php
new file mode 100644
index 0000000..cb17a94
--- /dev/null
+++ b/admin/api/update_setting.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * 系统更新设置(GitHub Release)
+ * setting.php 归外部 API 兼容改造占用,更新相关的配置单独走这个文件
+ */
+if($egn=='upset') {
+	include_once("../MPHX/BL.php");
+	include_once("../MPHX/github_updater.php");
+	include("../cf_up.php");
+
+	$repo = trim((string)($_POST['repo'] ?? ''));
+	if($repo==='') json_exit_error('请填写更新仓库,格式为 owner/repo');
+
+	$mirrors_raw = $_POST['mirrors'] ?? '';
+	if(is_array($mirrors_raw)) {
+		$mirrors = $mirrors_raw;
+	} else {
+		$mirrors = preg_split('/\r\n|\r|\n|,/', (string)$mirrors_raw);
+	}
+	$mirrors = array_values(array_filter(array_map('trim', (array)$mirrors), function($v){ return $v!==''; }));
+
+	// Token 留空表示保持原值,勾选清除才写空;任何场合都不回显明文
+	$token = null;
+	if(!empty($_POST['clear_token'])) {
+		$token = '';
+	} elseif(trim((string)($_POST['github_token'] ?? ''))!=='') {
+		$token = trim((string)$_POST['github_token']);
+	}
+
+	$res = mnbt_updater_save_config($repo, $mirrors, $token);
+	if(!$res['ok']) json_exit_error($res['error']);
+
+	$cfg = mnbt_updater_config();
+	logjl($user ?? '', '系统更新', '更新仓库设为 '.$cfg['repo'].',镜像 '.count($cfg['mirrors']).' 个', '保存成功', $DB);
+	json_exit('保存成功', [
+		'qk' => 1,
+		'repo' => $cfg['repo'],
+		'mirrors' => $cfg['mirrors'],
+		'has_token' => $cfg['github_token']!=='' ? 1 : 0,
+	]);
+	return;
+}
+if($egn=='upcheck') {
+	include_once("../MPHX/BL.php");
+	include_once("../MPHX/github_updater.php");
+	include("../cf_up.php");
+	// 纯数据载荷,前端直接取字段(与 egn=mnbt 的风格一致)
+	exit(json_encode(mnbt_updater_check(!empty($_POST['force']), 60), JSON_UNESCAPED_UNICODE));
+	return;
+}
+return;
diff --git a/admin/update.php b/admin/update.php
index 3da7043..39d33b9 100644
--- a/admin/update.php
+++ b/admin/update.php
@@ -1,8 +1,11 @@
 <?php
 include("../MPHX/common.php");
 include("../MPHX/BL.php");
-include("../MPHX/SQ.php");
+include_once("../MPHX/github_updater.php");
 include("../cf_up.php");
 $title = 'MN宝塔主机系统更新';
 mnbt_admin_require_login();
+// 检查一次更新,两套模板共用同一份数据;recheck=1 时跳过缓存重新问 GitHub(页面最多 60 秒问一次)
+$mnbt_update = mnbt_updater_check(!empty($_GET['recheck']), 60);
+$mnbt_upcfg = mnbt_updater_config();
 mnbt_admin_render('update');
diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md
index 8132484..4c170fc 100644
--- a/docs/guide/changelog.md
+++ b/docs/guide/changelog.md
@@ -1,10 +1,52 @@
 ---
 title: 更新日志
-description: MNBT 版本更新记录(V1.83 ~ V1.60)
+description: MNBT 版本更新记录(V1.85 ~ V1.60)
 ---
 
 # 更新日志
 
+## V1.85
+
+**外部主机 API 协议兼容开关**
+
+- 新增全局开关 `MN_config.api_compat`:`0`=新版协议(1.83+ 严格,默认),`1`=兼容 1.81 对接模块
+- 后台入口:系统设置 → API 设置 → 「对外 API 协议模式」(`egn=setapicompat`);代码侧统一走 `mnbt_api_compat_mode()`(`MPHX/function.php`)
+- 兼容模式只放宽 1.83+ 新增的**行为约束**(跨节点归属校验、`kt` 站点名按 `max(id)+1`、`zjmode` 缺参清零、`xf` 真值解停、鉴权失败老文案),传输包络与响应结构两个版本一直一致
+- 老版 `kt` 里恒真的「主机已存在」表达式与 `tz` 的资产级联删除属 bug/新能力,兼容模式刻意不还原
+- 详见 [外部对接 API](/api/external)
+
+**系统更新改造:改走 GitHub Release**
+
+- 更新不再回连自建更新服务器(`check.php`),也不再校验 `authcode`;统一从 GitHub Release 拉包覆盖站点
+- 新增 `MPHX/github_updater.php`(纯函数库,受 `IN_CRONLITE` 保护),承载配置读写、Release 元数据获取、流式下载与安全校验
+- 后台入口:`admin/api/gg.php`(`egn=update` 执行更新)、`admin/api/update_setting.php`(`egn=upcheck` 检查、`egn=upset` 保存设置);`admin/api/bt.php`(`egn=mnbt`)改用新检查器
+- 界面:`admin/update.php` + `templates/default/admin/update.php`、`templates/tdesign/admin/update.php`(含 SPA `UpdateView.vue`)展示最新可用版本、Release 正文作为更新日志、包来源与下载通道,并提供「更新设置」区块(仓库 owner/repo、镜像增删、可选 Token)
+
+**包来源优先级**
+
+1. Release 自定义 `.zip` 附件(`zip` 内容即站点根)
+2. 回落 GitHub 自动源码包(codeload),解压时剥掉顶层目录使内容对齐站点根
+
+**下载通道(多候选)**
+
+- 每个来源依次尝试:`github.com` 直连 → 配置镜像(原地址前加 `https://镜像/` 前缀)
+- Release 元数据接口(`api.github.com`)也按同样顺序走镜像兜底,否则国内环境直连不通时明明有镜像却检查不到新版本;Token 只随直连发出,不会带给镜像
+- 仓库与镜像在后台「更新设置」配置,默认 `1181469655/MNBT` + `https://gh-proxy.com/`,写入 `cf_up.php` 的 `update` 段(其余键原样保留)
+- Token 仅用于提升 GitHub API 速率,只以「是否已设置」回显,不随下载请求发给镜像,也不明文存储回显
+
+**安全与可靠性**
+
+- 下载/API 地址 host 白名单:github.com、api.github.com、codeload.github.com、objects.githubusercontent.com 及配置镜像域;解析到内网/保留 IP 一律拒绝(防 SSRF)
+- 流式下载:校验 `Content-Length` 与实际字节、`PK\x03\x04` zip 魔数,失败即删临时文件
+- 解压前扫描 ZipArchive 条目名,拒绝绝对路径、盘符、`..` 段
+- 覆盖前备份并在完成后还原 `config.php`、`cf_up.php`、`MPHX/SQ.php`、`install/install.lock`、`api/cookie/`;包自带的 `install.lock` 若站点原本没有会被删除,避免被判定为未安装
+- `set_time_limit(600)` + `ignore_user_abort` + `register_shutdown_function` 兜底:任何中断路径都会还原改名后的后台目录、还原本地配置并清理临时包
+- 升级 SQL 仅执行包内 `update/update.sql`(通过 `$dbconfig` 连接),执行成功后删除该文件与 `update/` 目录
+
+**发版运维约定**
+
+> 发版时请在 GitHub Release 挂载 zip 附件(内容即为站点根目录文件),否则将走源码包回落路径(自动剥顶层目录)。附件请确保不含本地配置文件的覆盖内容,运行时配置由更新流程的备份还原机制保护。
+
 ## V1.83(当前)
 
 **Docker 容器托管**
diff --git a/templates/default/admin/update.php b/templates/default/admin/update.php
index 903f5d5..df20f1a 100644
--- a/templates/default/admin/update.php
+++ b/templates/default/admin/update.php
@@ -1,81 +1,227 @@
 <?php mnbt_admin_include('head'); ?>
 <?php
-$gxtj = array(
-'url' => $_SERVER['HTTP_HOST'],
-'authcode' => $authcode,
-'ver' => $WEBQB,
-);
-$result = send_post($mn_conf['aet'].'://'.$mn_conf['url'].':'.$mn_conf['port'].'/check.php',$gxtj);
-$content=json_decode($result, true);
-$total ='V'.sprintf( "%.2f ",$WEBQB/1000);
+// $mnbt_update / $mnbt_upcfg 由 admin/update.php 服务端检查后注入
+$upd_ok = !empty($mnbt_update['ok']);
+$upd_can = !empty($mnbt_update['can_update']);
+$upd_log = trim((string)($mnbt_update['body'] ?? ''));
+$upd_mirror_txt = implode("\n", $mnbt_upcfg['mirrors']);
+$upd_token_set = !empty($mnbt_update['has_token']);
 ?>
-<body>
-<div class="container-fluid p-t-16">
+<style>
+/* 系统更新页:直角蓝白,不用蓝底色块与装饰竖条 */
+.mn-upd-page { max-width: 900px; margin: 0 auto; padding: 16px 14px 40px; }
+.mn-upd-card { background: #fff; border: 1px solid #e8ecf1; border-radius: 0; margin-bottom: 14px; }
+.mn-upd-hd { padding: 14px 18px; border-bottom: 1px solid #eef1f5; font-size: 15px; font-weight: 600; color: #0f172a; }
+.mn-upd-hd .mdi { color: #2563eb; margin-right: 6px; }
+.mn-upd-bd { padding: 18px; }
+.mn-upd-ver { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
+.mn-upd-ver-item { flex: 1; min-width: 180px; border: 1px solid #e8ecf1; border-radius: 0; padding: 14px 16px; }
+.mn-upd-ver-num { font-size: 22px; font-weight: 700; color: #1e293b; line-height: 1.2; }
+.mn-upd-ver-num.is-new { color: #2563eb; }
+.mn-upd-ver-label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
+.mn-upd-tip { font-size: 13px; color: #475569; line-height: 1.8; margin-bottom: 14px; }
+.mn-upd-tip.bad { color: #b42318; }
+.mn-upd-kv { width: 100%; border-collapse: collapse; margin-bottom: 6px; }
+.mn-upd-kv td { font-size: 13px; padding: 7px 0; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
+.mn-upd-kv td:first-child { color: #94a3b8; width: 110px; white-space: nowrap; }
+.mn-upd-kv td:last-child { color: #1e293b; word-break: break-all; }
+.mn-upd-log { background: #f8fafc; border: 1px solid #eef1f5; padding: 12px 14px; font-size: 12px; line-height: 1.8; color: #334155; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; margin: 0; border-radius: 0; }
+.mn-upd-log-title { font-size: 13px; font-weight: 600; color: #334155; margin: 0 0 8px; }
+.mn-upd-actions { margin-top: 16px; display: flex; gap: 10px; }
+.mn-upd-actions .btn { border-radius: 0; font-weight: 500; padding: 9px 18px; }
+.mn-upd-field { margin-bottom: 16px; }
+.mn-upd-field > label { display: block; font-size: 13px; font-weight: 500; color: #334155; margin-bottom: 6px; }
+.mn-upd-field .form-control { border-radius: 0; border-color: #e2e8f0; font-size: 13px; }
+.mn-upd-field .form-control:focus { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(37, 99, 235, .1); }
+.mn-upd-field small { display: block; margin-top: 6px; font-size: 12px; color: #94a3b8; line-height: 1.7; }
+.mn-upd-row { display: flex; gap: 8px; margin-bottom: 8px; }
+.mn-upd-row .form-control { flex: 1; }
+.mn-upd-row .btn { border-radius: 0; flex-shrink: 0; }
+.mn-upd-note { background: #f8fafc; border: 1px solid #eef1f5; padding: 12px 14px; font-size: 12px; color: #64748b; line-height: 1.8; }
+.mn-upd-note code { background: #fff; padding: 1px 4px; border: 1px solid #e8ecf1; }
+</style>
 
-<div class="row">
-    <div class="col-lg-8">
-      <div class="card">
-        <header class="card-header"><div class="card-title"><?=$content['msg']?></div></header>
-        <div class="card-body">
-        
-<div class="row">
-    <div class="col-md-6">
-      <div class="card bg-primary text-white">
-        <div class="card-body clearfix">
-          <div class="flex-box">
-            <span class="img-avatar img-avatar-48 bg-translucent"><i class="mdi mdi-dns fs-22"></i></span>
-            <span class="fs-22 lh-22"><?=$total?></span>
-          </div>
-          <div class="text-right">系统版本</div>
+<div class="mn-upd-page">
+
+  <div class="mn-upd-card">
+    <div class="mn-upd-hd"><i class="mdi mdi-cloud-download"></i>检查更新</div>
+    <div class="mn-upd-bd">
+      <div class="mn-upd-ver">
+        <div class="mn-upd-ver-item">
+          <div class="mn-upd-ver-num"><?=htmlspecialchars($mnbt_update['current'])?></div>
+          <div class="mn-upd-ver-label">当前版本</div>
         </div>
+        <div class="mn-upd-ver-item">
+          <div class="mn-upd-ver-num <?=($upd_ok && $upd_can) ? 'is-new' : ''?>"><?=htmlspecialchars($upd_ok ? $mnbt_update['latest'] : '—')?></div>
+          <div class="mn-upd-ver-label">最新可用版本<?=($upd_ok && !empty($mnbt_update['published_at'])) ? '(' . htmlspecialchars(substr((string)$mnbt_update['published_at'], 0, 10)) . ')' : ''?></div>
+        </div>
+      </div>
+
+<?php if (!$upd_ok): ?>
+      <div class="mn-upd-tip bad"><i class="mdi mdi-alert-circle-outline"></i> 检查更新失败:<?=htmlspecialchars($mnbt_update['error'])?>(仓库 <?=htmlspecialchars($mnbt_update['repo'])?>)</div>
+<?php elseif ($upd_can): ?>
+      <div class="mn-upd-tip"><i class="mdi mdi-arrow-up-bold-circle"></i> 发现新版本,可执行在线覆盖更新。</div>
+<?php else: ?>
+      <div class="mn-upd-tip"><i class="mdi mdi-bookmark-check"></i> 当前已是最新版本,无需更新。</div>
+<?php endif; ?>
+
+      <table class="mn-upd-kv">
+        <tr><td>更新仓库</td><td><?=htmlspecialchars($mnbt_update['repo'])?></td></tr>
+        <tr><td>包来源</td><td><?=htmlspecialchars($upd_ok ? $mnbt_update['source_label'] : '—')?><?=($upd_ok && !empty($mnbt_update['asset_size'])) ? '(' . number_format($mnbt_update['asset_size'] / 1048576, 1) . ' MB)' : ''?></td></tr>
+        <tr><td>下载通道</td><td>github.com 直连<?=!empty($mnbt_update['mirrors']) ? ',失败后依次尝试:' . htmlspecialchars(implode('、', $mnbt_update['mirrors'])) : ''?></td></tr>
+<?php if ($upd_ok && !empty($mnbt_update['asset_url'])): ?>
+        <tr><td>附件地址</td><td><?=htmlspecialchars($mnbt_update['asset_url'])?></td></tr>
+<?php endif; ?>
+<?php if (!empty($mnbt_update['fallback'])): ?>
+        <tr><td>检查方式</td><td>latest 接口不可用,已改用发布列表取最高版本</td></tr>
+<?php endif; ?>
+      </table>
+
+<?php if ($upd_ok): ?>
+      <div class="mn-upd-actions">
+<?php if ($upd_can): ?>
+        <button class="btn btn-primary" type="button" onclick="up()"><i class="mdi mdi-update"></i> 立刻更新</button>
+<?php endif; ?>
+        <a class="btn btn-outline-secondary" href="update.php?recheck=1"><i class="mdi mdi-refresh"></i> 重新检查</a>
       </div>
+<?php else: ?>
+      <div class="mn-upd-actions">
+        <a class="btn btn-outline-secondary" href="update.php?recheck=1"><i class="mdi mdi-refresh"></i> 重新检查</a>
+      </div>
+<?php endif; ?>
+    </div>
+  </div>
+
+  <div class="mn-upd-card">
+    <div class="mn-upd-hd"><i class="mdi mdi-text-box-outline"></i>更新日志</div>
+    <div class="mn-upd-bd">
+<?php if ($upd_log !== ''): ?>
+      <pre class="mn-upd-log"><?=htmlspecialchars($upd_log)?></pre>
+<?php else: ?>
+      <p class="mn-upd-tip">该版本发布没有填写更新说明。</p>
+<?php endif; ?>
     </div>
-    
-    <div class="col-md-6">
-      <div class="card bg-purple text-white">
-        <div class="card-body clearfix">
-          <div class="flex-box">
-            <span class="img-avatar img-avatar-48 bg-translucent"><i class="mdi mdi-image-filter-drama fs-22"></i></span>
-            <span class="fs-22 lh-22"><?=$content['ver']?></span>
+  </div>
+
+  <div class="mn-upd-card">
+    <div class="mn-upd-hd"><i class="mdi mdi-cog-outline"></i>更新设置</div>
+    <div class="mn-upd-bd">
+      <div class="mn-upd-field">
+        <label for="upd_repo">GitHub 仓库</label>
+        <input type="text" class="form-control" id="upd_repo" value="<?=htmlspecialchars($mnbt_upcfg['repo'])?>" placeholder="owner/repo" maxlength="140">
+        <small>格式 <code>owner/repo</code>,更新包只能从这个仓库的 Release 里取。</small>
+      </div>
+
+      <div class="mn-upd-field">
+        <label>下载镜像</label>
+        <div id="upd_mirrors">
+<?php foreach ($mnbt_upcfg['mirrors'] as $m): ?>
+          <div class="mn-upd-row">
+            <input type="text" class="form-control upd-mirror-input" value="<?=htmlspecialchars($m)?>" placeholder="https://gh-proxy.com/" maxlength="160">
+            <button class="btn btn-outline-danger" type="button" onclick="updDelMirror(this)"><i class="mdi mdi-close"></i></button>
           </div>
-          <div class="text-right">最新版本</div>
+<?php endforeach; ?>
         </div>
+        <button class="btn btn-outline-secondary" type="button" onclick="updAddMirror()"><i class="mdi mdi-plus"></i> 添加镜像</button>
+        <small>按顺序依次尝试:先 github.com 直连,再按上面的顺序走镜像。镜像地址填 <code>https://gh-proxy.com/</code> 这种带协议的形式,会在原地址前面加前缀。</small>
       </div>
-    </div>
-    
-      <?php if($content['code']=='1'){?>
-<div class="container-fluid p-t-15">
-          <button class="btn btn-primary form-control" type="button" onclick="up()"><label><i class="mdi mdi-arrow-up-bold-circle"></i></label>立刻更新</button>
-   </div>
-   <?php }?>
-      </div>
-    <div class="card-header"></div>
-          <div class="card-body">
-          <p><small><?=$content['uplog']?></small></p>
+
+      <div class="mn-upd-field">
+        <label for="upd_token">GitHub Token(可选)</label>
+        <input type="password" class="form-control" id="upd_token" placeholder="<?=($upd_token_set ? '已设置,留空表示不修改' : '仅用于提高 API 速率限制,可留空')?>" autocomplete="new-password" maxlength="128">
+<?php if ($upd_token_set): ?>
+        <div class="mn-upd-row" style="margin-top:8px">
+          <label style="font-size:12px;color:#64748b;font-weight:normal;margin:0;padding-top:8px;">
+            <input type="checkbox" id="upd_clear_token"> 清除已保存的 Token
+          </label>
         </div>
-    
+<?php endif; ?>
+        <small>Token 只用于调用 GitHub API,不会跟着下载请求发给镜像;页面不会回显明文。</small>
+      </div>
+
+      <div class="mn-upd-actions">
+        <button class="btn btn-primary" type="button" onclick="saveUpdSetting()"><i class="mdi mdi-content-save-outline"></i> 保存更新设置</button>
+      </div>
+
+      <div class="mn-upd-note" style="margin-top:16px;">
+        <b>更新说明:</b>更新会直接用 GitHub Release 的包覆盖站点文件,覆盖前会备份并还原
+        <code>config.php</code>、<code>cf_up.php</code>、<code>MPHX/SQ.php</code>、<code>install/install.lock</code>、<code>api/cookie/</code>;
+        包里若带 <code>update/update.sql</code> 会自动执行。请在维护时段操作,更新过程中不要关闭页面。
       </div>
     </div>
   </div>
+
 </div>
 <script type="text/javascript">
-function up() {
-msloading()  // 加载显示
-let data = {};
-data["gn"]="update";
-$.post('./ajax.php', data, function (date) {    
-var jsoe= JSON.parse(date);    
-var qk= jsoe.code
-if(qk=='更新成功~请手动刷新页面'){
-msalert(1,qk,4000);
-msloadingde();  // 隐藏
-}else{
-msalert(4,qk,4000);
-msloadingde();  // 隐藏
-
+function updAddMirror() {
+  var box = document.getElementById('upd_mirrors');
+  var div = document.createElement('div');
+  div.className = 'mn-upd-row';
+  div.innerHTML = '<input type="text" class="form-control upd-mirror-input" placeholder="https://gh-proxy.com/" maxlength="160">'
+                + '<button class="btn btn-outline-danger" type="button" onclick="updDelMirror(this)"><i class="mdi mdi-close"></i></button>';
+  box.appendChild(div);
+  div.querySelector('input').focus();
 }
-})
+function updDelMirror(btn) {
+  var row = btn.parentNode;
+  var box = document.getElementById('upd_mirrors');
+  if (box.children.length <= 1) {
+    row.querySelector('input').value = '';
+    return;
+  }
+  box.removeChild(row);
+}
+function saveUpdSetting() {
+  var mirrors = [];
+  var list = document.querySelectorAll('#upd_mirrors .upd-mirror-input');
+  for (var i = 0; i < list.length; i++) {
+    var v = $.trim(list[i].value);
+    if (v !== '' && mirrors.indexOf(v) === -1) mirrors.push(v);
+  }
+  var data = {};
+  data['gn'] = 'upset';
+  data['repo'] = $.trim(document.getElementById('upd_repo').value);
+  data['mirrors'] = mirrors.join('\n');
+  var tk = document.getElementById('upd_token');
+  if (tk && tk.value !== '') data['github_token'] = $.trim(tk.value);
+  var cl = document.getElementById('upd_clear_token');
+  if (cl && cl.checked) data['clear_token'] = '1';
+  msloading();
+  $.post('./ajax.php', data, function (date) {
+    msloadingde();
+    var jsoe = JSON.parse(date);
+    if (jsoe.qk == 1 || jsoe.code == '保存成功') {
+      msalert(1, '保存成功', 2500);
+      setTimeout(function () { window.location.href = 'update.php'; }, 1200);
+    } else {
+      msalert(4, jsoe.code || '保存失败', 4000);
+    }
+  }).fail(function () {
+    msloadingde();
+    msalert(4, '网络错误,保存失败', 3000);
+  });
+}
+function up() {
+  if (!window.confirm('将用 GitHub Release 的包覆盖当前站点文件,过程可能需要几分钟,期间请勿关闭页面。确认开始更新?')) return;
+  msloading();
+  let data = {};
+  data["gn"] = "update";
+  $.post('./ajax.php', data, function (date) {
+    msloadingde();
+    var jsoe = JSON.parse(date);
+    var qk = jsoe.code;
+    if (jsoe.qk == 1 || qk == '更新成功~请手动刷新页面') {
+      var txt = '更新成功~请手动刷新页面';
+      if (jsoe.source) txt += '|' + jsoe.source + (jsoe.via ? '(' + jsoe.via + ')' : '');
+      msalert(1, txt, 6000);
+    } else {
+      msalert(4, qk || '更新失败', 8000);
+    }
+  }).fail(function () {
+    msloadingde();
+    msalert(4, '更新请求中断,请刷新后台确认站点是否正常;本地配置与后台目录在中断时会自动还原', 8000);
+  });
 }
 </script>
 </body>
-</html>
\ No newline at end of file
+</html>
diff --git a/templates/tdesign/admin/update.php b/templates/tdesign/admin/update.php
index a11b2f9..67bf652 100644
--- a/templates/tdesign/admin/update.php
+++ b/templates/tdesign/admin/update.php
@@ -1,24 +1,53 @@
 <?php
-// 系统更新:服务器端检查更新(与默认主题 update.php 一致),注入 boot
+// 系统更新:服务器端检查更新(admin/update.php 已检查并注入 $mnbt_update / $mnbt_upcfg),输出注入 boot
 $td_entry = 'update';
 $td_hash  = '#/update';
 $td_inject = [];
 
-// 当前版本
-$td_inject['currentVersion'] = isset($WEBQB) ? 'V' . sprintf('%.2f', $WEBQB / 1000) : 'V0.00';
-
-// 服务器端检查更新
-if (isset($mn_conf['url']) && $mn_conf['url'] && function_exists('send_post')) {
-	$gxtj = array(
-		'url'      => $_SERVER['HTTP_HOST'] ?? '',
-		'authcode' => $authcode ?? '',
-		'ver'      => $WEBQB ?? 0,
-	);
-	$result  = send_post($mn_conf['aet'] . '://' . $mn_conf['url'] . ':' . $mn_conf['port'] . '/check.php', $gxtj);
-	$content = json_decode($result, true);
-	if (is_array($content)) {
-		$td_inject['updateInfo'] = $content;
+if (!isset($mnbt_update) || !is_array($mnbt_update)) {
+	// 直接访问本文件时的兜底(正常入口都会带上来)
+	if (!function_exists('mnbt_updater_check')) {
+		include_once dirname(__DIR__, 3) . '/MPHX/github_updater.php';
 	}
+	$mnbt_update = mnbt_updater_check(!empty($_GET['recheck']), 60);
+}
+if (!isset($mnbt_upcfg) || !is_array($mnbt_upcfg)) {
+	$mnbt_upcfg = mnbt_updater_config();
 }
 
+$td_inject['currentVersion'] = $mnbt_update['current'];
+
+// code/ver/msg/uplog 是给已编译的 dist 用的老字段,保持原语义不能改
+$td_info = [
+	'code'  => $mnbt_update['ok'] != 1 ? '-1' : ($mnbt_update['can_update'] == 1 ? '1' : '0'),
+	'ver'   => $mnbt_update['ok'] == 1 ? $mnbt_update['latest'] : '-',
+	'msg'   => $mnbt_update['ok'] != 1
+		? ('暂时无法检查更新:' . $mnbt_update['error'])
+		: ($mnbt_update['can_update'] == 1 ? '发现新版本,可执行在线覆盖更新' : '当前版本已是最新,无需更新'),
+	'uplog' => (string)$mnbt_update['body'],
+	// 新增字段:下载来源与配置(老 dist 会忽略)
+	'repo'         => $mnbt_update['repo'],
+	'tag'          => $mnbt_update['tag'],
+	'name'         => $mnbt_update['name'],
+	'source'       => $mnbt_update['source'],
+	'sourceLabel'  => $mnbt_update['source_label'],
+	'assetName'    => $mnbt_update['asset_name'],
+	'assetUrl'     => $mnbt_update['asset_url'],
+	'assetSize'    => $mnbt_update['asset_size'],
+	'publishedAt'  => $mnbt_update['published_at'],
+	'canUpdate'    => (int)$mnbt_update['can_update'],
+	'error'        => $mnbt_update['error'],
+	'fallback'     => (int)$mnbt_update['fallback'],
+	'currentVer'   => $mnbt_update['current'],
+	'mirrors'      => array_values((array)$mnbt_update['mirrors']),
+];
+$td_inject['updateInfo'] = $td_info;
+
+// 更新设置:Token 只回显是否已设置,绝不回显明文
+$td_inject['updaterConfig'] = [
+	'repo'      => $mnbt_upcfg['repo'],
+	'mirrors'   => array_values($mnbt_upcfg['mirrors']),
+	'hasToken'  => $mnbt_upcfg['github_token'] !== '' ? 1 : 0,
+];
+
 include __DIR__ . '/_spa_boot.php';
diff --git a/templates/tdesign/spa/src/admin/api/dashboard.js b/templates/tdesign/spa/src/admin/api/dashboard.js
index cb06021..73d29c8 100644
--- a/templates/tdesign/spa/src/admin/api/dashboard.js
+++ b/templates/tdesign/spa/src/admin/api/dashboard.js
@@ -17,6 +17,16 @@ export function systemUpdate() {
   return apiGn('update', {})
 }
 
+/** 重新检查更新(跳过服务端缓存,返回 GitHub Release 检查结果原始载荷) */
+export function updaterCheck(force = 1) {
+  return apiGn('upcheck', { force }, { silent: true })
+}
+
+/** 保存更新设置(仓库 / 镜像 / 可选 Token) */
+export function saveUpdaterConfig(data) {
+  return apiGn('upset', data)
+}
+
 /** 系统修复 */
 export function systemRepair(xx, xe) {
   return apiGn('xtxf', { xx, xe })
diff --git a/templates/tdesign/spa/src/admin/views/UpdateView.vue b/templates/tdesign/spa/src/admin/views/UpdateView.vue
index 4eb0741..5f1f0dd 100644
--- a/templates/tdesign/spa/src/admin/views/UpdateView.vue
+++ b/templates/tdesign/spa/src/admin/views/UpdateView.vue
@@ -62,6 +62,16 @@
               <i class="mdi mdi-update"></i> 立刻更新
             </t-button>
           </div>
+          <div class="td-set-card-bd">
+            <h5 class="upd-section">下载来源</h5>
+            <table class="upd-kv">
+              <tr><td>更新仓库</td><td>{{ info.repo }}</td></tr>
+              <tr><td>包来源</td><td>{{ info.sourceLabel }}<template v-if="info.assetSize">({{ mb(info.assetSize) }})</template></td></tr>
+              <tr><td>下载通道</td><td>github.com 直连<template v-if="info.mirrors && info.mirrors.length">,失败后依次尝试:{{ info.mirrors.join('、') }}</template></td></tr>
+              <tr v-if="info.publishedAt"><td>发布时间</td><td>{{ String(info.publishedAt).slice(0, 10) }}</td></tr>
+              <tr v-if="info.fallback"><td>检查方式</td><td>latest 接口不可用,已改用发布列表取最高版本</td></tr>
+            </table>
+          </div>
           <div class="td-set-card-bd" v-if="info.uplog">
             <h5 class="upd-section">更新日志</h5>
             <pre class="td-code-block upd-log">{{ info.uplog }}</pre>
@@ -79,33 +89,92 @@
               <p>{{ info.msg || '当前版本已是最新,无需更新' }}</p>
             </div>
           </div>
+          <div class="td-set-card-bd">
+            <h5 class="upd-section">下载来源</h5>
+            <table class="upd-kv">
+              <tr><td>更新仓库</td><td>{{ info.repo }}</td></tr>
+              <tr><td>包来源</td><td>{{ info.sourceLabel }}</td></tr>
+              <tr><td>下载通道</td><td>github.com 直连<template v-if="info.mirrors && info.mirrors.length">,失败后依次尝试:{{ info.mirrors.join('、') }}</template></td></tr>
+            </table>
+          </div>
           <div class="td-set-card-bd" v-if="info.uplog">
             <h5 class="upd-section">版本日志</h5>
             <pre class="td-code-block upd-log">{{ info.uplog }}</pre>
           </div>
         </div>
 
-        <!-- 离线模式 -->
+        <!-- 检查失败 -->
         <div v-else class="td-set-card">
           <div class="td-set-card-hd">
             <div class="td-set-icon" style="background: #f5f6f8; color: #8c8c8c">
-              <i class="mdi mdi-account-off"></i>
+              <i class="mdi mdi-cloud-off"></i>
             </div>
             <div>
-              <h4>{{ info.msg || '离线模式' }}</h4>
-              <p>离线模式不提供更新服务</p>
+              <h4>{{ info.msg || '暂时无法检查更新' }}</h4>
+              <p>更新只走 GitHub Release,请检查服务器能否访问 api.github.com 或更新设置里的仓库与镜像</p>
             </div>
           </div>
         </div>
       </template>
+
+      <!-- 更新设置 -->
+      <div class="td-set-card">
+        <div class="td-set-card-hd">
+          <div class="td-set-icon" style="background: #eff6ff; color: #2563eb">
+            <i class="mdi mdi-cog-outline"></i>
+          </div>
+          <div>
+            <h4>更新设置</h4>
+            <p>GitHub 仓库、下载镜像与可选 Token</p>
+          </div>
+        </div>
+        <div class="td-set-card-bd">
+          <div class="upd-field">
+            <label>GitHub 仓库</label>
+            <t-input v-model="form.repo" placeholder="owner/repo" :maxlength="140" />
+            <p class="upd-hint">更新包只能从这个仓库的 Release 里取。</p>
+          </div>
+          <div class="upd-field">
+            <label>下载镜像</label>
+            <div v-for="(m, i) in form.mirrors" :key="i" class="upd-mirror-row">
+              <t-input v-model="form.mirrors[i]" placeholder="https://gh-proxy.com/" :maxlength="160" />
+              <t-button theme="default" variant="outline" @click="delMirror(i)">
+                <i class="mdi mdi-close"></i>
+              </t-button>
+            </div>
+            <t-button theme="default" variant="outline" @click="addMirror">
+              <i class="mdi mdi-plus"></i> 添加镜像
+            </t-button>
+            <p class="upd-hint">先 github.com 直连,再按上面的顺序依次尝试镜像;镜像地址会在原始 GitHub 地址前面加前缀。</p>
+          </div>
+          <div class="upd-field">
+            <label>GitHub Token(可选)</label>
+            <t-input v-model="form.token" type="password" :placeholder="form.hasToken ? '已设置,留空表示不修改' : '仅用于提高 API 速率限制,可留空'" :maxlength="128" />
+            <label v-if="form.hasToken" class="upd-clear">
+              <input type="checkbox" v-model="form.clearToken" /> 清除已保存的 Token
+            </label>
+            <p class="upd-hint">Token 只用于调用 GitHub API,不会跟着下载请求发给镜像,页面也不回显明文。</p>
+          </div>
+          <div class="upd-actions">
+            <t-button theme="primary" :loading="saving" @click="saveSetting">
+              <i class="mdi mdi-content-save-outline"></i> 保存更新设置
+            </t-button>
+          </div>
+          <p class="upd-note">
+            更新会直接用 GitHub Release 的包覆盖站点文件,覆盖前会备份并还原
+            <code>config.php</code>、<code>cf_up.php</code>、<code>MPHX/SQ.php</code>、<code>install/install.lock</code>、<code>api/cookie/</code>;
+            包里若带 <code>update/update.sql</code> 会自动执行。请在维护时段操作。
+          </p>
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed } from 'vue'
+import { ref, reactive, computed } from 'vue'
 import { MessagePlugin } from 'tdesign-vue-next'
-import { systemUpdate } from '@/admin/api/dashboard'
+import { systemUpdate, updaterCheck, saveUpdaterConfig } from '@/admin/api/dashboard'
 
 const boot = window.__TD_BOOT__ || {}
 
@@ -122,6 +191,32 @@ const latestVersion = computed(() => {
 })
 
 const updating = ref(false)
+const saving = ref(false)
+
+const cfg = boot.updaterConfig || {}
+const form = reactive({
+  repo: cfg.repo || '',
+  mirrors: Array.isArray(cfg.mirrors) ? cfg.mirrors.slice() : [],
+  token: '',
+  hasToken: Number(cfg.hasToken) === 1,
+  clearToken: false,
+})
+
+function mb(bytes) {
+  return (Number(bytes) / 1048576).toFixed(1) + ' MB'
+}
+
+function addMirror() {
+  form.mirrors.push('')
+}
+
+function delMirror(i) {
+  if (form.mirrors.length <= 1) {
+    form.mirrors[0] = ''
+    return
+  }
+  form.mirrors.splice(i, 1)
+}
 
 async function doUpdate() {
   updating.value = true
@@ -137,9 +232,63 @@ async function doUpdate() {
   }
 }
 
-function recheck() {
-  // 重新检查需要重新请求 PHP(服务器端 send_post)
-  window.location.reload()
+async function recheck() {
+  const r = await updaterCheck()
+  if (r.ok && r.data && typeof r.data === 'object') {
+    info.value = normalizeInfo(r.data)
+    if (r.data.current) currentVersion.value = r.data.current
+    MessagePlugin.success('已重新检查')
+  } else {
+    // 接口不可用时退回整页重查
+    window.location.href = 'update.php?recheck=1'
+  }
+}
+
+async function saveSetting() {
+  const mirrors = form.mirrors.map((m) => String(m || '').trim()).filter((m, i, arr) => m !== '' && arr.indexOf(m) === i)
+  const payload = { repo: form.repo.trim(), mirrors }
+  if (form.token && form.token.trim() !== '') payload.github_token = form.token.trim()
+  if (form.clearToken) payload.clear_token = '1'
+  saving.value = true
+  const r = await saveUpdaterConfig(payload)
+  saving.value = false
+  if (r.ok) {
+    MessagePlugin.success('保存成功')
+    form.token = ''
+    form.clearToken = false
+    form.mirrors = mirrors.length ? mirrors.slice() : ['']
+    setTimeout(() => window.location.reload(), 1200)
+  } else {
+    MessagePlugin.error(r.message || '保存失败')
+  }
+}
+
+// 后端 upcheck 返回的是原始检查结果,转成页面用的 updateInfo 结构
+function normalizeInfo(d) {
+  const ok = Number(d.ok) === 1
+  const can = Number(d.can_update) === 1
+  return {
+    code: ok ? (can ? '1' : '0') : '-1',
+    ver: ok ? d.latest : '-',
+    msg: !ok
+      ? '暂时无法检查更新:' + (d.error || '')
+      : can
+        ? '发现新版本,可执行在线覆盖更新'
+        : '当前版本已是最新,无需更新',
+    uplog: d.body || '',
+    repo: d.repo,
+    tag: d.tag,
+    name: d.name,
+    source: d.source,
+    sourceLabel: d.source_label,
+    assetName: d.asset_name,
+    assetUrl: d.asset_url,
+    assetSize: d.asset_size,
+    publishedAt: d.published_at,
+    fallback: d.fallback,
+    mirrors: d.mirrors || [],
+    currentVer: d.current,
+  }
 }
 </script>
 
@@ -203,4 +352,64 @@ function recheck() {
   overflow-y: auto;
   margin: 0;
 }
+.upd-kv {
+  width: 100%;
+  border-collapse: collapse;
+}
+.upd-kv td {
+  font-size: 13px;
+  padding: 6px 0;
+  border-bottom: 1px solid #f1f5f9;
+  color: var(--td-text);
+  word-break: break-all;
+}
+.upd-kv td:first-child {
+  color: var(--td-text-secondary);
+  width: 96px;
+  white-space: nowrap;
+}
+.upd-field {
+  margin-bottom: 16px;
+}
+.upd-field > label {
+  display: block;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--td-text);
+  margin-bottom: 6px;
+}
+.upd-mirror-row {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+.upd-clear {
+  display: block;
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--td-text-secondary);
+}
+.upd-hint {
+  margin: 6px 0 0;
+  font-size: 12px;
+  color: var(--td-text-secondary);
+  line-height: 1.7;
+}
+.upd-actions {
+  margin-top: 6px;
+}
+.upd-note {
+  margin: 16px 0 0;
+  padding: 12px 14px;
+  background: #f8fafc;
+  border: 1px solid #eef1f5;
+  font-size: 12px;
+  line-height: 1.8;
+  color: var(--td-text-secondary);
+}
+.upd-note code {
+  background: #fff;
+  border: 1px solid #e8ecf1;
+  padding: 1px 4px;
+}
 </style>