仰望星辰工作室

fix(zjmfmanager): 加固密码存储与主机开通链路安全

Y yang1145 提交于 2026-09-13 22:10 · 56f613b ·父提交 a306823
fix(zjmfmanager): 加固密码存储与主机开通链路安全

1. 密码加密改为AES-256-GCM(v2:前缀,存量authcode兼容解密),
   密钥落runtime/zjmf/(web不可达),详情页改掩码+按需查看路由
2. 主机回填移除产品名+日期猜测匹配,改用开通时生成的host标识
   与上游domain精确相等绑定;用户端GET不再触发自动写库
3. 商品描述HTML白名单过滤,host.php两处输出转义防XSS
4. purchase跳过余额支付前经invoiceInfo确认账单已付且有主机;
   cartClear失败须确认购物车为空或仅含本次商品才继续结算
5. 开通/指派改为先建主机成功再标记opened,失败保持paid可重试
6. 状态映射合并为单一表并补off/unpaid/deleted分支,关机不再误写
   suspend,改为操作后拉取上游真实状态回写
7. api_password加密存储,JWT缓存目录加.htaccess,SSL校验默认开启,
   日志敏感键脱敏,商品分组名补join,schema改option标记执行
4 个文件变更 +482 -119 13172193298@163.com
•app_plugins/zjmfmanager_reserve/bootstrap.php +80 -17
•app_plugins/zjmfmanager_reserve/lib/upstream.php +116 -24
•app_plugins/zjmfmanager_reserve/lib/zjmf.php +237 -68
•app_plugins/zjmfmanager_reserve/views/host.php +49 -10
变更内容
diff --git a/app_plugins/zjmfmanager_reserve/bootstrap.php b/app_plugins/zjmfmanager_reserve/bootstrap.php
index 3401a92..99d918b 100644
--- a/app_plugins/zjmfmanager_reserve/bootstrap.php
+++ b/app_plugins/zjmfmanager_reserve/bootstrap.php
@@ -19,13 +19,21 @@ if (!defined('IN_CRONLITE')) {
 require_once __DIR__ . '/lib/zjmf.php';
 require_once __DIR__ . '/lib/upstream.php';
 
-// 确保插件数据表存在:修复历史版本安装时 install.sql 首段(注释 + CREATE TABLE)
-// 被 mnbt_plugin_run_sql_file 整体跳过导致缺表(如 MN_plugin_zjmf_supplier)的问题。
-// install.sql 全部为 IF NOT EXISTS 建表,幂等,可安全重复执行。
+// 确保插件数据表存在:用 option 标记一次性执行,避免每请求都跑一遍 install.sql。
+// 建表语句全部 IF NOT EXISTS,幂等;schema 版本变更时调大
+// ZJMF_SCHEMA_VERSION 即会重跑一次(对齐仓库插件 option 标记惯例)。
+define('ZJMF_SCHEMA_VERSION', '1');
 static $zjmf_tables_ready = false;
 if (!$zjmf_tables_ready && function_exists('mnbt_plugin_run_sql_file')) {
 	$zjmf_tables_ready = true;
-	mnbt_plugin_run_sql_file(__DIR__ . '/install.sql');
+	$doneVer = function_exists('mnbt_plugin_option_get')
+		? (string)mnbt_plugin_option_get('zjmfmanager_reserve', 'schema_version', '') : '';
+	if ($doneVer !== ZJMF_SCHEMA_VERSION) {
+		mnbt_plugin_run_sql_file(__DIR__ . '/install.sql');
+		if (function_exists('mnbt_plugin_option_set')) {
+			mnbt_plugin_option_set('zjmfmanager_reserve', 'schema_version', ZJMF_SCHEMA_VERSION);
+		}
+	}
 }
 
 mnbt_plugin_register('zjmfmanager_reserve', [
@@ -141,6 +149,11 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
 	if (mb_strlen($url) > 255) {
 		json_exit_error('站点 URL 过长');
 	}
+	// http:// 上游为明文传输,凭据可被截获,落一条管理员可见的告警日志
+	if ($url !== '' && stripos($url, 'http://') === 0) {
+		@error_log('[zjmfmanager_reserve] 警告:供应商「' . $name . '」使用不加密的'
+			. ' http:// 上游地址,API 凭据可能被明文传输,建议改用 https://');
+	}
 	if ($url !== '' && $username === '') {
 		json_exit_error('请填写 API 用户名');
 	}
@@ -162,7 +175,8 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
 		         $status, $sort, $remark, $now];
 		if ($password !== '') {
 			$sql .= ", api_password=?";
-			$args[] = $password;
+			// API 密钥加密入库(读取处 ZjmfUpstream::client 统一解密)
+			$args[] = zjmf_encrypt($password);
 		}
 		$sql .= " WHERE id=?";
 		$args[] = $id;
@@ -176,7 +190,7 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
 			 (name, api_url, api_username, api_password, api_timeout,
 			  markup_type, markup_value, status, sort, remark, created_at, updated_at)
 			 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
-			[$name, $url, $username, $password, $timeout, $markupType,
+			[$name, $url, $username, zjmf_encrypt($password), $timeout, $markupType,
 			 $markupValue, $status, $sort, $remark, $now, $now]
 		);
 		if (!$ok) {
@@ -471,10 +485,9 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
 			continue;
 		}
 		$order_id = (int)$order['order_id'];
-		zjmf_order_fill_opened($order_id, 0, $upHostId, $account);
-		zjmf_order_set_status($order_id, 'opened', '管理员指派');
 
-		// 写本地主机映射(绑定该上游机器)
+		// 写本地主机映射(绑定该上游机器)——先建主机,成功后再标记订单
+		// opened,避免主机写入失败时订单停留在已开通的中间态
 		$hostId = zjmf_host_create([
 			'supplier_id'   => (int)$supplier['id'],
 			'user_id'       => (int)$user['id'],
@@ -489,6 +502,7 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
 			'renew_date'    => zjmf_normalize_date($renew),
 		]);
 		if ($hostId <= 0) {
+			zjmf_order_set_status($order_id, 'failed', '本地主机映射写入失败');
 			$results[] = [
 				'up_host_id' => $upHostId,
 				'domain'     => (string)($upRow['domain'] ?? ''),
@@ -499,6 +513,10 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
 			continue;
 		}
 
+		// 主机映射写入成功后再回填订单并标记 opened
+		zjmf_order_fill_opened($order_id, 0, $upHostId, $account);
+		zjmf_order_set_status($order_id, 'opened', '管理员指派');
+
 		zjmf_log((int)$user['id'], (string)($order['order_no'] ?? ''), 'assign', 'success',
 			json_encode(['up_host_id' => $upHostId, 'assign_by' => 'admin'], JSON_UNESCAPED_UNICODE),
 			(int)$supplier['id']);
@@ -1042,8 +1060,8 @@ mnbt_register_route('GET', '/reserve/api/hosts', function ($params, $ctx) {
 	}
 	$hosts = [];
 	foreach (zjmf_host_list_by_user((int)$user['id']) as $h) {
-		// 缺失上游主机 ID 时尝试从上游主机列表补齐(同一次请求只拉一次上游列表)
-		$h = zjmf_backfill_host_upid($h);
+		// 注:缺失上游主机 ID 时的回填已收敛到开通流程内/管理端,
+		// 用户端 GET 请求不再触发任何自动写库
 		$supplier = zjmf_supplier_get((int)$h['supplier_id']);
 		$hosts[] = [
 			'id'            => (int)$h['id'],
@@ -1092,11 +1110,10 @@ mnbt_register_route('GET', '/reserve/orders', function ($params, $ctx) {
 mnbt_register_route('GET', '/reserve/hosts', function ($params, $ctx) {
 	$user = zjmf_require_user();
 	$hosts = zjmf_host_list_by_user((int)$user['id']);
-	// 列表页顺带补齐缺失的上游主机 ID(同一次请求只拉一次上游列表)
+	// 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+	// (回填上游主机 ID 已收敛到开通流程内/管理端,GET 请求不写库)
 	foreach ($hosts as $i => $h) {
-		$hosts[$i] = zjmf_backfill_host_upid($h);
-		// 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
-		$hosts[$i]['renew_date'] = zjmf_normalize_date((string)$hosts[$i]['renew_date']);
+		$hosts[$i]['renew_date'] = zjmf_normalize_date((string)$h['renew_date']);
 	}
 	zjmf_render('hosts', [
 		'page_title' => '我的主机',
@@ -1115,9 +1132,8 @@ mnbt_register_route('GET', '/reserve/hosts/{host_id}', function ($params, $ctx)
 		return;
 	}
 
-	// 缺失上游主机 ID 时尝试补齐(开通结算未解析出 ID 的历史数据)
-	$host = zjmf_backfill_host_upid($host);
 	// 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+	// (回填上游主机 ID 已收敛到开通流程内/管理端,GET 请求不写库)
 	$host['renew_date'] = zjmf_normalize_date((string)$host['renew_date']);
 
 	// 实时信息(失败不致命,仅展示缓存)
@@ -1284,6 +1300,15 @@ mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx)
 	if ($status !== '') {
 		zjmf_host_update_cache((int)$host['id'], ['status' => $status]);
 	}
+	// 电源类操作后主动刷新上游真实状态回写:本地主机表无独立电源字段,
+	// 关机不写 suspend(避免缓存状态与上游 domainstatus 脱节),以上游为准
+	if (in_array($action, ['on', 'off', 'reboot'], true)) {
+		$fresh = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+		if (!empty($fresh['ok']) && $fresh['status'] !== 'unknown'
+			&& $fresh['status'] !== $host['status']) {
+			zjmf_host_update_cache((int)$host['id'], ['status' => $fresh['status']]);
+		}
+	}
 	if ($action === 'reset_password' && $extra['password'] !== '') {
 		global $DB, $date;
 		$now = $date ?: date('Y-m-d H:i:s');
@@ -1296,6 +1321,44 @@ mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx)
 	zjmf_json('ok', ['msg' => '操作成功']);
 });
 
+// 主机密码查看:详情页默认仅展示掩码,用户点击"显示密码"后经本路由
+// 按需解密返回(每次查看记录操作日志),页面不再无条件渲染明文密码
+mnbt_register_route('POST', '/reserve/api/host_password', function ($params, $ctx) {
+	$user = zjmf_require_user();
+
+	$host_id = (int)($_POST['host_id'] ?? 0);
+	$host = zjmf_host_get_by_user((int)$user['id'], $host_id);
+	if (!$host) {
+		zjmf_json('主机不存在');
+	}
+
+	$password = zjmf_decrypt((string)$host['password']);
+	$source = 'local';
+	// 本地未存密码(历史数据)且可查上游时,回退拉取上游实时密码
+	if ($password === '' && (int)$host['up_host_id'] > 0) {
+		$supplier = zjmf_supplier_get((int)$host['supplier_id']);
+		if ($supplier) {
+			try {
+				$info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+				if (!empty($info['ok'])) {
+					$password = (string)($info['data']['password'] ?? '');
+					$source = 'upstream';
+				}
+			} catch (Throwable $e) {
+				// 上游查询失败按无密码处理
+			}
+		}
+	}
+
+	$hostOrder = zjmf_order_get((int)$host['order_id']);
+	zjmf_log((int)$user['id'], $hostOrder ? $hostOrder['order_no'] : '',
+		'view_password', 'success',
+		json_encode(['host_id' => (int)$host['id'], 'source' => $source], JSON_UNESCAPED_UNICODE),
+		(int)$host['supplier_id']);
+
+	zjmf_json('ok', ['password' => $password]);
+});
+
 /* ============================================================
  *  升级(配置升级 / 产品升降级)
  * ============================================================ */
diff --git a/app_plugins/zjmfmanager_reserve/lib/upstream.php b/app_plugins/zjmfmanager_reserve/lib/upstream.php
index 5690c51..a659b38 100644
--- a/app_plugins/zjmfmanager_reserve/lib/upstream.php
+++ b/app_plugins/zjmfmanager_reserve/lib/upstream.php
@@ -55,13 +55,30 @@ class ZjmfUpstream
 		$supplierId = (int)($supplier['id'] ?? 0);
 		$cacheDir = mnbt_plugin_path('zjmfmanager_reserve')
 			. 'runtime/cache/s' . $supplierId;
+		// JWT 缓存目录防 Web 直接访问:.htaccess 拒绝 + 空 index.html 防目录列举
+		if (!is_dir($cacheDir)) {
+			@mkdir($cacheDir, 0755, true);
+		}
+		$ht = $cacheDir . '/.htaccess';
+		if (!is_file($ht)) {
+			@file_put_contents($ht, "Deny from all\n");
+		}
+		$ix = $cacheDir . '/index.html';
+		if (!is_file($ix)) {
+			@file_put_contents($ix, '');
+		}
+		// 解密 API 密钥:新数据为 v2: AES 密文;历史数据为明文直取
+		// (不经 authcode 解密,避免把明文误当乱码密文解出无效值)
+		$apiPassword = strpos($password, 'v2:') === 0 ? zjmf_decrypt($password) : $password;
 		return new CubeFinanceClient([
 			'url'        => $apiUrl,
 			'username'   => $username,
-			'password'   => $password,
+			'password'   => $apiPassword,
 			'timeout'    => $t,
 			'cache_dir'  => $cacheDir,
-			'verify_ssl' => false,
+			// TODO: install.sql 供应商表暂无 verify_ssl 配置字段,先默认强制
+			// 校验证书;后续加字段后改为按供应商配置读取(默认 true)
+			'verify_ssl' => true,
 		]);
 	}
 
@@ -490,20 +507,26 @@ class ZjmfUpstream
 		try {
 			// 0. 清空购物车:该版本 settle(checkout=1) 会结算整辆购物车,
 			//    若残留历史测试商品会把多件一起结算开通(曾实测一次开出多台机器)。
-			//    先清空保证本次结算只涉及刚添加的这一件商品。
+			//    清空失败时确认购物车为空或仅含本次商品才继续,否则中断开通,
+			//    避免残留项被一起结算。
 			try {
 				$client->cartClear();
 			} catch (CubeFinanceException $e) {
-				// 清空失败不致命,继续尝试(可能购物车本就为空)
+				if (!self::cartSafeForSettle($client, $upProductId)) {
+					return ['ok' => false, 'msg' => '上游购物车清空失败且无法确认购物车为空,'
+						. '为避免残留商品被一起结算已中断本次开通:' . $e->getMessage()];
+				}
 			}
 
 			// 1. 添加产品至购物车(官方:POST /cart/add_to_shop)→ data.i 购物车位置
 			$upCycle = self::upstreamCycle((int)($order['supplier_id'] ?? 0), $upProductId, $cycle);
+			$genHost = (string)($extra['host'] ?? '') !== ''
+				? (string)$extra['host'] : self::randHost();
 			$addParams = [
 				'pid'          => $upProductId,
 				'billingcycle' => $upCycle,
 				'qty'          => 1,
-				'host'         => (string)($extra['host'] ?? self::randHost()),
+				'host'         => $genHost,
 				'password'     => (string)($extra['password'] ?? self::randPassword()),
 			];
 			foreach (['configoption', 'customfield', 'serverid', 'os', 'currencyid'] as $k) {
@@ -575,8 +598,18 @@ class ZjmfUpstream
 			$invoiceId = self::findId($checkoutData);
 			$hostId = self::findHostId($checkoutData);
 
-			// 3. 使用余额支付账单(官方:POST /apply_credit)
+			// 3. 使用余额支付账单(官方:POST /apply_credit)。
+			//    仅当账单确认已支付且已生成主机记录时才跳过支付,防止把结算
+			//    响应中其他 ID 误判为主机 ID 而漏付;确认失败则正常走支付。
+			$skipPay = false;
 			if ($invoiceId > 0 && $hostId <= 0) {
+				$paid = self::invoiceInfo($client, $invoiceId, 1);
+				if ($paid && self::isPaidStatus($paid['status']) && (int)$paid['host_id'] > 0) {
+					$skipPay = true;
+					$hostId = (int)$paid['host_id'];
+				}
+			}
+			if ($invoiceId > 0 && !$skipPay) {
 				$credit = $client->post('apply_credit', [
 					'invoiceid' => $invoiceId,
 					'use_credit' => 1,
@@ -586,6 +619,7 @@ class ZjmfUpstream
 					// 账单可能已被自动扣款,确认已支付后再继续
 					$info = self::invoiceInfo($client, $invoiceId, 1);
 					if (!$info || !self::isPaidStatus($info['status'])) {
+						// 支付未确认:明确失败(订单将被标 failed),不得继续当作开通成功
 						return ['ok' => false, 'msg' => self::respErr('上游余额支付失败', $credit)];
 					}
 				}
@@ -607,6 +641,8 @@ class ZjmfUpstream
 					'msg'         => '开通成功',
 					'up_order_id' => $invoiceId,
 					'up_host_id'  => $hostId,
+					// 本次开通使用的本地生成主机标识(host 参数,供确定性回填)
+					'host'        => $genHost,
 					'username'    => $header['username'],
 					'password'    => $header['password'],
 					'name'        => $header['name'],
@@ -620,6 +656,8 @@ class ZjmfUpstream
 				'msg'         => '上游订单已创建,但未返回主机 ID,请到上游后台核对',
 				'up_order_id' => $invoiceId,
 				'up_host_id'  => 0,
+				// 同上,保存主机标识供回填确定性匹配
+				'host'        => $genHost,
 				'username'    => '',
 				'password'    => '',
 				'name'        => (string)($order['product_name'] ?? ''),
@@ -1699,6 +1737,40 @@ class ZjmfUpstream
 		return $lastPid; // 仅 pid 匹配的最后一个
 	}
 
+	/**
+	 * 购物车是否可安全结算:为空或仅含本次商品。
+	 * 用于 cartClear 失败后的兜底确认;购物车数据拉取失败视为不可确认。
+	 *
+	 * @param CubeFinanceClient $client
+	 * @param int               $upProductId 本次开通的上游商品 ID
+	 * @return bool true=可继续结算
+	 */
+	protected static function cartSafeForSettle($client, $upProductId)
+	{
+		try {
+			$res = $client->cartGetShopData();
+		} catch (CubeFinanceException $e) {
+			return false; // 无法确认购物车内容,不可继续
+		}
+		if (!self::respOk($res)) {
+			return false;
+		}
+		$products = $res['data']['cart_products'] ?? null;
+		if (!is_array($products)) {
+			return false; // 结构异常,无法确认
+		}
+		foreach ($products as $p) {
+			if (!is_array($p)) {
+				continue;
+			}
+			$pid = (string)($p['productid'] ?? $p['pid'] ?? $p['id'] ?? '');
+			if ($pid !== (string)$upProductId) {
+				return false; // 含本次商品之外的项目,不可继续
+			}
+		}
+		return true;
+	}
+
 	/**
 	 * 从加购响应中取购物车位置 data.i(兼容字符串 data、其他位置键、一层嵌套)。
 	 *
@@ -1754,14 +1826,16 @@ class ZjmfUpstream
 		return in_array($st, [200, 1001], true);
 	}
 
-	/** 组装上游失败详情(msg + data 截断),避免日志里只有泛化文案。 */
+	/** 组装上游失败详情(msg + data 截断),避免日志里只有泛化文案。
+	 *  data 中 password/pass/pwd/token/secret 等键的值脱敏后再入日志。 */
 	protected static function respErr($prefix, $res)
 	{
 		$msg = (string)($res['msg'] ?? '');
 		$data = $res['data'] ?? null;
 		$detail = '';
 		if (is_array($data) || is_scalar($data)) {
-			$json = json_encode($data, JSON_UNESCAPED_UNICODE);
+			$safeData = is_array($data) ? self::maskSecrets($data) : $data;
+			$json = json_encode($safeData, JSON_UNESCAPED_UNICODE);
 			if (is_string($json)) {
 				$detail = ' data=' . self::truncate($json, 300);
 			}
@@ -1770,6 +1844,30 @@ class ZjmfUpstream
 		return $out !== '' ? $out : $prefix;
 	}
 
+	/** 递归脱敏:键名含 password/pass/pwd/token/secret 的值替换为 ***。 */
+	protected static function maskSecrets(array $data)
+	{
+		$out = [];
+		foreach ($data as $k => $v) {
+			$lk = strtolower((string)$k);
+			$sensitive = $lk !== '' && (
+				strpos($lk, 'password') !== false
+				|| strpos($lk, 'pass') !== false
+				|| strpos($lk, 'pwd') !== false
+				|| strpos($lk, 'token') !== false
+				|| strpos($lk, 'secret') !== false
+			);
+			if ($sensitive) {
+				$out[$k] = '***';
+			} elseif (is_array($v)) {
+				$out[$k] = self::maskSecrets($v);
+			} else {
+				$out[$k] = $v;
+			}
+		}
+		return $out;
+	}
+
 	/** 截断字符串(mb_substr 不可用时回退 substr)。 */
 	protected static function truncate($str, $len)
 	{
@@ -1810,13 +1908,15 @@ class ZjmfUpstream
 		return 0;
 	}
 
-	/** 从 data 中找主机 ID(支持嵌套 host 与 hostid 数组)。 */
+	/** 从 data 中找主机 ID(支持嵌套 host 与 hostid 数组)。
+	 *  不含 'id' 兜底键:结算响应 data.id 通常是账单/订单 ID,曾被误判
+	 *  为主机 ID 导致跳过支付(M3 误判)。 */
 	protected static function findHostId($arr)
 	{
 		if (!is_array($arr)) {
 			return 0;
 		}
-		foreach (['host_id', 'hostid', 'hid', 'id'] as $k) {
+		foreach (['host_id', 'hostid', 'hid'] as $k) {
 			if (isset($arr[$k])) {
 				$v = $arr[$k];
 				if (is_array($v)) {
@@ -1955,7 +2055,11 @@ class ZjmfUpstream
 		}
 	}
 
-	/** 上游主机状态 → 本地展示状态(active/suspend/pending/terminated/unknown)。 */
+	/**
+	 * 上游主机数据 → 本地展示状态(active/suspend/pending/terminated/unknown)。
+	 * 状态映射委托统一实现 zjmf_map_upstream_status(lib/zjmf.php,主表),
+	 * 无状态字段时用 qk 兜底(false 视为不可用)。
+	 */
 	public static function mapHostStatus($data)
 	{
 		if (!is_array($data)) {
@@ -1963,19 +2067,7 @@ class ZjmfUpstream
 		}
 		$st = strtolower(trim((string)($data['status'] ?? $data['domainstatus'] ?? '')));
 		if ($st !== '') {
-			if (in_array($st, ['active', 'on', 'true', 'completed', '运行中'], true)) {
-				return 'active';
-			}
-			if (in_array($st, ['pending', 'wait', 'waiting', '待开通'], true)) {
-				return 'pending';
-			}
-			if (in_array($st, ['suspended', 'suspend', 'paused', 'off', '已暂停'], true)) {
-				return 'suspend';
-			}
-			if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'fraud', '已终止'], true)) {
-				return 'terminated';
-			}
-			return 'unknown';
+			return zjmf_map_upstream_status($st);
 		}
 		// 无状态字段时用 qk 兜底(false 视为不可用)
 		$qk = $data['qk'] ?? null;
diff --git a/app_plugins/zjmfmanager_reserve/lib/zjmf.php b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
index 376525c..888bebd 100644
--- a/app_plugins/zjmfmanager_reserve/lib/zjmf.php
+++ b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
@@ -128,22 +128,118 @@ function zjmf_json($code, $extra = [])
 	exit;
 }
 
-/** 明文加密(authcode,用于上游主机密码入库)。 */
+/**
+ * 获取 AES-256 加密密钥(32 字节原始密钥,不可用时返回 null)。
+ * 来源优先级:
+ *   1. 环境变量 / 常量 MNBT_SECRET_KEY(任意字符串,sha256 派生 32 字节)
+ *   2. 站点根 runtime/zjmf/zjmf_secret.key 密钥文件(插件目录之外,
+ *      runtime/ 为仓库既有数据目录惯例并带 .htaccess 禁止 Web 访问;
+ *      不存在时自动生成 64 hex 字符随机密钥并尝试 chmod 600)
+ * 两者均不可用时返回 null(调用方退回旧 authcode 并记日志)。
+ */
+function zjmf_secret_key_raw()
+{
+	static $cached = null;
+	if ($cached !== null) {
+		return $cached['key'] ?? null;
+	}
+	$cached = ['key' => null];
+	// 1. 环境变量 / 常量
+	$secret = getenv('MNBT_SECRET_KEY');
+	if ($secret === '' || $secret === false) {
+		$secret = defined('MNBT_SECRET_KEY') ? (string)constant('MNBT_SECRET_KEY') : '';
+	}
+	if ($secret !== '') {
+		$cached['key'] = hash('sha256', (string)$secret, true);
+		return $cached['key'];
+	}
+	// 2. 密钥文件(站点根 runtime/zjmf/,插件 data 目录之外)
+	$base = defined('ROOT') ? ROOT : dirname(dirname(dirname(__DIR__))) . '/';
+	$dir = $base . 'runtime/zjmf';
+	$file = $dir . '/zjmf_secret.key';
+	$secret = '';
+	if (is_file($file)) {
+		$secret = trim((string)@file_get_contents($file));
+	}
+	if ($secret === '') {
+		// 自动生成 64 hex 字符随机密钥并落盘
+		if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
+			@error_log('[zjmfmanager_reserve] 加密密钥目录创建失败:' . $dir);
+			return null;
+		}
+		// 目录防 Web 直接访问(.htaccess 拒绝 + 空 index.html 防目录列举)
+		$ht = $dir . '/.htaccess';
+		if (!is_file($ht)) {
+			@file_put_contents($ht, "Deny from all\n");
+		}
+		$ix = $dir . '/index.html';
+		if (!is_file($ix)) {
+			@file_put_contents($ix, '');
+		}
+		$secret = bin2hex(random_bytes(32));
+		if (@file_put_contents($file, $secret) === false) {
+			@error_log('[zjmfmanager_reserve] 加密密钥文件写入失败:' . $file
+				. ',退回旧 authcode 加密');
+			return null;
+		}
+		@chmod($file, 0600);
+	}
+	$cached['key'] = hash('sha256', $secret, true);
+	return $cached['key'];
+}
+
+/**
+ * 加密上游主机密码等敏感信息。
+ * 密钥可用时 AES-256-GCM,密文格式 'v2:' + base64(nonce.tag.ciphertext);
+ * 密钥不可用时退回旧 authcode(记日志)。
+ */
 function zjmf_encrypt($plain)
 {
-	return authcode((string)$plain, 'ENCODE', SYS_KEY);
+	$plain = (string)$plain;
+	if ($plain === '') {
+		return '';
+	}
+	$key = zjmf_secret_key_raw();
+	if ($key !== null) {
+		$nonce = random_bytes(12);
+		$tag = '';
+		$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
+		if ($cipher !== false) {
+			return 'v2:' . base64_encode($nonce . $tag . $cipher);
+		}
+		@error_log('[zjmfmanager_reserve] AES-256-GCM 加密失败,退回旧 authcode');
+	}
+	return authcode($plain, 'ENCODE', SYS_KEY);
 }
 
 /**
  * 密文解密(防御式)。
- * authcode 解密分支在 PHP 8 下对乱码密文会执行「前10位 - time()」并抛
- * TypeError(Unsupported operand types: string - int),导致详情页 500。
- * 这里对空/过短密文直接返回,异常兜底为空串。
+ * 'v2:' 前缀走 AES-256-GCM(与 zjmf_encrypt 对称);无前缀走旧 authcode
+ * 解密以兼容存量数据。authcode 解密分支在 PHP 8 下对乱码密文会抛
+ * TypeError,这里对空/过短密文直接返回,异常兜底为空串。
  */
 function zjmf_decrypt($cipher)
 {
 	$cipher = (string)$cipher;
-	if ($cipher === '' || strlen($cipher) <= 4) {
+	if ($cipher === '') {
+		return '';
+	}
+	if (strpos($cipher, 'v2:') === 0) {
+		$key = zjmf_secret_key_raw();
+		if ($key === null) {
+			return ''; // 密钥不可用(如密钥文件被删),无法解密
+		}
+		$raw = base64_decode(substr($cipher, 3), true);
+		if ($raw === false || strlen($raw) <= 12 + 16) {
+			return '';
+		}
+		$nonce = substr($raw, 0, 12);
+		$tag = substr($raw, 12, 16);
+		$ct = substr($raw, 28);
+		$plain = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
+		return $plain === false ? '' : $plain;
+	}
+	if (strlen($cipher) <= 4) {
 		return ''; // 未设置密码或非 authcode 密文
 	}
 	try {
@@ -207,8 +303,10 @@ function zjmf_cycles()
 /**
  * 渲染商品简介为规范的展示 HTML。
  * 上游常见 `&lt;li&gt;CPU:4核&lt;/li&gt; &lt;li&gt;内存:4G&lt;/li&gt;...` 格式:
- *   解码实体 → 压缩标签间空白 → 外层包裹 <ul> 渲染成列表。
- * 非 <li> 内容(含管理员手写 HTML)仅解码实体后原样输出。
+ *   解码实体 → 白名单过滤标签 → 压缩标签间空白 → 外层包裹 <ul> 渲染成列表。
+ * 白名单:p/br/b/strong/em/i/ul/ol/li/a[href|target]/span,
+ * a 的 href 仅允许 http/https 协议,其余标签的全部属性(含 style/on*、
+ * script/iframe 等危险标签)一律剥除,防上游描述注入 XSS。
  */
 function zjmf_render_description($raw)
 {
@@ -223,6 +321,34 @@ function zjmf_render_description($raw)
 		$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
 		$i++;
 	} while ($html !== $prev && $i < 3);
+	// 第一步:仅保留白名单标签(script/iframe 等连同标签一并剥除)
+	$html = strip_tags(
+		$html,
+		'<p><br><b><strong><em><i><ul><ol><li><a><span>'
+	);
+	// 第二步:逐标签重建,白名单外的属性全部剥除;a 仅恢复安全的 href/target
+	$html = preg_replace_callback(
+		'/<([a-zA-Z0-9]+)((?:\s+[^<>]*?)?)(\/?)>/u',
+		function ($m) {
+			$tag = strtolower($m[1]);
+			$attrs = $m[2] ?? '';
+			$selfClose = $m[3] ?? '';
+			if ($tag === 'a') {
+				$out = '<a';
+				// href 仅 http/https 协议,杜绝 javascript:/data: 等协议注入
+				if (preg_match('/href\s*=\s*(?:"|\')?\s*((?:https?:\/\/)[^"\'\s>]+)/iu', $attrs, $am)) {
+					$out .= ' href="' . htmlspecialchars($am[1], ENT_QUOTES) . '"';
+				}
+				if (preg_match('/target\s*=\s*(?:"|\')?_blank(?:"|\')?/iu', $attrs)) {
+					$out .= ' target="_blank" rel="noopener"';
+				}
+				return $out . '>';
+			}
+			// 其余标签剥除全部属性(<br/>、<br> 统一输出为 <br>)
+			return '<' . $tag . '>';
+		},
+		$html
+	);
 	if (stripos($html, '<li') === false) {
 		return $html;
 	}
@@ -328,12 +454,13 @@ function zjmf_product_get_by_up($supplier_id, $up_product_id)
 	) ?: null;
 }
 
-/** 上架商品列表(用户端,仅所属供应商启用时可见)。 */
+/** 上架商品列表(用户端,仅所属供应商启用时可见;带供应商名供分组展示)。 */
 function zjmf_product_list_active()
 {
 	global $DB;
 	return $DB->get_all_prepare(
-		"SELECT p.* FROM MN_plugin_zjmf_product p
+		"SELECT p.*, s.name AS supplier_name
+		 FROM MN_plugin_zjmf_product p
 		 LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = p.supplier_id
 		 WHERE p.status=1 AND s.status=1
 		 ORDER BY s.sort ASC, p.sort ASC, p.id ASC"
@@ -873,12 +1000,15 @@ function zjmf_normalize_date($val)
 }
 
 /**
- * 补齐本地主机缺失的上游主机 ID(up_host_id<=0 时)。
+ * 补齐本地主机缺失的上游主机 ID(up_host_id<=0 时),确定性匹配。
  *
- * 开通/结算响应未能解析出主机 ID 时会落库 up_host_id=0,导致用户端
- * 卡片按钮不可用、详情页无法拉取实时信息。此函数通过上游
- * GET host/list(我的主机列表)按 产品名 + 开通日期 匹配回填。
- * 同一次请求内只拉取一次上游列表(进程内静态缓存)。
+ * 匹配键:开通流程保存在订单 order_params.up_host 的本地生成主机标识
+ * (upstream.php purchase() add_to_shop 时的 host 参数),与上游
+ * host/list 返回的 domain 精确相等才绑定;已带 up_host_id 的正常路径
+ * 不会进入本函数。不再做"产品名前缀 + 日期最近"的猜测匹配(曾发生
+ * 误绑上游他人主机的越权风险)。
+ * 匹配不到不写库(保持 up_host_id=0),仅记一条告警日志提示人工绑定。
+ * 本函数只应在开通流程内或管理端调用,用户端 GET 请求不得触发写库。
  *
  * @param array $host MN_plugin_zjmf_host 行
  * @return array 回填后的主机行(未匹配则原样返回)
@@ -897,61 +1027,58 @@ function zjmf_backfill_host_upid($host)
 	if (!$supplier || (int)$supplier['status'] !== 1) {
 		return $host;
 	}
-	// 进程内缓存:同一次请求(列表页/详情页)只向上游请求一次
-	static $cache = [];
-	if (!array_key_exists($supplierId, $cache)) {
-		$res = ZjmfUpstream::hostList($supplier, ['orderby' => 'id', 'sort' => 'DESC']);
-		$cache[$supplierId] = empty($res['ok']) ? [] : ($res['data']['list'] ?? []);
+	// 确定性匹配键:订单参数中保存的本地生成 host 标识
+	$wantDomain = '';
+	$orderNo = '';
+	$order = zjmf_order_get((int)($host['order_id'] ?? 0));
+	if ($order) {
+		$orderNo = (string)($order['order_no'] ?? '');
+		$params = json_decode((string)($order['order_params'] ?? ''), true);
+		if (is_array($params) && isset($params['up_host'])) {
+			$wantDomain = trim((string)$params['up_host']);
+		}
 	}
-	$list = $cache[$supplierId];
-	if (!is_array($list) || $list === []) {
+	if ($wantDomain === '') {
+		// 无匹配键(历史数据/指派单等):不猜测,提示人工绑定
+		zjmf_log((int)($host['user_id'] ?? 0), $orderNo, 'backfill', 'failed',
+			'主机 #' . (int)$host['id'] . ' 缺少上游主机 ID 且无可用的确定性匹配键,'
+			. '已放弃自动回填,请管理员人工绑定', $supplierId);
 		return $host;
 	}
-	$name = (string)($host['name'] ?? '');
-	$created = substr((string)($host['created_at'] ?? ''), 0, 10);
-	$candidates = [];
-	foreach ($list as $item) {
-		if (!is_array($item)) {
-			continue;
+	$res = ZjmfUpstream::hostList($supplier, ['orderby' => 'id', 'sort' => 'DESC']);
+	$list = empty($res['ok']) ? [] : ($res['data']['list'] ?? []);
+	$matched = null;
+	foreach ((array)$list as $item) {
+		if (is_array($item) && trim((string)($item['domain'] ?? '')) === $wantDomain) {
+			$matched = $item;
+			break;
 		}
-		$pn = (string)($item['productname'] ?? '');
-		if ($pn === '' || ($name !== '' && $pn !== $name
-			&& strpos($pn, $name) !== 0 && strpos($name, $pn) !== 0)) {
-			continue;
-		}
-		$candidates[] = $item;
 	}
-	if ($candidates === []) {
+	if (!$matched || (int)($matched['id'] ?? 0) <= 0) {
+		// 上游列表中无该 domain(主机可能仍在异步创建):不写库
+		zjmf_log((int)($host['user_id'] ?? 0), $orderNo, 'backfill', 'failed',
+			'主机 #' . (int)$host['id'] . ' 未能按 host=' . $wantDomain
+			. ' 在上游主机列表中确定性匹配,保持 up_host_id=0,请人工核对绑定', $supplierId);
 		return $host;
 	}
-	// 多台同名主机时,按开通日期与本地创建日期最接近者匹配
-	$best = $candidates[0];
-	if ($created !== '') {
-		$bestDiff = PHP_INT_MAX;
-		$bestTime = strtotime($created) ?: 0;
-		foreach ($candidates as $item) {
-			// regdate 部分版本为 Unix 时间戳,先归一化为 Y-m-d 再比较
-			$rd = zjmf_normalize_date((string)($item['regdate'] ?? ''));
-			if ($rd === '') {
-				continue;
-			}
-			$rdTime = strtotime($rd) ?: 0;
-			$diff = $rdTime ? abs($rdTime - $bestTime) : PHP_INT_MAX;
-			if ($diff < $bestDiff) {
-				$bestDiff = $diff;
-				$best = $item;
-			}
-		}
-	}
-	$upId = (int)($best['id'] ?? 0);
-	if ($upId <= 0) {
+	$upId = (int)$matched['id'];
+	// 代码级防重:该上游主机已被其他本地主机绑定时不重复写库
+	$dup = $DB->get_row_prepare(
+		"SELECT id FROM MN_plugin_zjmf_host
+		 WHERE supplier_id=? AND up_host_id=? AND id<>? LIMIT 1",
+		[$supplierId, $upId, (int)$host['id']]
+	);
+	if ($dup) {
+		zjmf_log((int)($host['user_id'] ?? 0), $orderNo, 'backfill', 'failed',
+			'上游主机 #' . $upId . ' 已被本地主机 #' . (int)$dup['id'] . ' 绑定,'
+			. '主机 #' . (int)$host['id'] . ' 放弃自动回填,请人工核对', $supplierId);
 		return $host;
 	}
 	$status = function_exists('zjmf_map_upstream_status')
-		? zjmf_map_upstream_status((string)($best['domainstatus'] ?? ''))
+		? zjmf_map_upstream_status((string)($matched['domainstatus'] ?? ''))
 		: (string)($host['status'] ?? '');
 	// nextduedate 部分版本为 Unix 时间戳,统一归一化为 Y-m-d
-	$renew = zjmf_normalize_date((string)($best['nextduedate'] ?? $host['renew_date'] ?? ''));
+	$renew = zjmf_normalize_date((string)($matched['nextduedate'] ?? $host['renew_date'] ?? ''));
 	$now = $date ?: date('Y-m-d H:i:s');
 	$DB->query_prepare(
 		"UPDATE MN_plugin_zjmf_host
@@ -964,20 +1091,25 @@ function zjmf_backfill_host_upid($host)
 	return $host;
 }
 
-/** 上游 domainstatus → 本地展示状态(active/suspend/unknown)。 */
+/**
+ * 上游状态 → 本地展示状态(统一映射表,主实现)。
+ * ZjmfUpstream::mapHostStatus 委托调用本函数;除常规 domainstatus 外
+ * 补充 off/true/false/deleted/Unpaid 等分支(true/false/off 多见于
+ * DCIM/云主机开关机状态,deleted/Unpaid 见于部分上游版本)。
+ */
 function zjmf_map_upstream_status($status)
 {
 	$st = strtolower(trim((string)$status));
-	if (in_array($st, ['active', 'completed', '运行中'], true)) {
+	if (in_array($st, ['active', 'on', 'true', 'completed', '运行中'], true)) {
 		return 'active';
 	}
-	if (in_array($st, ['pending', 'wait', 'waiting', '待开通'], true)) {
+	if (in_array($st, ['pending', 'wait', 'waiting', 'unpaid', '待开通', '未付款'], true)) {
 		return 'pending';
 	}
-	if (in_array($st, ['suspended', 'suspend', 'paused', '已暂停'], true)) {
+	if (in_array($st, ['suspended', 'suspend', 'paused', 'off', 'false', '已暂停', '已关机'], true)) {
 		return 'suspend';
 	}
-	if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'fraud', '已终止'], true)) {
+	if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'deleted', 'delete', 'fraud', '已终止', '已删除'], true)) {
 		return 'terminated';
 	}
 	return 'unknown';
@@ -1005,8 +1137,11 @@ function zjmf_action_status($action)
 {
 	$map = [
 		'on'     => 'active',
-		'off'    => 'suspend',
 		'reboot' => 'active',
+		// 关机不写 suspend:本地主机表无独立电源字段,写入 suspend 会与
+		// 上游真实状态(domainstatus 仍为 active)脱节,由调用方刷新上游
+		// 状态回写真实状态
+		'off'    => '',
 	];
 	return $map[$action] ?? '';
 }
@@ -1092,11 +1227,25 @@ function zjmf_open_host($order_id)
 	$password = (string)($result['password'] ?? '');
 	$upOrderId = (int)($result['up_order_id'] ?? 0);
 
-	// 回填订单
-	zjmf_order_fill_opened($order_id, $upOrderId, $upHostId, $username);
-	zjmf_order_set_status($order_id, 'opened', '主机已开通');
+	// 保存本次开通使用的本地生成主机标识(add_to_shop 的 host 参数),
+	// 供开通响应未带主机 ID 时的确定性回填匹配(S2,避免猜测匹配越权)
+	$genHost = trim((string)($result['host'] ?? ''));
+	if ($genHost !== '') {
+		$params = json_decode((string)($order['order_params'] ?? ''), true);
+		if (!is_array($params)) {
+			$params = [];
+		}
+		if ((string)($params['up_host'] ?? '') !== $genHost) {
+			$params['up_host'] = $genHost;
+			$DB->query_prepare(
+				"UPDATE MN_plugin_zjmf_order SET order_params=? WHERE id=?",
+				[json_encode($params, JSON_UNESCAPED_UNICODE), (int)$order_id]
+			);
+		}
+	}
 
-	// 写主机映射
+	// 写主机映射(先建主机,成功后再标记订单 opened,避免中间态):
+	// 主机映射写入失败时订单保持 paid 可人工重试,不得标 opened
 	$hostId = zjmf_host_create([
 		'supplier_id'    => (int)$order['supplier_id'],
 		'user_id'        => (int)$order['user_id'],
@@ -1110,6 +1259,26 @@ function zjmf_open_host($order_id)
 		'status'         => 'active',
 		'renew_date'     => (string)($result['renew_date'] ?? ''),
 	]);
+	if ($hostId <= 0) {
+		@error_log('[zjmfmanager_reserve] host create failed, order stays paid: order_id='
+			. (int)$order_id . ' up_host_id=' . $upHostId);
+		zjmf_log((int)$order['user_id'], $order['order_no'], 'purchase', 'failed',
+			'上游开通成功但本地主机映射写入失败,订单保持已支付待人工处理'
+			. '(up_host_id=' . $upHostId . ')', (int)$order['supplier_id']);
+		return ['ok' => false, 'msg' => '上游开通成功但本地主机映射写入失败,请人工处理', 'host_id' => 0];
+	}
+
+	// 回填订单并标记 opened
+	zjmf_order_fill_opened($order_id, $upOrderId, $upHostId, $username);
+	zjmf_order_set_status($order_id, 'opened', '主机已开通');
+
+	// 开通响应未带主机 ID 时,在开通流程内同步做一次确定性回填(失败不阻断)
+	if ($upHostId <= 0) {
+		$hostRow = zjmf_host_get($hostId);
+		if ($hostRow) {
+			zjmf_backfill_host_upid($hostRow);
+		}
+	}
 
 	zjmf_log((int)$order['user_id'], $order['order_no'],
 		'purchase', 'success',
diff --git a/app_plugins/zjmfmanager_reserve/views/host.php b/app_plugins/zjmfmanager_reserve/views/host.php
index e390668..da80a1f 100644
--- a/app_plugins/zjmfmanager_reserve/views/host.php
+++ b/app_plugins/zjmfmanager_reserve/views/host.php
@@ -16,7 +16,8 @@ $os_list = $os_list ?? [];
 $os_groups = $os_groups ?? [];
 $os_error = $os_error ?? '';
 
-$password = zjmf_decrypt((string)$host['password']);
+// 密码默认掩码展示,点击"显示"后经 /reserve/api/host_password 按需解密
+// (服务端记日志),页面不再无条件渲染明文密码
 $hasUpId = (int)$host['up_host_id'] > 0;
 
 // 上游详情字段(label => [显示名, 分组])。价格 / 付款相关字段一律不展示。
@@ -30,7 +31,7 @@ $up_fields = [
 	'ip_num'                   => ['IP 数量', '网络信息'],
 	'port'                     => ['端口', '网络信息'],
 	'username'                 => ['服务器用户名', '账号信息'],
-	'password'                 => ['服务器密码', '账号信息'],
+	// 上游密码不在详情面板展示(脱敏收敛,仅在"显示密码"后经 AJAX 获取)
 	'regdate'                  => ['开通时间', '生命周期'],
 	'nextduedate'              => ['到期时间', '生命周期'],
 	'domainstatus'             => ['产品状态', '生命周期'],
@@ -75,10 +76,6 @@ if (is_array($info['data']) && $info['data'] !== []) {
 		if ($k === 'domainstatus') {
 			$v = zjmf_host_status_label(zjmf_map_upstream_status((string)$v));
 		}
-		// 上游 password 为明文,直接展示(存本地库时才加密)
-		if ($k === 'password') {
-			$v = (string)$v === '' ? '(空)' : $v;
-		}
 		if (is_array($v)) {
 			$v = json_encode($v, JSON_UNESCAPED_UNICODE);
 		}
@@ -253,8 +250,10 @@ ob_start();
     <div class="zj-stat-value zj-mono"><?= htmlspecialchars($host['username'] ?: '-') ?></div>
   </div>
   <div class="zj-stat">
-    <div class="zj-stat-label">密码</div>
-    <div class="zj-stat-value zj-mono"><?= htmlspecialchars($password ?: '-') ?></div>
+    <div class="zj-stat-label">密码
+      <a href="javascript:;" id="zjf-pw-toggle" style="margin-left:6px;">显示</a>
+    </div>
+    <div class="zj-stat-value zj-mono" id="zjf-pw-value">••••••••</div>
   </div>
   <div class="zj-stat">
     <div class="zj-stat-label">周期</div>
@@ -413,7 +412,7 @@ $taskTypeMap = ['0' => '重装系统', '1' => '救援系统', '2' => '重置密
     <?php if (!empty($traffic['ok'])): ?>
       <div class="zj-desc2"><?= zjmf_view_show_traffic($traffic['data']) ?></div>
     <?php else: ?>
-      <span class="zj-tip"><?= (($traffic['msg'] ?? '') ?: '流量查询失败') ?></span>
+      <span class="zj-tip"><?= htmlspecialchars((string)(($traffic['msg'] ?? '') ?: '流量查询失败')) ?></span>
     <?php endif; ?>
   </div>
 </div>
@@ -498,9 +497,49 @@ $taskTypeMap = ['0' => '重装系统', '1' => '救援系统', '2' => '重置密
   var reinstallPanel = document.getElementById('zjf-reinstall-panel');
   var rescuePanel = document.getElementById('zjf-rescue-panel');
   var osCount = <?= is_array($os_list) ? count($os_list) : 0 ?>;
-  var osError = <?= json_encode((string)$os_error, JSON_UNESCAPED_UNICODE) ?>;
+  var osError = <?= json_encode((string)$os_error, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
   var pendingAction = '';
 
+  // 密码显示/隐藏:点击"显示"后经 AJAX 获取一次解密值,再次点击恢复掩码
+  var pwValue = document.getElementById('zjf-pw-value');
+  var pwToggle = document.getElementById('zjf-pw-toggle');
+  var pwFetched = '';
+  if (pwToggle) {
+    pwToggle.addEventListener('click', function () {
+      if (pwToggle.textContent === '隐藏') {
+        pwValue.textContent = '••••••••';
+        pwToggle.textContent = '显示';
+        return;
+      }
+      if (pwFetched !== '') {
+        pwValue.textContent = pwFetched;
+        pwToggle.textContent = '隐藏';
+        return;
+      }
+      pwToggle.textContent = '…';
+      var body = new URLSearchParams();
+      body.append('host_id', '<?= (int)$host['id'] ?>');
+      fetch('<?= zjmf_url('reserve/api/host_password') ?>', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+        body: body.toString()
+      }).then(function (r) { return r.json(); }).then(function (res) {
+        if (res.code === 'ok' && typeof res.password !== 'undefined' && res.password !== '') {
+          pwFetched = res.password;
+          pwValue.textContent = pwFetched;
+          pwToggle.textContent = '隐藏';
+        } else {
+          pwValue.textContent = '-';
+          pwToggle.textContent = '显示';
+          show(res.msg || res.code || '密码获取失败', false);
+        }
+      }).catch(function () {
+        pwToggle.textContent = '显示';
+        show('网络错误,请重试', false);
+      });
+    });
+  }
+
   function show(text, ok) {
     msg.textContent = text;
     msg.className = 'zj-msg zj-msg-show ' + (ok ? 'zj-msg-success' : 'zj-msg-error');