仰望星辰工作室

feat(resource_pool): 新增资源池管理插件

Y yang1145 提交于 2026-08-12 13:42 · 195d61c ·父提交 e3f6c89
feat(resource_pool): 新增资源池管理插件

实现资源池(经销/代理配额)管理功能,包含资源池账号、可用节点、配额与到期管理,支持从资源池开通主机、绑定/解除主机归属,统计配额使用情况,兼容原有系统架构不修改核心表结构
9 个文件变更 +2373 -0 13172193298@163.com
•app_plugins/resource_pool/README.md +154 -0
•app_plugins/resource_pool/admin/add.php +202 -0
•app_plugins/resource_pool/admin/hosts.php +276 -0
•app_plugins/resource_pool/admin/list.php +355 -0
•app_plugins/resource_pool/bootstrap.php +300 -0
•app_plugins/resource_pool/install.sql +18 -0
•app_plugins/resource_pool/lib/pool.php +1058 -0
•app_plugins/resource_pool/plugin.json +9 -0
•app_plugins/resource_pool/uninstall.sql +1 -0
变更内容
diff --git a/app_plugins/resource_pool/README.md b/app_plugins/resource_pool/README.md
new file mode 100644
index 0000000..d9a6687
--- /dev/null
+++ b/app_plugins/resource_pool/README.md
@@ -0,0 +1,154 @@
+# 资源池插件(resource_pool)
+
+管理员后台的资源池(经销 / 代理配额)管理插件。为每个资源池配置账号、可用节点白名单、
+网页空间 / 数据库空间 / 流量总配额、到期日期与状态;从资源池开通的主机会记录归属资源池。
+
+纯插件实现,**不修改任何核心文件,也不改动核心主机表 `MN_zj` 的结构**。
+
+---
+
+## 功能
+
+| 位置 | 功能 |
+|------|------|
+| 一级菜单「资源池管理」→ 添加资源池 | 新建 / 编辑资源池 |
+| 一级菜单「资源池管理」→ 资源池列表 | 列表、搜索、启用/禁用、编辑、删除、从资源池开通主机 |
+| 一级菜单「资源池管理」→ 资源主机管理 | 查看所有资源池开通的主机、按资源池筛选、绑定/解除归属、清理失效归属 |
+| 主机管理 → 主机列表 | 自动多出一列「资源池」,显示该主机归属哪个资源池 |
+
+## 安装
+
+1. 目录放到 `app_plugins/resource_pool/`
+2. 后台 → 插件管理 → **安装** → **启用**
+3. **整页刷新**后台(侧栏菜单在框架页渲染,只刷 iframe 不够)
+
+安装时执行 `install.sql`,只建插件自有表 `MN_plugin_respool`。
+若表结构有缺失,可在「资源池列表」页点 **修复数据表** 补齐。
+
+## 归属关系怎么存的
+
+**不在 `MN_zj` 上加字段。** 归属关系存在资源池表的 `host_users` 字段里 ——
+一个 JSON 数组,元素是 `MN_zj.user`(主机账号):
+
+```json
+["user123456", "shopabc001"]
+```
+
+统计与查询时用 `MN_zj.user` 反查主机行:
+
+- 算配额:把该池 `host_users` 里的账号对应的主机 `hxa/hxb/llmax` 的 `max` 累加
+- 查主机:`SELECT * FROM MN_zj WHERE user IN (...)`
+- 反查归属:把所有池的 `host_users` 展开成 `主机账号 => 资源池` 映射
+
+主机在「主机列表」被删除后,它的账号仍留在 `host_users` 里,但反查不到主机行,
+因此**自动不再计入配额与列表**;可用「清理失效归属」清掉残留记录。
+
+一个主机账号只能归属一个资源池,绑定时会做互斥校验。
+
+> 从 1.0.0 升级:1.0.0 曾给 `MN_zj` 加过 `pool_id` 字段。插件启动时会自动把
+> `pool_id` 的归属数据搬进 `host_users` 并清空 `pool_id`,之后该字段闲置。
+> 插件不会删除这个字段(不动核心表结构),留着也不影响原有功能,可自行手动清理。
+
+## 数据表
+
+只新建一张表 `MN_plugin_respool`:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | int | 主键 |
+| `name` | varchar(120) | 资源池名 |
+| `username` | varchar(120) | 用户名(唯一) |
+| `password` | varchar(255) | 密码 |
+| `nodes` | text | 可用节点,`MN_bt.btdh` 的 JSON 数组;`[]` = 不限 |
+| `host_users` | text | **归属本池的主机账号**,`MN_zj.user` 的 JSON 数组 |
+| `web_space` | int | 网页空间总配额 MB,`0` = 不限 |
+| `sql_space` | int | 数据库空间总配额 MB,`0` = 不限 |
+| `flow` | int | 流量总配额 GB/月,`0` = 不限 |
+| `expire_date` | varchar(50) | 到期日期 `yyyy-mm-dd`,空 = 永不到期 |
+| `status` | varchar(20) | 资源池状态 `enabled` / `disabled` |
+| `remark` | varchar(500) | 备注 |
+| `created_at` / `updated_at` | varchar(50) | 时间戳 |
+
+卸载插件时 `DROP` 掉这张表即可,核心表零残留。
+
+## 配额语义
+
+`web_space` / `sql_space` / `flow` 是该资源池的**总配额**。从本资源池开通主机时,
+把该池已归属主机的对应 `max` 值累加,加上本次申请量,超过总配额即拒绝开通。
+填 `0` 表示该项不限。
+
+资源池被**禁用**或**已过期**时不能再开通主机,已开通的主机不受影响。
+
+删除资源池不会删除已开通的主机(归属关系随该行一起消失)。
+「解除归属」同理,解除后该主机占用的配额从资源池释放。
+
+## 主机列表「资源池」列的实现
+
+不改核心 `templates/default/admin/list.php`。插件通过
+`mnbt_register_partial_override('admin', 'head', ...)` 在管理端 `head` 之后注入一段脚本,
+包装 `$.fn.bootstrapTable`,在核心初始化主机表时把「资源池」列插到「主机操作」列之前,
+按行数据的 `user` 字段匹配资源池名。
+
+- 仅在 `admin/list.php?gn=zj` 生效,其他页面回调直接返回 `null`
+- 包装时保留库自身属性(`VERSION` / `locales` / `defaults` 等)
+- 全程 `try/catch`,注入失败则静默降级,不影响原页面
+- 资源池名做 HTML 转义;未归属的主机显示 `-`
+- 映射用 `JSON_FORCE_OBJECT` 编码,避免纯数字主机账号被当成数组下标
+
+「资源主机管理」页是服务端渲染,不依赖上述注入。
+
+## AJAX 接口
+
+均为管理端(`POST admin/ajax.php`),前缀 `p_respool_`:
+
+| `gn` | 说明 |
+|------|------|
+| `p_respool_save` | 新增 / 编辑资源池(带 `id` 为编辑;密码留空 = 不改) |
+| `p_respool_delete` | 删除资源池 |
+| `p_respool_status` | 启用 / 禁用 |
+| `p_respool_open_host` | 从资源池开通主机 |
+| `p_respool_bind_host` | 把已有主机绑定到资源池(`pool_id` + `host_user`) |
+| `p_respool_unbind_host` | 解除主机的资源池归属(`host_user`) |
+| `p_respool_prune` | 清理失效归属 |
+| `p_respool_repair` | 修复数据表 |
+
+## 对外函数
+
+其他插件可直接调用:
+
+```php
+rp_get($id);                          // 取资源池
+rp_get_by_username($username);        // 按用户名取资源池
+rp_list($page, $per_page, $kw, $status);
+rp_usage($pool_id);                   // 已分配用量 ['hosts','web','sql','flow']
+rp_usage_batch([$id1, $id2]);         // 批量用量,避免 N+1
+rp_remaining($pool);                  // 剩余配额,null = 不限
+rp_is_usable($pool);                  // 启用且未过期
+rp_pool_hosts($pool_id);              // 该池的主机行
+rp_all_pool_hosts($pool_id, $kw);     // 全部资源池主机(0=不筛选),含 pool_id/pool_name
+rp_unbound_hosts($limit);             // 未归属任何资源池的主机
+rp_host_user_map();                   // 主机账号 => ['pool_id','pool_name']
+rp_host_user_name_map();              // 主机账号 => 资源池名
+rp_find_pool_by_host_user($user);     // 查某主机账号的归属池
+rp_bind_host_user($pool_id, $user);   // 绑定
+rp_unbind_host_user($user, $pool_id); // 解绑($pool_id 可省略,自动查)
+rp_prune_host_users($pool_id);        // 清理失效归属(省略=全部池)
+rp_open_host($pool_id, $args);        // 从资源池开通主机
+```
+
+`rp_open_host($pool_id, $args)` 的 `$args`:
+`node` / `user` / `pass` / `web_space` / `sql_space` / `flow` / `domain_count` /
+`expire_date` / `status`(`'true'`|`'false'`),返回 `['ok'=>bool,'msg'=>string,'host_id'=>int]`。
+
+开通成功会触发 `host.created` 钩子,`$ctx` 为
+`['source'=>'resource_pool', 'pool_id'=>N]`。
+
+## 已知限制
+
+- 资源池密码以明文存储(与核心 `MN_zj.pass` 主机密码一致的做法)。目前资源池只在
+  后台使用,没有对外登录入口;若后续要做资源池自助登录,应改为哈希存储。
+- 归属按**主机账号**匹配。若在别处把某台主机的 `MN_zj.user` 改掉,归属会失效,
+  需重新绑定(核心「编辑主机」弹窗里账号是只读的,正常不会发生)。
+- 「资源池」列注入依赖当前 default 管理端主题的 bootstrap-table 用法。换主题后
+  若该列不出现,用「资源主机管理」页查看归属即可,功能不受影响。
+- 配额按主机的 `max` 配置累加,不是按实际使用量。
diff --git a/app_plugins/resource_pool/admin/add.php b/app_plugins/resource_pool/admin/add.php
new file mode 100644
index 0000000..edaa088
--- /dev/null
+++ b/app_plugins/resource_pool/admin/add.php
@@ -0,0 +1,202 @@
+<?php
+/**
+ * 管理员端 - 添加 / 编辑资源池
+ * URL: admin/plugin.php?p=resource_pool&page=add[&id=N]
+ */
+if (!defined('IN_CRONLITE')) {
+	exit;
+}
+
+rp_ensure_schema();
+
+$edit_id = (int)($_GET['id'] ?? 0);
+$pool    = $edit_id > 0 ? rp_get($edit_id) : null;
+$is_edit = $pool !== null;
+
+$nodes         = rp_all_nodes();
+$checked_nodes = $is_edit ? rp_decode_nodes($pool['nodes'] ?? '') : [];
+$statuses      = rp_statuses();
+
+$title = $is_edit ? '编辑资源池' : '添加资源池';
+mnbt_admin_include('head');
+?>
+<link href="<?= mnbt_asset_url('js/bootstrap-datepicker/bootstrap-datepicker3.min.css') ?>" rel="stylesheet">
+
+<div class="container-fluid p-t-15">
+  <div class="row">
+    <div class="col-lg-12">
+      <div class="card">
+        <header class="card-header">
+          <div class="card-title"><?= $is_edit ? '编辑资源池 #' . (int)$pool['id'] : '添加资源池' ?></div>
+        </header>
+        <div class="card-body">
+
+          <?php if ($edit_id > 0 && !$is_edit): ?>
+            <div class="alert alert-danger">资源池不存在(ID <?= $edit_id ?>),请返回<a href="plugin.php?p=resource_pool&page=list">资源池列表</a>。</div>
+          <?php else: ?>
+
+          <input type="hidden" id="rp_id" value="<?= $is_edit ? (int)$pool['id'] : 0 ?>">
+
+          <div class="form-group">
+            <label><b>资源池名</b></label>
+            <input type="text" class="form-control" id="rp_name" maxlength="120" placeholder="例如:华东代理A"
+                   value="<?= $is_edit ? htmlspecialchars($pool['name'], ENT_QUOTES, 'UTF-8') : '' ?>">
+            <small class="text-muted">用于识别该资源池的名称</small>
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>用户名</b></label>
+            <input type="text" class="form-control" id="rp_username" maxlength="120" placeholder="字母/数字/下划线/横线,4-120 位"
+                   value="<?= $is_edit ? htmlspecialchars($pool['username'], ENT_QUOTES, 'UTF-8') : '' ?>">
+            <small class="text-muted">资源池登录账号,全局唯一</small>
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>密码</b></label>
+            <div class="input-group">
+              <input type="text" class="form-control" id="rp_password" maxlength="255"
+                     placeholder="<?= $is_edit ? '留空表示不修改密码' : '不少于 6 位' ?>">
+              <div class="input-group-btn">
+                <button class="btn btn-default" type="button" onclick="rpGenPass()">随机生成</button>
+              </div>
+            </div>
+            <small class="text-muted"><?= $is_edit ? '留空表示保持原密码不变' : '不少于 6 位' ?></small>
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>可用节点</b></label>
+            <?php if (empty($nodes)): ?>
+              <div class="alert alert-warning mb-0">还没有任何宝塔节点,请先到「宝塔管理 → 添加宝塔」添加节点。</div>
+            <?php else: ?>
+              <div class="row">
+                <?php foreach ($nodes as $n): ?>
+                  <div class="col-md-4">
+                    <label class="lyear-checkbox checkbox-primary">
+                      <input type="checkbox" class="rp-node" value="<?= htmlspecialchars($n['btdh'], ENT_QUOTES, 'UTF-8') ?>"
+                        <?= in_array((string)$n['btdh'], $checked_nodes, true) ? 'checked' : '' ?>>
+                      <span><?= htmlspecialchars($n['btdh'], ENT_QUOTES, 'UTF-8') ?></span>
+                      <small class="text-muted">(<?= htmlspecialchars($n['btip'], ENT_QUOTES, 'UTF-8') ?> / <?= ((string)$n['btos'] === '1' ? 'Linux' : 'Windows') ?>)</small>
+                    </label>
+                  </div>
+                <?php endforeach; ?>
+              </div>
+              <small class="text-muted">全部不勾选表示不限制节点(该资源池可用所有节点)</small>
+            <?php endif; ?>
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>网页空间(MB)</b></label>
+            <input type="number" min="0" class="form-control" id="rp_web" placeholder="该资源池可分配的网页空间总量,0 表示不限"
+                   value="<?= $is_edit ? (int)$pool['web_space'] : 0 ?>">
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>数据库空间(MB)</b></label>
+            <input type="number" min="0" class="form-control" id="rp_sql" placeholder="该资源池可分配的数据库空间总量,0 表示不限"
+                   value="<?= $is_edit ? (int)$pool['sql_space'] : 0 ?>">
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>流量(G/月)</b></label>
+            <input type="number" min="0" class="form-control" id="rp_flow" placeholder="该资源池可分配的月流量总量,0 表示不限"
+                   value="<?= $is_edit ? (int)$pool['flow'] : 0 ?>">
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>到期日期</b>(点击即可选择)</label>
+            <input class="form-control js-datepicker m-b-10" type="text" id="rp_expire" placeholder="yyyy-mm-dd"
+                   data-provide="datepicker" readonly="true" data-date-format="yyyy-mm-dd" style="background-color:#FFFFFF;"
+                   value="<?= $is_edit ? htmlspecialchars($pool['expire_date'], ENT_QUOTES, 'UTF-8') : '' ?>">
+            <small class="text-muted">留空表示永不到期。到期后该资源池不能再开通主机(已开通主机不受影响)</small>
+            <div><button type="button" class="btn btn-xs btn-outline-secondary mt-1" onclick="document.getElementById('rp_expire').value='';">清空日期</button></div>
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>资源池状态</b></label>
+            <select class="form-control" id="rp_status">
+              <?php foreach ($statuses as $k => $v): ?>
+                <option value="<?= htmlspecialchars($k, ENT_QUOTES, 'UTF-8') ?>"
+                  <?= ($is_edit && $pool['status'] === $k) ? 'selected' : '' ?>><?= htmlspecialchars($v, ENT_QUOTES, 'UTF-8') ?></option>
+              <?php endforeach; ?>
+            </select>
+            <small class="text-muted">禁用后该资源池不能开通主机</small>
+          </div><br/>
+
+          <div class="form-group">
+            <label><b>备注</b>(可选)</label>
+            <input type="text" class="form-control" id="rp_remark" maxlength="500"
+                   value="<?= $is_edit ? htmlspecialchars($pool['remark'], ENT_QUOTES, 'UTF-8') : '' ?>">
+          </div><br/>
+
+          <button class="btn btn-primary form-control" type="button" onclick="rpSave()">
+            <i class="mdi mdi-checkbox-marked-circle-outline"></i> <?= $is_edit ? '保存修改' : '确认添加' ?>
+          </button>
+
+          <div class="panel-footer mt-3">
+            <span class="mdi mdi-information-outline"></span> 说明:网页空间 / 数据库空间 / 流量是该资源池的<b>总配额</b>,
+            从本资源池开通主机时会累计校验,超出即拒绝开通。填 0 表示该项不限。
+          </div>
+
+          <?php endif; ?>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script src="<?= mnbt_asset_url('js/bootstrap-datepicker/bootstrap-datepicker.min.js') ?>"></script>
+<script src="<?= mnbt_asset_url('js/bootstrap-datepicker/locales/bootstrap-datepicker.zh-CN.min.js') ?>"></script>
+<script type="text/javascript" src="<?= mnbt_asset_url('js/main.min.js') ?>"></script>
+<script type="text/javascript">
+function rpGenPass() {
+	var s = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+	var out = '';
+	for (var i = 0; i < 12; i++) { out += s.charAt(Math.floor(Math.random() * s.length)); }
+	document.getElementById('rp_password').value = out;
+	msalert(1, '已生成随机密码', 2000);
+}
+
+function rpSave() {
+	var id = $('#rp_id').val();
+	var nodes = [];
+	$('.rp-node:checked').each(function () { nodes.push($(this).val()); });
+
+	var name = $.trim($('#rp_name').val());
+	var username = $.trim($('#rp_username').val());
+	var password = $('#rp_password').val();
+
+	if (name === '') { msalert(3, '请填写资源池名', 2000); return; }
+	if (username === '') { msalert(3, '请填写用户名', 2000); return; }
+	if (id === '0' && password.length < 6) { msalert(3, '密码不能少于 6 位', 2000); return; }
+
+	msloading('正在保存...');
+	$.post('ajax.php', {
+		gn: 'p_respool_save',
+		id: id,
+		name: name,
+		username: username,
+		password: password,
+		'nodes[]': nodes,
+		web_space: $('#rp_web').val() || 0,
+		sql_space: $('#rp_sql').val() || 0,
+		flow: $('#rp_flow').val() || 0,
+		expire_date: $.trim($('#rp_expire').val()),
+		status: $('#rp_status').val(),
+		remark: $.trim($('#rp_remark').val())
+	}, function (resp) {
+		var j;
+		try { j = typeof resp === 'string' ? JSON.parse(resp) : resp; } catch (e) { j = {}; }
+		msloadingde();
+		var msg = j.msg || j.code || '未知返回';
+		if (j.qk === 1 || j.qk === '1') {
+			msalert(1, msg, 2000);
+			setTimeout(function () { window.location.href = 'plugin.php?p=resource_pool&page=list'; }, 800);
+		} else {
+			msalert(4, msg, 4000);
+		}
+	}).fail(function () {
+		msloadingde();
+		msalert(4, '网络错误', 3000);
+	});
+}
+</script>
diff --git a/app_plugins/resource_pool/admin/hosts.php b/app_plugins/resource_pool/admin/hosts.php
new file mode 100644
index 0000000..43c0e5f
--- /dev/null
+++ b/app_plugins/resource_pool/admin/hosts.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * 管理员端 - 资源主机管理
+ *
+ * 查看所有由资源池开通 / 归属资源池的主机,可按资源池筛选。
+ * URL: admin/plugin.php?p=resource_pool&page=hosts[&pool_id=N][&kw=xxx]
+ *
+ * 归属关系来自 MN_plugin_respool.host_users(MN_zj.user 的 JSON 数组),
+ * 不依赖主机表的任何新增字段。
+ */
+if (!defined('IN_CRONLITE')) {
+	exit;
+}
+
+rp_ensure_schema();
+
+$pool_id = (int)($_GET['pool_id'] ?? 0);
+$kw      = trim((string)($_GET['kw'] ?? ''));
+
+$pools    = rp_list(1, 200)['list'];
+$cur_pool = $pool_id > 0 ? rp_get($pool_id) : null;
+if ($pool_id > 0 && !$cur_pool) {
+	$pool_id = 0;
+}
+
+$hosts     = rp_all_pool_hosts($pool_id, $kw);
+$unbound   = rp_unbound_hosts(500);
+$total_all = count(rp_host_user_map());
+
+$title = '资源主机管理';
+mnbt_admin_include('head');
+?>
+<link rel="stylesheet" href="<?= mnbt_asset_url('js/jquery-confirm/jquery-confirm.min.css') ?>">
+<script src="<?= mnbt_asset_url('js/jquery-confirm/jquery-confirm.min.js') ?>"></script>
+
+<div class="container-fluid p-t-15">
+  <div class="card">
+    <header class="card-header">
+      <div class="card-title" style="display:inline-block">
+        资源主机管理<?= $cur_pool ? ' - ' . htmlspecialchars($cur_pool['name'], ENT_QUOTES, 'UTF-8') : '' ?>
+      </div>
+      <small class="text-muted ml-2">
+        当前 <?= count($hosts) ?> 台<?= $pool_id > 0 ? '(全部资源池共 ' . (int)$total_all . ' 台)' : '' ?>
+      </small>
+    </header>
+    <div class="card-body">
+
+      <div class="callout callout-info">
+        <p class="small">
+          本页只列出<b>归属资源池</b>的主机。主机的开通 / 暂停 / 删除仍在「主机管理 → 主机列表」操作;
+          这里管理归属关系与查看各资源池的主机分布。
+        </p>
+      </div>
+
+      <form method="get" class="form-inline mb-3">
+        <input type="hidden" name="p" value="resource_pool">
+        <input type="hidden" name="page" value="hosts">
+        <label class="mr-2">资源池</label>
+        <select name="pool_id" class="form-control form-control-sm mr-2">
+          <option value="0">全部资源池</option>
+          <?php foreach ($pools as $p): ?>
+            <option value="<?= (int)$p['id'] ?>" <?= $pool_id === (int)$p['id'] ? 'selected' : '' ?>>
+              <?= htmlspecialchars($p['name'], ENT_QUOTES, 'UTF-8') ?>
+            </option>
+          <?php endforeach; ?>
+        </select>
+        <input type="text" name="kw" class="form-control form-control-sm mr-2" placeholder="主机账号 / 网站名 / 宝塔"
+               value="<?= htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') ?>">
+        <button type="submit" class="btn btn-sm btn-primary">筛选</button>
+        <a href="plugin.php?p=resource_pool&page=hosts" class="btn btn-sm btn-outline-secondary ml-2">重置</a>
+        <button type="button" class="btn btn-sm btn-success ml-2" onclick="rpBindDialog()">
+          <i class="mdi mdi-link-variant"></i> 绑定已有主机
+        </button>
+        <button type="button" class="btn btn-sm btn-outline-warning ml-2" onclick="rpPrune()">
+          清理失效归属
+        </button>
+        <a href="plugin.php?p=resource_pool&page=list" class="btn btn-sm btn-outline-secondary ml-2">资源池列表</a>
+      </form>
+
+      <div class="table-responsive">
+        <table class="table table-bordered table-hover table-striped">
+          <thead>
+            <tr>
+              <th style="width:60px">ID</th>
+              <th>资源池</th>
+              <th>所属宝塔</th>
+              <th>网站名</th>
+              <th>主机账号</th>
+              <th>网页空间</th>
+              <th>数据库空间</th>
+              <th>流量</th>
+              <th>创建时间</th>
+              <th>到期时间</th>
+              <th>状态</th>
+              <th style="width:100px">操作</th>
+            </tr>
+          </thead>
+          <tbody>
+          <?php if (empty($hosts)): ?>
+            <tr><td colspan="12" class="text-center text-muted">
+              <?php if ($kw !== ''): ?>
+                没有匹配「<?= htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') ?>」的资源池主机
+              <?php elseif ($pool_id > 0): ?>
+                该资源池还没有主机,可在「资源池列表」点「开通主机」,或用上方「绑定已有主机」
+              <?php else: ?>
+                还没有任何归属资源池的主机
+              <?php endif; ?>
+            </td></tr>
+          <?php else: ?>
+            <?php foreach ($hosts as $h):
+              $hu   = (string)$h['user'];
+              $hpid = (int)($h['pool_id'] ?? 0);
+            ?>
+              <tr>
+                <td><?= (int)$h['id'] ?></td>
+                <td>
+                  <?php if ($hpid > 0): ?>
+                    <a href="plugin.php?p=resource_pool&page=hosts&pool_id=<?= $hpid ?>">
+                      <span class="badge badge-info"><?= htmlspecialchars((string)$h['pool_name'], ENT_QUOTES, 'UTF-8') ?></span>
+                    </a>
+                  <?php else: ?>
+                    <span class="text-muted">-</span>
+                  <?php endif; ?>
+                </td>
+                <td><?= htmlspecialchars((string)$h['ssbt'], ENT_QUOTES, 'UTF-8') ?></td>
+                <td><?= htmlspecialchars((string)$h['sqldz'], ENT_QUOTES, 'UTF-8') ?></td>
+                <td><?= htmlspecialchars($hu, ENT_QUOTES, 'UTF-8') ?></td>
+                <td><?= (int)rp_json_max($h['hxa'] ?? '') ?>MB</td>
+                <td><?= (int)rp_json_max($h['hxb'] ?? '') ?>MB</td>
+                <td><?= (int)rp_json_max($h['llmax'] ?? '') ?>G</td>
+                <td class="small text-muted"><?= htmlspecialchars((string)$h['data'], ENT_QUOTES, 'UTF-8') ?></td>
+                <td><?= htmlspecialchars((string)$h['datae'], ENT_QUOTES, 'UTF-8') ?></td>
+                <td>
+                  <?php if ((string)$h['qk'] === 'true'): ?>
+                    <span class="badge badge-success">正常</span>
+                  <?php else: ?>
+                    <span class="badge badge-danger">暂停</span>
+                  <?php endif; ?>
+                </td>
+                <td>
+                  <button type="button" class="btn btn-xs btn-outline-danger"
+                          onclick="rpUnbind('<?= htmlspecialchars($hu, ENT_QUOTES, 'UTF-8') ?>')"
+                          title="解除归属,不删除主机">解除归属</button>
+                </td>
+              </tr>
+            <?php endforeach; ?>
+          <?php endif; ?>
+          </tbody>
+        </table>
+      </div>
+
+      <div class="panel-footer">
+        <span class="mdi mdi-information-outline"></span>
+        归属关系存在资源池表的 <code>host_users</code> 字段(按主机账号匹配),未改动主机表结构。
+        主机在「主机列表」被删除后,归属会自动失效不再统计,可点「清理失效归属」清掉残留记录。
+      </div>
+    </div>
+  </div>
+</div>
+
+<script type="text/javascript" src="<?= mnbt_asset_url('js/main.min.js') ?>"></script>
+<script type="text/javascript">
+var RP_POOLS = <?= json_encode(array_map(function ($p) {
+	return ['id' => (int)$p['id'], 'name' => (string)$p['name'], 'status' => (string)$p['status']];
+}, $pools), JSON_UNESCAPED_UNICODE) ?: '[]' ?>;
+
+var RP_UNBOUND = <?= json_encode(array_map(function ($h) {
+	return ['user' => (string)$h['user'], 'sqldz' => (string)$h['sqldz'], 'ssbt' => (string)$h['ssbt']];
+}, $unbound), JSON_UNESCAPED_UNICODE) ?: '[]' ?>;
+
+function rpEsc(s) { return $('<div>').text(s == null ? '' : s).html(); }
+function rpResp(resp) {
+	try { return typeof resp === 'string' ? JSON.parse(resp) : resp; } catch (e) { return {}; }
+}
+function rpOk(j) { return j && (j.qk === 1 || j.qk === '1'); }
+function rpMsg(j) { return (j && (j.msg || j.code)) || '未知返回'; }
+
+function rpUnbind(hostUser) {
+	$.confirm({
+		title: '解除资源池归属',
+		content: '确认解除主机「' + rpEsc(hostUser) + '」的资源池归属?<br/>主机<b>不会</b>被删除,其占用的配额会从资源池释放。',
+		type: 'orange',
+		buttons: {
+			confirm: {
+				text: '确认解除', btnClass: 'btn-warning',
+				action: function () {
+					msloading('正在处理...');
+					$.post('ajax.php', { gn: 'p_respool_unbind_host', host_user: hostUser }, function (resp) {
+						var j = rpResp(resp);
+						msloadingde();
+						if (rpOk(j)) { msalert(1, rpMsg(j), 1500); setTimeout(function () { location.reload(); }, 600); }
+						else { msalert(4, rpMsg(j), 4000); }
+					}).fail(function () { msloadingde(); msalert(4, '网络错误', 3000); });
+				}
+			},
+			cancel: { text: '取消' }
+		}
+	});
+}
+
+function rpBindDialog() {
+	if (!RP_POOLS.length) {
+		$.alert({ title: '没有资源池', content: '请先到「添加资源池」创建资源池。', type: 'red' });
+		return;
+	}
+	if (!RP_UNBOUND.length) {
+		$.alert({ title: '没有可绑定的主机', content: '所有主机都已归属资源池,或系统内还没有主机。', type: 'orange' });
+		return;
+	}
+	var pOpts = '';
+	for (var i = 0; i < RP_POOLS.length; i++) {
+		var dis = RP_POOLS[i].status === 'enabled' ? '' : '(已禁用)';
+		pOpts += '<option value="' + RP_POOLS[i].id + '">' + rpEsc(RP_POOLS[i].name) + dis + '</option>';
+	}
+	var hOpts = '';
+	for (var k = 0; k < RP_UNBOUND.length; k++) {
+		var h = RP_UNBOUND[k];
+		hOpts += '<option value="' + rpEsc(h.user) + '">' + rpEsc(h.user) + ' — ' + rpEsc(h.sqldz) + ' (' + rpEsc(h.ssbt) + ')</option>';
+	}
+	$.confirm({
+		title: '绑定已有主机到资源池',
+		columnClass: 'col-md-8 col-md-offset-2',
+		content: '<div class="form-group"><label>目标资源池</label>' +
+			'<select class="form-control" id="rp-bd-pool">' + pOpts + '</select></div>' +
+			'<div class="form-group"><label>选择主机(仅列出未归属任何资源池的主机)</label>' +
+			'<select class="form-control" id="rp-bd-host">' + hOpts + '</select></div>' +
+			'<p class="small text-muted">绑定后该主机的空间 / 流量会计入所选资源池的已用配额。</p>',
+		buttons: {
+			confirm: {
+				text: '确认绑定', btnClass: 'btn-success',
+				action: function () {
+					msloading('正在绑定...');
+					$.post('ajax.php', {
+						gn: 'p_respool_bind_host',
+						pool_id: $('#rp-bd-pool').val(),
+						host_user: $('#rp-bd-host').val()
+					}, function (resp) {
+						var j = rpResp(resp);
+						msloadingde();
+						if (rpOk(j)) {
+							msalert(1, rpMsg(j), 1500);
+							setTimeout(function () { location.reload(); }, 600);
+						} else {
+							$.alert({ title: '绑定失败', content: rpEsc(rpMsg(j)), type: 'red' });
+						}
+					}).fail(function () { msloadingde(); msalert(4, '网络错误', 3000); });
+					return true;
+				}
+			},
+			cancel: { text: '取消' }
+		}
+	});
+}
+
+function rpPrune() {
+	$.confirm({
+		title: '清理失效归属',
+		content: '把已删除主机的账号从资源池的归属列表里移除。<br/>不会影响任何现存主机。',
+		buttons: {
+			confirm: {
+				text: '开始清理', btnClass: 'btn-warning',
+				action: function () {
+					msloading('正在清理...');
+					$.post('ajax.php', { gn: 'p_respool_prune' }, function (resp) {
+						var j = rpResp(resp);
+						msloadingde();
+						if (rpOk(j)) { msalert(1, rpMsg(j), 2000); setTimeout(function () { location.reload(); }, 900); }
+						else { msalert(4, rpMsg(j), 4000); }
+					}).fail(function () { msloadingde(); msalert(4, '网络错误', 3000); });
+				}
+			},
+			cancel: { text: '取消' }
+		}
+	});
+}
+</script>
diff --git a/app_plugins/resource_pool/admin/list.php b/app_plugins/resource_pool/admin/list.php
new file mode 100644
index 0000000..5b225c8
--- /dev/null
+++ b/app_plugins/resource_pool/admin/list.php
@@ -0,0 +1,355 @@
+<?php
+/**
+ * 管理员端 - 资源池列表
+ * URL: admin/plugin.php?p=resource_pool&page=list
+ */
+if (!defined('IN_CRONLITE')) {
+	exit;
+}
+
+rp_ensure_schema();
+
+$page_num = max(1, (int)($_GET['page_num'] ?? 1));
+$kw       = trim((string)($_GET['kw'] ?? ''));
+$status_f = trim((string)($_GET['status'] ?? ''));
+
+$result      = rp_list($page_num, 20, $kw, $status_f);
+$list        = $result['list'];
+$total       = $result['total'];
+$total_pages = $result['per_page'] > 0 ? (int)ceil($total / $result['per_page']) : 1;
+$statuses    = rp_statuses();
+$all_nodes   = rp_all_nodes();
+
+// 一次性统计本页所有资源池的用量,避免逐行查询
+$usage_map = rp_usage_batch(array_map(function ($p) {
+	return (int)$p['id'];
+}, $list));
+
+/** 配额单元格:已用/总额,超额标红;总额 0 表示不限 */
+$quota_cell = function ($used, $total, $unit) {
+	$total = (int)$total;
+	if ($total <= 0) {
+		return '<span class="text-success">' . (int)$used . $unit . ' / 不限</span>';
+	}
+	$cls = ((int)$used > $total) ? 'text-danger' : 'text-success';
+	return '<span class="' . $cls . '">' . (int)$used . ' / ' . $total . $unit . '</span>';
+};
+
+$title = '资源池列表';
+mnbt_admin_include('head');
+
+/** 拼接分页链接 */
+$page_url = function ($n) use ($kw, $status_f) {
+	return 'plugin.php?p=resource_pool&page=list&page_num=' . (int)$n
+		. '&kw=' . urlencode($kw) . '&status=' . urlencode($status_f);
+};
+?>
+<link rel="stylesheet" href="<?= mnbt_asset_url('js/jquery-confirm/jquery-confirm.min.css') ?>">
+<link href="<?= mnbt_asset_url('js/bootstrap-datepicker/bootstrap-datepicker3.min.css') ?>" rel="stylesheet">
+<script src="<?= mnbt_asset_url('js/jquery-confirm/jquery-confirm.min.js') ?>"></script>
+
+<div class="container-fluid p-t-15">
+  <div class="card">
+    <header class="card-header">
+      <div class="card-title" style="display:inline-block">资源池列表</div>
+      <small class="text-muted ml-2">共 <?= (int)$total ?> 个资源池</small>
+    </header>
+    <div class="card-body">
+
+      <form method="get" class="form-inline mb-3">
+        <input type="hidden" name="p" value="resource_pool">
+        <input type="hidden" name="page" value="list">
+        <input type="text" name="kw" class="form-control form-control-sm mr-2" placeholder="资源池名 / 用户名"
+               value="<?= htmlspecialchars($kw, ENT_QUOTES, 'UTF-8') ?>">
+        <select name="status" class="form-control form-control-sm mr-2">
+          <option value="">全部状态</option>
+          <?php foreach ($statuses as $k => $v): ?>
+            <option value="<?= htmlspecialchars($k, ENT_QUOTES, 'UTF-8') ?>" <?= $status_f === $k ? 'selected' : '' ?>>
+              <?= htmlspecialchars($v, ENT_QUOTES, 'UTF-8') ?>
+            </option>
+          <?php endforeach; ?>
+        </select>
+        <button type="submit" class="btn btn-sm btn-primary">搜索</button>
+        <a href="plugin.php?p=resource_pool&page=list" class="btn btn-sm btn-outline-secondary ml-2">重置</a>
+        <a href="plugin.php?p=resource_pool&page=add" class="btn btn-sm btn-success ml-2">
+          <i class="mdi mdi-plus"></i> 添加资源池
+        </a>
+        <a href="plugin.php?p=resource_pool&page=hosts" class="btn btn-sm btn-outline-primary ml-2">
+          <i class="mdi mdi-server"></i> 资源主机管理
+        </a>
+        <button type="button" class="btn btn-sm btn-outline-secondary ml-2" onclick="rpRepair()" title="补建插件数据表与 host_users 列">
+          修复数据表
+        </button>
+      </form>
+
+      <div class="table-responsive">
+        <table class="table table-bordered table-hover table-striped">
+          <thead>
+            <tr>
+              <th style="width:60px">ID</th>
+              <th>资源池名</th>
+              <th>用户名</th>
+              <th>密码</th>
+              <th>可用节点</th>
+              <th>网页空间<br/><small class="text-muted">已用/总额</small></th>
+              <th>数据库空间<br/><small class="text-muted">已用/总额</small></th>
+              <th>流量<br/><small class="text-muted">已用/总额</small></th>
+              <th>主机数</th>
+              <th>到期日期</th>
+              <th>状态</th>
+              <th style="width:210px">操作</th>
+            </tr>
+          </thead>
+          <tbody>
+          <?php if (empty($list)): ?>
+            <tr><td colspan="12" class="text-center text-muted">暂无资源池,点击右上「添加资源池」创建</td></tr>
+          <?php else: ?>
+            <?php foreach ($list as $p):
+              $pid     = (int)$p['id'];
+              $usage   = $usage_map[$pid] ?? ['hosts' => 0, 'web' => 0, 'sql' => 0, 'flow' => 0];
+              $nodes   = rp_decode_nodes($p['nodes'] ?? '');
+              $expired = rp_is_expired($p);
+              $pname   = htmlspecialchars($p['name'], ENT_QUOTES, 'UTF-8');
+            ?>
+              <tr>
+                <td><?= $pid ?></td>
+                <td><b><?= $pname ?></b>
+                  <?php if (!empty($p['remark'])): ?>
+                    <br/><small class="text-muted"><?= htmlspecialchars($p['remark'], ENT_QUOTES, 'UTF-8') ?></small>
+                  <?php endif; ?>
+                </td>
+                <td><?= htmlspecialchars($p['username'], ENT_QUOTES, 'UTF-8') ?></td>
+                <td>
+                  <span class="rp-pass" data-pass="<?= htmlspecialchars($p['password'], ENT_QUOTES, 'UTF-8') ?>">••••••</span>
+                  <a href="#!" class="ml-1 small" onclick="rpTogglePass(this);return false;">显示</a>
+                </td>
+                <td>
+                  <?php if (empty($nodes)): ?>
+                    <span class="badge badge-secondary">不限</span>
+                  <?php else: ?>
+                    <?php foreach ($nodes as $n): ?>
+                      <span class="badge badge-light"><?= htmlspecialchars($n, ENT_QUOTES, 'UTF-8') ?></span>
+                    <?php endforeach; ?>
+                  <?php endif; ?>
+                </td>
+                <td><?= $quota_cell($usage['web'], $p['web_space'], 'MB') ?></td>
+                <td><?= $quota_cell($usage['sql'], $p['sql_space'], 'MB') ?></td>
+                <td><?= $quota_cell($usage['flow'], $p['flow'], 'G') ?></td>
+                <td>
+                  <a href="plugin.php?p=resource_pool&page=hosts&pool_id=<?= $pid ?>"><?= (int)$usage['hosts'] ?> 台</a>
+                </td>
+                <td>
+                  <?php if (trim((string)$p['expire_date']) === '' || $p['expire_date'] === '0000-00-00'): ?>
+                    <span class="text-success">永不到期</span>
+                  <?php else: ?>
+                    <span class="<?= $expired ? 'text-danger' : 'text-success' ?>">
+                      <?= htmlspecialchars($p['expire_date'], ENT_QUOTES, 'UTF-8') ?>
+                    </span>
+                    <?php if ($expired): ?><br/><span class="badge badge-danger">已到期</span><?php endif; ?>
+                  <?php endif; ?>
+                </td>
+                <td>
+                  <?php if (($p['status'] ?? '') === 'enabled'): ?>
+                    <span class="badge badge-success">启用</span>
+                  <?php else: ?>
+                    <span class="badge badge-danger">禁用</span>
+                  <?php endif; ?>
+                </td>
+                <td>
+                  <button type="button" class="btn btn-xs btn-primary" title="从该资源池开通主机"
+                          onclick="rpOpenHost(<?= $pid ?>, '<?= $pname ?>')">开通主机</button>
+                  <a class="btn btn-xs btn-default" href="plugin.php?p=resource_pool&page=add&id=<?= $pid ?>" title="编辑">
+                    <i class="mdi mdi-pencil"></i>
+                  </a>
+                  <?php if (($p['status'] ?? '') === 'enabled'): ?>
+                    <button type="button" class="btn btn-xs btn-warning" onclick="rpStatus(<?= $pid ?>, 'disabled')" title="禁用">禁用</button>
+                  <?php else: ?>
+                    <button type="button" class="btn btn-xs btn-success" onclick="rpStatus(<?= $pid ?>, 'enabled')" title="启用">启用</button>
+                  <?php endif; ?>
+                  <button type="button" class="btn btn-xs btn-danger" onclick="rpDelete(<?= $pid ?>, '<?= $pname ?>')" title="删除">
+                    <i class="mdi mdi-window-close"></i>
+                  </button>
+                </td>
+              </tr>
+            <?php endforeach; ?>
+          <?php endif; ?>
+          </tbody>
+        </table>
+      </div>
+
+      <?php if ($total_pages > 1): ?>
+        <nav>
+          <ul class="pagination justify-content-center">
+            <?php for ($i = 1; $i <= $total_pages; $i++): ?>
+              <li class="page-item <?= $i === $page_num ? 'active' : '' ?>">
+                <a class="page-link" href="<?= htmlspecialchars($page_url($i), ENT_QUOTES, 'UTF-8') ?>"><?= $i ?></a>
+              </li>
+            <?php endfor; ?>
+          </ul>
+        </nav>
+      <?php endif; ?>
+
+      <div class="panel-footer">
+        <span class="mdi mdi-information-outline"></span>
+        「开通主机」会调用所选节点的宝塔 API 创建站点,并把新主机的账号记入本资源池的
+        <code>host_users</code>(未改动主机表结构);删除资源池不会删除已开通的主机,只会解除归属关系。
+        主机分布与归属管理见「资源主机管理」。
+      </div>
+    </div>
+  </div>
+</div>
+
+<script src="<?= mnbt_asset_url('js/bootstrap-datepicker/bootstrap-datepicker.min.js') ?>"></script>
+<script src="<?= mnbt_asset_url('js/bootstrap-datepicker/locales/bootstrap-datepicker.zh-CN.min.js') ?>"></script>
+<script type="text/javascript" src="<?= mnbt_asset_url('js/main.min.js') ?>"></script>
+<script type="text/javascript">
+var RP_NODES = <?= json_encode(array_map(function ($n) {
+	return ['btdh' => (string)$n['btdh'], 'btip' => (string)$n['btip']];
+}, $all_nodes), JSON_UNESCAPED_UNICODE) ?: '[]' ?>;
+
+var RP_POOL_NODES = <?= json_encode(array_reduce($list, function ($carry, $p) {
+	$carry[(string)(int)$p['id']] = rp_decode_nodes($p['nodes'] ?? '');
+	return $carry;
+}, []), JSON_UNESCAPED_UNICODE) ?: '{}' ?>;
+
+function rpEsc(s) { return $('<div>').text(s == null ? '' : s).html(); }
+
+function rpTogglePass(a) {
+	var span = $(a).prev('.rp-pass');
+	if ($(a).text() === '显示') {
+		span.text(span.data('pass'));
+		$(a).text('隐藏');
+	} else {
+		span.text('••••••');
+		$(a).text('显示');
+	}
+}
+
+function rpResp(resp) {
+	try { return typeof resp === 'string' ? JSON.parse(resp) : resp; } catch (e) { return {}; }
+}
+function rpOk(j) { return j && (j.qk === 1 || j.qk === '1'); }
+function rpMsg(j) { return (j && (j.msg || j.code)) || '未知返回'; }
+
+function rpRepair() {
+	msloading('正在修复...');
+	$.post('ajax.php', { gn: 'p_respool_repair' }, function (resp) {
+		var j = rpResp(resp);
+		msloadingde();
+		if (rpOk(j)) { msalert(1, rpMsg(j), 2000); setTimeout(function () { location.reload(); }, 800); }
+		else { msalert(4, rpMsg(j), 4000); }
+	}).fail(function () { msloadingde(); msalert(4, '网络错误', 3000); });
+}
+
+function rpStatus(id, status) {
+	msloading('正在处理...');
+	$.post('ajax.php', { gn: 'p_respool_status', id: id, status: status }, function (resp) {
+		var j = rpResp(resp);
+		msloadingde();
+		if (rpOk(j)) { msalert(1, rpMsg(j), 1500); setTimeout(function () { location.reload(); }, 600); }
+		else { msalert(4, rpMsg(j), 4000); }
+	}).fail(function () { msloadingde(); msalert(4, '网络错误', 3000); });
+}
+
+function rpDelete(id, name) {
+	$.confirm({
+		title: '删除资源池',
+		content: '确认删除资源池「' + rpEsc(name) + '」?<br/>已开通的主机<b>不会</b>被删除,只会解除归属关系。',
+		type: 'red',
+		buttons: {
+			confirm: {
+				text: '确认删除', btnClass: 'btn-danger',
+				action: function () {
+					msloading('正在删除...');
+					$.post('ajax.php', { gn: 'p_respool_delete', id: id }, function (resp) {
+						var j = rpResp(resp);
+						msloadingde();
+						if (rpOk(j)) { msalert(1, rpMsg(j), 1500); setTimeout(function () { location.reload(); }, 600); }
+						else { msalert(4, rpMsg(j), 4000); }
+					}).fail(function () { msloadingde(); msalert(4, '网络错误', 3000); });
+				}
+			},
+			cancel: { text: '取消' }
+		}
+	});
+}
+
+function rpOpenHost(poolId, poolName) {
+	var allow = RP_POOL_NODES[String(poolId)] || [];
+	var opts = '';
+	for (var i = 0; i < RP_NODES.length; i++) {
+		var nd = RP_NODES[i];
+		if (allow.length && allow.indexOf(nd.btdh) === -1) continue;
+		opts += '<option value="' + rpEsc(nd.btdh) + '">' + rpEsc(nd.btdh) + ' (' + rpEsc(nd.btip) + ')</option>';
+	}
+	if (opts === '') {
+		$.alert({ title: '无可用节点', content: '该资源池没有可用节点,请先编辑资源池勾选节点,或在「宝塔管理」添加节点。', type: 'red' });
+		return;
+	}
+
+	$.confirm({
+		title: '从资源池开通主机 - ' + rpEsc(poolName),
+		columnClass: 'col-md-8 col-md-offset-2',
+		content: '<div class="form-group"><label>开通节点</label><select class="form-control" id="rp-oh-node">' + opts + '</select></div>' +
+			'<div class="form-group"><label>主机账号(FTP/SQL 账号,≥6 位,字母数字下划线)</label>' +
+			'<input type="text" class="form-control" id="rp-oh-user" placeholder="例如 user123456"></div>' +
+			'<div class="form-group"><label>主机密码(≥6 位)</label>' +
+			'<input type="text" class="form-control" id="rp-oh-pass" placeholder="例如 Abc123456"></div>' +
+			'<div class="form-group"><label>网页空间(MB)</label><input type="number" min="0" class="form-control" id="rp-oh-web" value="1024"></div>' +
+			'<div class="form-group"><label>数据库空间(MB)</label><input type="number" min="0" class="form-control" id="rp-oh-sql" value="256"></div>' +
+			'<div class="form-group"><label>流量(G/月,0=不限)</label><input type="number" min="0" class="form-control" id="rp-oh-flow" value="0"></div>' +
+			'<div class="form-group"><label>域名最大绑定数(0=不限)</label><input type="number" min="0" class="form-control" id="rp-oh-ym" value="5"></div>' +
+			'<div class="form-group"><label>主机到期时间(留空=不启用到期检测)</label>' +
+			'<input type="text" class="form-control" id="rp-oh-expire" placeholder="yyyy-mm-dd"></div>',
+		buttons: {
+			confirm: {
+				text: '确认开通', btnClass: 'btn-primary',
+				action: function () {
+					var hu = $.trim($('#rp-oh-user').val());
+					var hp = $('#rp-oh-pass').val();
+					if (hu.length < 6 || hp.length < 6) {
+						$.alert('主机账号和密码均不能少于 6 位');
+						return false;
+					}
+					msloading('正在调用宝塔开通,请稍候...');
+					$.post('ajax.php', {
+						gn: 'p_respool_open_host',
+						pool_id: poolId,
+						node: $('#rp-oh-node').val(),
+						host_user: hu,
+						host_pass: hp,
+						web_space: $('#rp-oh-web').val() || 0,
+						sql_space: $('#rp-oh-sql').val() || 0,
+						flow: $('#rp-oh-flow').val() || 0,
+						domain_count: $('#rp-oh-ym').val() || 0,
+						expire_date: $.trim($('#rp-oh-expire').val()),
+						host_status: 'true'
+					}, function (resp) {
+						var j = rpResp(resp);
+						msloadingde();
+						if (rpOk(j)) {
+							$.alert({
+								title: '开通成功', type: 'green',
+								content: '主机 ID:' + rpEsc(j.host_id),
+								buttons: { ok: { action: function () { location.reload(); } } }
+							});
+						} else {
+							$.alert({ title: '开通失败', content: rpEsc(rpMsg(j)), type: 'red' });
+						}
+					}).fail(function () {
+						msloadingde();
+						$.alert({ title: '错误', content: '网络错误', type: 'red' });
+					});
+					return true;
+				}
+			},
+			cancel: { text: '取消' }
+		},
+		onContentReady: function () {
+			try {
+				$('#rp-oh-expire').datepicker({ format: 'yyyy-mm-dd', language: 'zh-CN', autoclose: true });
+			} catch (e) {}
+		}
+	});
+}
+</script>
diff --git a/app_plugins/resource_pool/bootstrap.php b/app_plugins/resource_pool/bootstrap.php
new file mode 100644
index 0000000..156c2da
--- /dev/null
+++ b/app_plugins/resource_pool/bootstrap.php
@@ -0,0 +1,300 @@
+<?php
+/**
+ * resource_pool 插件 - 主入口
+ *
+ * 管理员后台一级菜单「资源池管理」,二级菜单:
+ *   - 添加资源池
+ *   - 资源池列表
+ *
+ * 数据表:MN_plugin_respool(插件自有)
+ * 主机表:MN_zj 新增 pool_id 字段(默认 NULL,资源池开通的主机记录归属)
+ */
+
+if (!defined('IN_CRONLITE')) {
+	exit;
+}
+
+require_once __DIR__ . '/lib/pool.php';
+
+mnbt_plugin_register('resource_pool', [
+	'name'        => '资源池管理',
+	'description' => '资源池账号、可用节点、配额与到期管理',
+]);
+
+/* ============================================================
+ *  菜单与页面
+ * ============================================================ */
+
+mnbt_register_page('admin', 'add',   'admin/add.php',   '添加资源池');
+mnbt_register_page('admin', 'list',  'admin/list.php',  '资源池列表');
+mnbt_register_page('admin', 'hosts', 'admin/hosts.php', '资源主机管理');
+
+mnbt_register_menu('admin', [
+	'title'    => '资源池管理',
+	'icon'     => 'mdi-database',
+	'order'    => 60,
+	'children' => [
+		['title' => '添加资源池',   'page' => 'add',   'icon' => 'mdi-plus-box',             'order' => 10, 'multitabs' => true],
+		['title' => '资源池列表',   'page' => 'list',  'icon' => 'mdi-format-list-bulleted', 'order' => 20, 'multitabs' => true],
+		['title' => '资源主机管理', 'page' => 'hosts', 'icon' => 'mdi-server',               'order' => 30, 'multitabs' => true],
+	],
+]);
+
+mnbt_register_settings_tab([
+	'title' => '资源池管理',
+	'page'  => 'list',
+	'order' => 60,
+]);
+
+/* ============================================================
+ *  主机列表注入「资源池」列
+ * ============================================================
+ *  不改核心文件:在管理端 head 之后注入一段脚本,包装
+ *  $.fn.bootstrapTable,在核心 list.php 初始化主机表时插入一列。
+ *  仅在 admin/list.php?gn=zj 生效;异常时静默降级,不影响原页面。
+ */
+mnbt_register_partial_override('admin', 'head', function ($vars) {
+	if (($_GET['gn'] ?? '') !== 'zj') {
+		return null;
+	}
+	$script = (string)($_SERVER['SCRIPT_NAME'] ?? '');
+	if (basename($script) !== 'list.php') {
+		return null;
+	}
+	// 主机账号 => 资源池名(归属存在资源池表,不依赖主机表字段)
+	// JSON_FORCE_OBJECT:防止纯数字主机账号让 json_encode 输出数组而非对象
+	$map  = rp_host_user_name_map();
+	$json = json_encode($map, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT) ?: '{}';
+
+	$js = <<<HTML
+<script type="text/javascript">
+/* resource_pool 插件:向主机列表注入「资源池」列(按主机账号匹配) */
+(function () {
+	var POOL_BY_USER = {$json};
+	function formatter(value, row) {
+		var u = row ? row.user : null;
+		if (u === null || u === undefined || u === '') {
+			return '<span class="text-muted">-</span>';
+		}
+		var name = POOL_BY_USER[String(u)];
+		if (name) {
+			return '<span class="badge badge-info">' + \$('<div>').text(name).html() + '</span>';
+		}
+		return '<span class="text-muted">-</span>';
+	}
+	function inject(columns) {
+		if (!columns || !columns.length) return false;
+		for (var i = 0; i < columns.length; i++) {
+			if (columns[i] && columns[i].field === 'rp_pool') return false;
+		}
+		var at = columns.length;
+		for (var j = 0; j < columns.length; j++) {
+			if (columns[j] && columns[j].field === 'operate') { at = j; break; }
+		}
+		columns.splice(at, 0, { field: 'rp_pool', title: '资源池', formatter: formatter });
+		return true;
+	}
+	function wrap(real) {
+		var wrapped = function (option) {
+			try {
+				if (option && typeof option === 'object' && option.columns) {
+					if (Array.isArray(option.columns[0])) {
+						inject(option.columns[0]);
+					} else {
+						inject(option.columns);
+					}
+				}
+			} catch (e) {}
+			return real.apply(this, arguments);
+		};
+		return wrapped;
+	}
+	function install() {
+		if (!window.jQuery) return false;
+		var \$ = window.jQuery;
+		if (\$.fn.bootstrapTable) {
+			if (!\$.fn.bootstrapTable.__rpWrapped) {
+				var real = \$.fn.bootstrapTable;
+				var w = wrap(real);
+				for (var k in real) { if (Object.prototype.hasOwnProperty.call(real, k)) w[k] = real[k]; }
+				w.__rpWrapped = true;
+				\$.fn.bootstrapTable = w;
+			}
+			return true;
+		}
+		// bootstrapTable 尚未加载:拦截其赋值
+		try {
+			var stored;
+			Object.defineProperty(\$.fn, 'bootstrapTable', {
+				configurable: true,
+				enumerable: true,
+				get: function () { return stored; },
+				set: function (v) {
+					if (typeof v === 'function' && !v.__rpWrapped) {
+						var w2 = wrap(v);
+						for (var k2 in v) { if (Object.prototype.hasOwnProperty.call(v, k2)) w2[k2] = v[k2]; }
+						w2.__rpWrapped = true;
+						stored = w2;
+					} else {
+						stored = v;
+					}
+				}
+			});
+			return true;
+		} catch (e) { return false; }
+	}
+	try { install(); } catch (e) {}
+})();
+</script>
+HTML;
+	return ['after' => $js];
+}, 10);
+
+/* ============================================================
+ *  AJAX(管理端,gn 前缀 p_respool_)
+ * ============================================================ */
+
+/** 新增 / 编辑资源池 */
+mnbt_register_ajax('admin', 'p_respool_save', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	rp_ensure_schema();
+
+	$id = (int)($_POST['id'] ?? 0);
+	$in = [
+		'name'        => $_POST['name'] ?? '',
+		'username'    => $_POST['username'] ?? '',
+		'password'    => $_POST['password'] ?? '',
+		'nodes'       => isset($_POST['nodes']) && is_array($_POST['nodes']) ? $_POST['nodes'] : [],
+		'web_space'   => $_POST['web_space'] ?? 0,
+		'sql_space'   => $_POST['sql_space'] ?? 0,
+		'flow'        => $_POST['flow'] ?? 0,
+		'expire_date' => $_POST['expire_date'] ?? '',
+		'status'      => $_POST['status'] ?? 'enabled',
+		'remark'      => $_POST['remark'] ?? '',
+	];
+
+	if ($id > 0) {
+		$r = rp_update($id, $in);
+		if (!$r['ok']) {
+			json_exit_error($r['msg']);
+		}
+		mnbt_log($user ?? '系统', '插件-资源池', '编辑资源池 ID' . $id, '成功', $DB);
+		json_exit_success('保存成功', ['id' => $id]);
+	}
+
+	$r = rp_create($in);
+	if (!$r['ok']) {
+		json_exit_error($r['msg']);
+	}
+	mnbt_log($user ?? '系统', '插件-资源池', '添加资源池 ' . $in['name'], '成功', $DB);
+	json_exit_success('添加成功', ['id' => $r['id']]);
+});
+
+/** 删除资源池 */
+mnbt_register_ajax('admin', 'p_respool_delete', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	$id = (int)($_POST['id'] ?? 0);
+	$r  = rp_delete($id);
+	if (!$r['ok']) {
+		json_exit_error($r['msg']);
+	}
+	mnbt_log($user ?? '系统', '插件-资源池', '删除资源池 ID' . $id, '成功', $DB);
+	json_exit_success('删除成功');
+});
+
+/** 启用 / 禁用资源池 */
+mnbt_register_ajax('admin', 'p_respool_status', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	$id     = (int)($_POST['id'] ?? 0);
+	$status = (string)($_POST['status'] ?? '');
+	$r      = rp_set_status($id, $status);
+	if (!$r['ok']) {
+		json_exit_error($r['msg']);
+	}
+	mnbt_log($user ?? '系统', '插件-资源池', '资源池 ID' . $id . ' 状态改为 ' . $status, '成功', $DB);
+	json_exit_success('操作成功');
+});
+
+/** 从资源池开通主机 */
+mnbt_register_ajax('admin', 'p_respool_open_host', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	$pool_id = (int)($_POST['pool_id'] ?? 0);
+	$r = rp_open_host($pool_id, [
+		'node'         => $_POST['node'] ?? '',
+		'user'         => $_POST['host_user'] ?? '',
+		'pass'         => $_POST['host_pass'] ?? '',
+		'web_space'    => $_POST['web_space'] ?? 0,
+		'sql_space'    => $_POST['sql_space'] ?? 0,
+		'flow'         => $_POST['flow'] ?? 0,
+		'domain_count' => $_POST['domain_count'] ?? 0,
+		'expire_date'  => $_POST['expire_date'] ?? '',
+		'status'       => ($_POST['host_status'] ?? 'true') === 'true' ? 'true' : 'false',
+	]);
+	if (!$r['ok']) {
+		mnbt_log($user ?? '系统', '插件-资源池', '资源池 ID' . $pool_id . ' 开通主机失败:' . $r['msg'], '失败', $DB);
+		json_exit_error($r['msg']);
+	}
+	mnbt_log($user ?? '系统', '插件-资源池', '资源池 ID' . $pool_id . ' 开通主机 ID' . $r['host_id'], '成功', $DB);
+	json_exit_success('开通成功', ['host_id' => $r['host_id']]);
+});
+
+/** 把已有主机绑定到资源池(按主机账号写入 host_users) */
+mnbt_register_ajax('admin', 'p_respool_bind_host', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	$pool_id   = (int)($_POST['pool_id'] ?? 0);
+	$host_user = trim((string)($_POST['host_user'] ?? ''));
+	$r = rp_bind_host_user($pool_id, $host_user);
+	if (!$r['ok']) {
+		json_exit_error($r['msg']);
+	}
+	mnbt_log($user ?? '系统', '插件-资源池', '主机 ' . $host_user . ' 绑定到资源池 ID' . $pool_id, '成功', $DB);
+	json_exit_success($r['msg']);
+});
+
+/** 解除主机的资源池归属(从 host_users 移除,不删主机) */
+mnbt_register_ajax('admin', 'p_respool_unbind_host', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	$host_user = trim((string)($_POST['host_user'] ?? ''));
+	$pool_id   = isset($_POST['pool_id']) && $_POST['pool_id'] !== '' ? (int)$_POST['pool_id'] : null;
+	if ($host_user === '') {
+		json_exit_error('参数错误');
+	}
+	$r = rp_unbind_host_user($host_user, $pool_id);
+	if (!$r['ok']) {
+		json_exit_error($r['msg']);
+	}
+	mnbt_log($user ?? '系统', '插件-资源池', '解除主机 ' . $host_user . ' 的资源池归属', '成功', $DB);
+	json_exit_success($r['msg']);
+});
+
+/** 清理失效归属(主机已被删除但账号仍留在 host_users 里) */
+mnbt_register_ajax('admin', 'p_respool_prune', function () {
+	global $DB, $user;
+	mnbt_plugin_require_admin();
+	$pool_id = isset($_POST['pool_id']) && (int)$_POST['pool_id'] > 0 ? (int)$_POST['pool_id'] : null;
+	$r = rp_prune_host_users($pool_id);
+	if (!$r['ok']) {
+		json_exit_error($r['msg']);
+	}
+	if ((int)$r['removed'] > 0) {
+		mnbt_log($user ?? '系统', '插件-资源池', '清理失效归属 ' . (int)$r['removed'] . ' 条', '成功', $DB);
+	}
+	json_exit_success($r['msg'], ['removed' => (int)$r['removed']]);
+});
+
+/** 修复数据表(补建 MN_plugin_respool 与 host_users 列) */
+mnbt_register_ajax('admin', 'p_respool_repair', function () {
+	global $DB;
+	mnbt_plugin_require_admin();
+	rp_ensure_schema(true);
+	if (@$DB->get_row_prepare("SELECT `host_users` FROM " . RP_TABLE . " WHERE 1 LIMIT 1") === false) {
+		json_exit_error('资源池表的 host_users 列仍不可用,请检查数据库账号权限');
+	}
+	json_exit_success('数据表已就绪');
+});
diff --git a/app_plugins/resource_pool/install.sql b/app_plugins/resource_pool/install.sql
new file mode 100644
index 0000000..7b44b9d
--- /dev/null
+++ b/app_plugins/resource_pool/install.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS `MN_plugin_respool` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `name` varchar(120) NOT NULL DEFAULT '' COMMENT '资源池名',
+  `username` varchar(120) NOT NULL DEFAULT '' COMMENT '资源池用户名',
+  `password` varchar(255) NOT NULL DEFAULT '' COMMENT '资源池密码',
+  `nodes` text COMMENT '可用节点(MN_bt.btdh 的 JSON 数组,空数组=不限)',
+  `host_users` text COMMENT '归属本池的主机账号(MN_zj.user 的 JSON 数组)',
+  `web_space` int(11) NOT NULL DEFAULT '0' COMMENT '网页空间总配额 MB(0=不限)',
+  `sql_space` int(11) NOT NULL DEFAULT '0' COMMENT '数据库空间总配额 MB(0=不限)',
+  `flow` int(11) NOT NULL DEFAULT '0' COMMENT '流量总配额 GB/月(0=不限)',
+  `expire_date` varchar(50) NOT NULL DEFAULT '' COMMENT '到期日期 yyyy-mm-dd(空=永不到期)',
+  `status` varchar(20) NOT NULL DEFAULT 'enabled' COMMENT '资源池状态 enabled/disabled',
+  `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
+  `created_at` varchar(50) NOT NULL DEFAULT '',
+  `updated_at` varchar(50) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_respool_username` (`username`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
diff --git a/app_plugins/resource_pool/lib/pool.php b/app_plugins/resource_pool/lib/pool.php
new file mode 100644
index 0000000..0d84dfb
--- /dev/null
+++ b/app_plugins/resource_pool/lib/pool.php
@@ -0,0 +1,1058 @@
+<?php
+/**
+ * resource_pool 插件 - 公共函数库
+ *
+ * 归属关系存在插件自有表 MN_plugin_respool.host_users(MN_zj.user 的 JSON 数组),
+ * 不改核心主机表结构、不修改任何核心文件。
+ */
+
+if (!defined('IN_CRONLITE')) {
+	exit;
+}
+
+define('RP_TABLE', 'MN_plugin_respool');
+
+/* ============================================================
+ *  建表保障(幂等)
+ * ============================================================
+ *  install.sql 已建表;此处兜底处理"手动放目录未走安装"或
+ *  "旧版本升级"的情况。只在后台页面/AJAX 内调用,避免拖慢用户端。
+ */
+
+function rp_ensure_schema($force = false)
+{
+	global $DB;
+	static $done = false;
+	if (!isset($DB) || !is_object($DB)) {
+		return;
+	}
+	if ($done && !$force) {
+		return;
+	}
+	$done = true;
+
+	@$DB->query("CREATE TABLE IF NOT EXISTS `" . RP_TABLE . "` (
+		`id` int(11) NOT NULL AUTO_INCREMENT,
+		`name` varchar(120) NOT NULL DEFAULT '',
+		`username` varchar(120) NOT NULL DEFAULT '',
+		`password` varchar(255) NOT NULL DEFAULT '',
+		`nodes` text,
+		`host_users` text,
+		`web_space` int(11) NOT NULL DEFAULT '0',
+		`sql_space` int(11) NOT NULL DEFAULT '0',
+		`flow` int(11) NOT NULL DEFAULT '0',
+		`expire_date` varchar(50) NOT NULL DEFAULT '',
+		`status` varchar(20) NOT NULL DEFAULT 'enabled',
+		`remark` varchar(500) NOT NULL DEFAULT '',
+		`created_at` varchar(50) NOT NULL DEFAULT '',
+		`updated_at` varchar(50) NOT NULL DEFAULT '',
+		PRIMARY KEY (`id`),
+		UNIQUE KEY `uk_respool_username` (`username`)
+	) ENGINE=MyISAM DEFAULT CHARSET=utf8");
+
+	// host_users:老版本(1.0.0)建的表没有这一列,补上
+	if (@$DB->get_row_prepare("SELECT `host_users` FROM " . RP_TABLE . " WHERE 1 LIMIT 1") === false) {
+		@$DB->query("ALTER TABLE `" . RP_TABLE . "` ADD COLUMN `host_users` text");
+	}
+
+	rp_migrate_from_pool_id();
+}
+
+/**
+ * 从 1.0.0 版的 MN_zj.pool_id 迁移到 host_users。
+ *
+ * 1.0.0 曾给 MN_zj 加过 pool_id 字段;现在归属关系改存在资源池表里。
+ * 若检测到该字段且仍有数据,则把归属搬到 host_users。
+ * 不删除 MN_zj.pool_id(不动核心表结构),迁移后该字段闲置不影响原功能。
+ */
+function rp_migrate_from_pool_id()
+{
+	global $DB;
+	// pool_id 不存在(正常的新装)→ 无需迁移
+	if (@$DB->get_row_prepare("SELECT `pool_id` FROM MN_zj WHERE 1 LIMIT 1") === false) {
+		return;
+	}
+	$rows = @$DB->get_all_prepare("SELECT user, pool_id FROM MN_zj WHERE pool_id IS NOT NULL AND pool_id>0");
+	if (!$rows) {
+		return;
+	}
+	$byPool = [];
+	foreach ($rows as $r) {
+		$pid = (int)$r['pool_id'];
+		$u   = trim((string)$r['user']);
+		if ($pid > 0 && $u !== '') {
+			$byPool[$pid][] = $u;
+		}
+	}
+	foreach ($byPool as $pid => $users) {
+		$pool = @$DB->get_row_prepare("SELECT id, host_users FROM " . RP_TABLE . " WHERE id=? LIMIT 1", [$pid]);
+		if (!$pool) {
+			continue;
+		}
+		$merged = rp_decode_host_users($pool['host_users'] ?? '');
+		foreach ($users as $u) {
+			if (!in_array($u, $merged, true)) {
+				$merged[] = $u;
+			}
+		}
+		@$DB->query_prepare(
+			"UPDATE " . RP_TABLE . " SET host_users=? WHERE id=?",
+			[json_encode(array_values($merged), JSON_UNESCAPED_UNICODE), $pid]
+		);
+	}
+	// 迁移完成,清空 pool_id 避免下次重复迁移
+	@$DB->query("UPDATE MN_zj SET pool_id=NULL WHERE pool_id IS NOT NULL");
+}
+
+/* ============================================================
+ *  工具
+ * ============================================================ */
+
+/** 当前时间字符串 */
+function rp_now()
+{
+	global $date;
+	return $date ?: date('Y-m-d H:i:s');
+}
+
+/** 资源池状态可选值 */
+function rp_statuses()
+{
+	return [
+		'enabled'  => '启用',
+		'disabled' => '禁用',
+	];
+}
+
+/** nodes 字段解码为节点代号数组 */
+function rp_decode_nodes($raw)
+{
+	if (is_array($raw)) {
+		return array_values(array_filter(array_map('strval', $raw), 'strlen'));
+	}
+	$raw = trim((string)$raw);
+	if ($raw === '') {
+		return [];
+	}
+	$arr = json_decode($raw, true);
+	if (!is_array($arr)) {
+		return [];
+	}
+	$out = [];
+	foreach ($arr as $v) {
+		$v = trim((string)$v);
+		if ($v !== '' && !in_array($v, $out, true)) {
+			$out[] = $v;
+		}
+	}
+	return $out;
+}
+
+/** host_users 字段解码为主机账号数组 */
+function rp_decode_host_users($raw)
+{
+	if (is_array($raw)) {
+		$arr = $raw;
+	} else {
+		$raw = trim((string)$raw);
+		if ($raw === '') {
+			return [];
+		}
+		$arr = json_decode($raw, true);
+		if (!is_array($arr)) {
+			return [];
+		}
+	}
+	$out = [];
+	foreach ($arr as $v) {
+		$v = trim((string)$v);
+		if ($v !== '' && !in_array($v, $out, true)) {
+			$out[] = $v;
+		}
+	}
+	return $out;
+}
+
+/** 全部宝塔节点(用于可用节点勾选) */
+function rp_all_nodes()
+{
+	global $DB;
+	return $DB->get_all_prepare("SELECT id, btdh, btip, btos FROM MN_bt WHERE 1 ORDER BY id ASC") ?: [];
+}
+
+/** 日期格式校验:空串或 yyyy-mm-dd */
+function rp_valid_date($d)
+{
+	$d = trim((string)$d);
+	if ($d === '') {
+		return true;
+	}
+	if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
+		return false;
+	}
+	$parts = explode('-', $d);
+	return checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
+}
+
+/** 资源池是否已过期 */
+function rp_is_expired($pool)
+{
+	$d = trim((string)($pool['expire_date'] ?? ''));
+	if ($d === '' || $d === '0000-00-00') {
+		return false;
+	}
+	return strtotime($d . ' 23:59:59') < time();
+}
+
+/** 资源池当前是否可用于开通(启用且未过期) */
+function rp_is_usable($pool)
+{
+	return is_array($pool)
+		&& ($pool['status'] ?? '') === 'enabled'
+		&& !rp_is_expired($pool);
+}
+
+/** 配额显示:0 表示不限 */
+function rp_quota_text($value, $unit)
+{
+	$value = (int)$value;
+	return $value <= 0 ? '不限' : ($value . $unit);
+}
+
+/* ============================================================
+ *  资源池 CRUD
+ * ============================================================ */
+
+/** 按 ID 取资源池 */
+function rp_get($id)
+{
+	global $DB;
+	$id = (int)$id;
+	if ($id <= 0) {
+		return null;
+	}
+	rp_ensure_schema();
+	$row = $DB->get_row_prepare("SELECT * FROM " . RP_TABLE . " WHERE id=? LIMIT 1", [$id]);
+	return $row ?: null;
+}
+
+/** 按用户名取资源池 */
+function rp_get_by_username($username)
+{
+	global $DB;
+	$username = trim((string)$username);
+	if ($username === '') {
+		return null;
+	}
+	rp_ensure_schema();
+	$row = $DB->get_row_prepare("SELECT * FROM " . RP_TABLE . " WHERE username=? LIMIT 1", [$username]);
+	return $row ?: null;
+}
+
+/**
+ * 分页列出资源池。
+ *
+ * @param int    $page     页码(从 1 开始)
+ * @param int    $per_page 每页条数
+ * @param string $kw       关键词(匹配资源池名 / 用户名)
+ * @param string $status   状态筛选(''=全部)
+ * @return array ['list'=>array, 'total'=>int, 'per_page'=>int, 'page'=>int]
+ */
+function rp_list($page = 1, $per_page = 20, $kw = '', $status = '')
+{
+	global $DB;
+	rp_ensure_schema();
+
+	$page     = max(1, (int)$page);
+	$per_page = max(1, min(200, (int)$per_page));
+	$kw       = trim((string)$kw);
+	$status   = trim((string)$status);
+
+	$where  = 'WHERE 1';
+	$params = [];
+	if ($kw !== '') {
+		$where .= ' AND (name LIKE ? OR username LIKE ?)';
+		$params[] = '%' . $kw . '%';
+		$params[] = '%' . $kw . '%';
+	}
+	if ($status !== '' && isset(rp_statuses()[$status])) {
+		$where .= ' AND status=?';
+		$params[] = $status;
+	}
+
+	$total  = (int)$DB->count_prepare("SELECT count(*) FROM " . RP_TABLE . " {$where}", $params);
+	$offset = ($page - 1) * $per_page;
+	$list   = $DB->get_all_prepare(
+		"SELECT * FROM " . RP_TABLE . " {$where} ORDER BY id DESC LIMIT {$offset},{$per_page}",
+		$params
+	) ?: [];
+
+	return ['list' => $list, 'total' => $total, 'per_page' => $per_page, 'page' => $page];
+}
+
+/**
+ * 校验资源池表单数据。
+ *
+ * @param array    $in 表单输入
+ * @param int|null $id 编辑时的资源池 ID(用于用户名查重排除自身)
+ * @return array ['ok'=>bool, 'msg'=>string, 'data'=>array]
+ */
+function rp_validate($in, $id = null)
+{
+	$name     = trim((string)($in['name'] ?? ''));
+	$username = trim((string)($in['username'] ?? ''));
+	$password = (string)($in['password'] ?? '');
+	$nodes    = rp_decode_nodes($in['nodes'] ?? []);
+	$web      = (int)($in['web_space'] ?? 0);
+	$sql      = (int)($in['sql_space'] ?? 0);
+	$flow     = (int)($in['flow'] ?? 0);
+	$expire   = trim((string)($in['expire_date'] ?? ''));
+	$status   = trim((string)($in['status'] ?? 'enabled'));
+	$remark   = trim((string)($in['remark'] ?? ''));
+
+	if ($name === '' || mb_strlen($name) > 120) {
+		return ['ok' => false, 'msg' => '资源池名不能为空且不超过 120 字'];
+	}
+	if (!preg_match('/^[a-zA-Z0-9_-]{4,120}$/', $username)) {
+		return ['ok' => false, 'msg' => '用户名只能用字母/数字/下划线/横线,长度 4-120 位'];
+	}
+	// 新增必填密码;编辑时留空表示不修改
+	if ($id === null) {
+		if (mb_strlen($password) < 6) {
+			return ['ok' => false, 'msg' => '密码不能少于 6 位'];
+		}
+	} elseif ($password !== '' && mb_strlen($password) < 6) {
+		return ['ok' => false, 'msg' => '密码不能少于 6 位(留空表示不修改)'];
+	}
+	if (mb_strlen($password) > 255) {
+		return ['ok' => false, 'msg' => '密码过长'];
+	}
+	if ($web < 0 || $sql < 0 || $flow < 0) {
+		return ['ok' => false, 'msg' => '配额不能为负数'];
+	}
+	if (!rp_valid_date($expire)) {
+		return ['ok' => false, 'msg' => '到期日期格式错误,应为 yyyy-mm-dd 或留空'];
+	}
+	if (!isset(rp_statuses()[$status])) {
+		return ['ok' => false, 'msg' => '资源池状态非法'];
+	}
+	if (mb_strlen($remark) > 500) {
+		return ['ok' => false, 'msg' => '备注不超过 500 字'];
+	}
+	// 可用节点必须存在于 MN_bt
+	if ($nodes) {
+		$valid = [];
+		foreach (rp_all_nodes() as $n) {
+			$valid[] = (string)$n['btdh'];
+		}
+		foreach ($nodes as $n) {
+			if (!in_array($n, $valid, true)) {
+				return ['ok' => false, 'msg' => '可用节点不存在:' . $n];
+			}
+		}
+	}
+	// 用户名唯一
+	$exist = rp_get_by_username($username);
+	if ($exist && (int)$exist['id'] !== (int)$id) {
+		return ['ok' => false, 'msg' => '该用户名已被其他资源池占用'];
+	}
+
+	return ['ok' => true, 'msg' => '', 'data' => [
+		'name'        => $name,
+		'username'    => $username,
+		'password'    => $password,
+		'nodes'       => json_encode(array_values($nodes), JSON_UNESCAPED_UNICODE),
+		'web_space'   => $web,
+		'sql_space'   => $sql,
+		'flow'        => $flow,
+		'expire_date' => $expire,
+		'status'      => $status,
+		'remark'      => $remark,
+	]];
+}
+
+/**
+ * 新增资源池。
+ * @return array ['ok'=>bool, 'msg'=>string, 'id'=>int]
+ */
+function rp_create($in)
+{
+	global $DB;
+	rp_ensure_schema();
+	$v = rp_validate($in, null);
+	if (!$v['ok']) {
+		return ['ok' => false, 'msg' => $v['msg']];
+	}
+	$d   = $v['data'];
+	$now = rp_now();
+	$ok  = $DB->query_prepare(
+		"INSERT INTO " . RP_TABLE . " (name, username, password, nodes, web_space, sql_space, flow, expire_date, status, remark, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
+		[$d['name'], $d['username'], $d['password'], $d['nodes'], $d['web_space'], $d['sql_space'], $d['flow'], $d['expire_date'], $d['status'], $d['remark'], $now, $now]
+	);
+	if (!$ok) {
+		return ['ok' => false, 'msg' => '写入数据库失败:' . $DB->error()];
+	}
+	$row = rp_get_by_username($d['username']);
+	return ['ok' => true, 'msg' => '添加成功', 'id' => $row ? (int)$row['id'] : 0];
+}
+
+/**
+ * 更新资源池。密码留空表示不修改。
+ * @return array ['ok'=>bool, 'msg'=>string]
+ */
+function rp_update($id, $in)
+{
+	global $DB;
+	rp_ensure_schema();
+	$id   = (int)$id;
+	$pool = rp_get($id);
+	if (!$pool) {
+		return ['ok' => false, 'msg' => '资源池不存在'];
+	}
+	$v = rp_validate($in, $id);
+	if (!$v['ok']) {
+		return ['ok' => false, 'msg' => $v['msg']];
+	}
+	$d = $v['data'];
+
+	if ($d['password'] === '') {
+		$ok = $DB->query_prepare(
+			"UPDATE " . RP_TABLE . " SET name=?, username=?, nodes=?, web_space=?, sql_space=?, flow=?, expire_date=?, status=?, remark=?, updated_at=? WHERE id=?",
+			[$d['name'], $d['username'], $d['nodes'], $d['web_space'], $d['sql_space'], $d['flow'], $d['expire_date'], $d['status'], $d['remark'], rp_now(), $id]
+		);
+	} else {
+		$ok = $DB->query_prepare(
+			"UPDATE " . RP_TABLE . " SET name=?, username=?, password=?, nodes=?, web_space=?, sql_space=?, flow=?, expire_date=?, status=?, remark=?, updated_at=? WHERE id=?",
+			[$d['name'], $d['username'], $d['password'], $d['nodes'], $d['web_space'], $d['sql_space'], $d['flow'], $d['expire_date'], $d['status'], $d['remark'], rp_now(), $id]
+		);
+	}
+	if (!$ok) {
+		return ['ok' => false, 'msg' => '更新失败:' . $DB->error()];
+	}
+	return ['ok' => true, 'msg' => '保存成功'];
+}
+
+/**
+ * 删除资源池。仅删除资源池记录本身,不删除已开通的主机;
+ * 归属关系随资源池记录一并消失(host_users 存在该行内)。
+ * @return array ['ok'=>bool, 'msg'=>string]
+ */
+function rp_delete($id)
+{
+	global $DB;
+	rp_ensure_schema();
+	$id   = (int)$id;
+	$pool = rp_get($id);
+	if (!$pool) {
+		return ['ok' => false, 'msg' => '资源池不存在'];
+	}
+	if (!$DB->query_prepare("DELETE FROM " . RP_TABLE . " WHERE id=? LIMIT 1", [$id])) {
+		return ['ok' => false, 'msg' => '删除失败:' . $DB->error()];
+	}
+	return ['ok' => true, 'msg' => '删除成功'];
+}
+
+/** 切换资源池状态 */
+function rp_set_status($id, $status)
+{
+	global $DB;
+	rp_ensure_schema();
+	$id = (int)$id;
+	if (!isset(rp_statuses()[$status])) {
+		return ['ok' => false, 'msg' => '状态非法'];
+	}
+	if (!rp_get($id)) {
+		return ['ok' => false, 'msg' => '资源池不存在'];
+	}
+	if (!$DB->query_prepare("UPDATE " . RP_TABLE . " SET status=?, updated_at=? WHERE id=?", [$status, rp_now(), $id])) {
+		return ['ok' => false, 'msg' => '操作失败:' . $DB->error()];
+	}
+	return ['ok' => true, 'msg' => '操作成功'];
+}
+
+/* ============================================================
+ *  配额统计(按已开通主机汇总)
+ * ============================================================ */
+
+/** 从 MN_zj 的 JSON 配额字段中取 max 值 */
+function rp_json_max($raw)
+{
+	$j = json_decode((string)$raw, true);
+	return is_array($j) ? (float)($j['max'] ?? 0) : 0.0;
+}
+
+/**
+ * 资源池已开通的主机列表。
+ *
+ * 归属关系存在资源池表的 host_users(主机账号 JSON 数组),
+ * 用 MN_zj.user 反查主机行;账号在 MN_zj 已不存在的(主机被删)自动忽略。
+ */
+function rp_pool_hosts($pool_id)
+{
+	global $DB;
+	rp_ensure_schema();
+	$pool = rp_get($pool_id);
+	if (!$pool) {
+		return [];
+	}
+	$users = rp_decode_host_users($pool['host_users'] ?? '');
+	if (!$users) {
+		return [];
+	}
+	$place = implode(',', array_fill(0, count($users), '?'));
+	return $DB->get_all_prepare(
+		"SELECT * FROM MN_zj WHERE user IN ({$place}) ORDER BY id DESC",
+		$users
+	) ?: [];
+}
+
+/**
+ * 主机账号 => 资源池 的反查表。
+ *
+ * @return array user => ['pool_id'=>int, 'pool_name'=>string]
+ */
+function rp_host_user_map()
+{
+	global $DB;
+	rp_ensure_schema();
+	$rows = $DB->get_all_prepare("SELECT id, name, host_users FROM " . RP_TABLE . " WHERE 1") ?: [];
+	$map  = [];
+	foreach ($rows as $r) {
+		foreach (rp_decode_host_users($r['host_users'] ?? '') as $u) {
+			// 一个主机账号只归属一个池;先出现的优先(写入时已做互斥校验)
+			if (!isset($map[$u])) {
+				$map[$u] = ['pool_id' => (int)$r['id'], 'pool_name' => (string)$r['name']];
+			}
+		}
+	}
+	return $map;
+}
+
+/**
+ * 查某主机账号归属的资源池。
+ * @return array|null ['pool_id'=>int,'pool_name'=>string]
+ */
+function rp_find_pool_by_host_user($host_user)
+{
+	$host_user = trim((string)$host_user);
+	if ($host_user === '') {
+		return null;
+	}
+	$map = rp_host_user_map();
+	return $map[$host_user] ?? null;
+}
+
+/**
+ * 把主机账号加入资源池的 host_users。
+ * @return array ['ok'=>bool,'msg'=>string]
+ */
+function rp_bind_host_user($pool_id, $host_user)
+{
+	global $DB;
+	rp_ensure_schema();
+	$host_user = trim((string)$host_user);
+	$pool      = rp_get($pool_id);
+	if (!$pool) {
+		return ['ok' => false, 'msg' => '资源池不存在'];
+	}
+	if ($host_user === '') {
+		return ['ok' => false, 'msg' => '主机账号不能为空'];
+	}
+	if (!$DB->get_row_prepare("SELECT id FROM MN_zj WHERE user=? LIMIT 1", [$host_user])) {
+		return ['ok' => false, 'msg' => '主机账号不存在:' . $host_user];
+	}
+	// 互斥:已归属其他池则拒绝
+	$exist = rp_find_pool_by_host_user($host_user);
+	if ($exist && $exist['pool_id'] !== (int)$pool['id']) {
+		return ['ok' => false, 'msg' => '该主机已归属资源池「' . $exist['pool_name'] . '」,请先解除归属'];
+	}
+	$users = rp_decode_host_users($pool['host_users'] ?? '');
+	if (in_array($host_user, $users, true)) {
+		return ['ok' => true, 'msg' => '该主机已在本资源池内'];
+	}
+	$users[] = $host_user;
+	$ok = $DB->query_prepare(
+		"UPDATE " . RP_TABLE . " SET host_users=?, updated_at=? WHERE id=?",
+		[json_encode(array_values($users), JSON_UNESCAPED_UNICODE), rp_now(), (int)$pool['id']]
+	);
+	if (!$ok) {
+		return ['ok' => false, 'msg' => '写入失败:' . $DB->error()];
+	}
+	return ['ok' => true, 'msg' => '绑定成功'];
+}
+
+/**
+ * 把主机账号从资源池的 host_users 移除。
+ * @param int|null $pool_id 为 null 时自动查归属池
+ * @return array ['ok'=>bool,'msg'=>string]
+ */
+function rp_unbind_host_user($host_user, $pool_id = null)
+{
+	global $DB;
+	rp_ensure_schema();
+	$host_user = trim((string)$host_user);
+	if ($host_user === '') {
+		return ['ok' => false, 'msg' => '主机账号不能为空'];
+	}
+	if ($pool_id === null) {
+		$found = rp_find_pool_by_host_user($host_user);
+		if (!$found) {
+			return ['ok' => false, 'msg' => '该主机没有资源池归属'];
+		}
+		$pool_id = $found['pool_id'];
+	}
+	$pool = rp_get($pool_id);
+	if (!$pool) {
+		return ['ok' => false, 'msg' => '资源池不存在'];
+	}
+	$users = rp_decode_host_users($pool['host_users'] ?? '');
+	$left  = array_values(array_filter($users, function ($u) use ($host_user) {
+		return $u !== $host_user;
+	}));
+	if (count($left) === count($users)) {
+		return ['ok' => false, 'msg' => '该主机不在此资源池内'];
+	}
+	$ok = $DB->query_prepare(
+		"UPDATE " . RP_TABLE . " SET host_users=?, updated_at=? WHERE id=?",
+		[json_encode($left, JSON_UNESCAPED_UNICODE), rp_now(), (int)$pool['id']]
+	);
+	if (!$ok) {
+		return ['ok' => false, 'msg' => '写入失败:' . $DB->error()];
+	}
+	return ['ok' => true, 'msg' => '已解除归属'];
+}
+
+/**
+ * 清理失效归属:host_users 里在 MN_zj 已不存在的主机账号。
+ * @return array ['ok'=>bool,'msg'=>string,'removed'=>int]
+ */
+function rp_prune_host_users($pool_id = null)
+{
+	global $DB;
+	rp_ensure_schema();
+
+	if ($pool_id === null) {
+		$pools = $DB->get_all_prepare("SELECT id, host_users FROM " . RP_TABLE . " WHERE 1") ?: [];
+	} else {
+		$one   = rp_get($pool_id);
+		$pools = $one ? [$one] : [];
+	}
+
+	$removed = 0;
+	foreach ($pools as $p) {
+		$users = rp_decode_host_users($p['host_users'] ?? '');
+		if (!$users) {
+			continue;
+		}
+		$place = implode(',', array_fill(0, count($users), '?'));
+		$rows  = $DB->get_all_prepare("SELECT user FROM MN_zj WHERE user IN ({$place})", $users) ?: [];
+		$alive = [];
+		foreach ($rows as $r) {
+			$alive[] = (string)$r['user'];
+		}
+		$keep = array_values(array_filter($users, function ($u) use ($alive) {
+			return in_array($u, $alive, true);
+		}));
+		if (count($keep) !== count($users)) {
+			$removed += count($users) - count($keep);
+			$DB->query_prepare(
+				"UPDATE " . RP_TABLE . " SET host_users=?, updated_at=? WHERE id=?",
+				[json_encode($keep, JSON_UNESCAPED_UNICODE), rp_now(), (int)$p['id']]
+			);
+		}
+	}
+	return ['ok' => true, 'msg' => $removed > 0 ? ('已清理 ' . $removed . ' 条失效归属') : '没有失效归属', 'removed' => $removed];
+}
+
+/**
+ * 统计资源池已分配用量。
+ *
+ * @return array ['hosts'=>int,'web'=>int,'sql'=>int,'flow'=>int](单位 MB/MB/GB)
+ */
+function rp_usage($pool_id)
+{
+	$out = ['hosts' => 0, 'web' => 0, 'sql' => 0, 'flow' => 0];
+	foreach (rp_pool_hosts($pool_id) as $h) {
+		$out['hosts']++;
+		$out['web']  += (int)rp_json_max($h['hxa'] ?? '');
+		$out['sql']  += (int)rp_json_max($h['hxb'] ?? '');
+		$out['flow'] += (int)rp_json_max($h['llmax'] ?? '');
+	}
+	return $out;
+}
+
+/**
+ * 批量统计多个资源池的用量(避免列表页 N+1 查询)。
+ *
+ * @param array $pool_ids
+ * @return array pool_id => ['hosts'=>int,'web'=>int,'sql'=>int,'flow'=>int]
+ */
+function rp_usage_batch($pool_ids)
+{
+	global $DB;
+	rp_ensure_schema();
+
+	$ids = [];
+	foreach ((array)$pool_ids as $v) {
+		$v = (int)$v;
+		if ($v > 0 && !in_array($v, $ids, true)) {
+			$ids[] = $v;
+		}
+	}
+	$out = [];
+	foreach ($ids as $v) {
+		$out[$v] = ['hosts' => 0, 'web' => 0, 'sql' => 0, 'flow' => 0];
+	}
+	if (!$ids) {
+		return $out;
+	}
+
+	// 取这些池的 host_users,建立 主机账号 => pool_id 映射
+	$place = implode(',', array_fill(0, count($ids), '?'));
+	$pools = $DB->get_all_prepare("SELECT id, host_users FROM " . RP_TABLE . " WHERE id IN ({$place})", $ids) ?: [];
+	$userToPool = [];
+	foreach ($pools as $p) {
+		foreach (rp_decode_host_users($p['host_users'] ?? '') as $u) {
+			if (!isset($userToPool[$u])) {
+				$userToPool[$u] = (int)$p['id'];
+			}
+		}
+	}
+	if (!$userToPool) {
+		return $out;
+	}
+
+	// 一次查回所有相关主机
+	// 注意:PHP 会把纯数字字符串键转成 int,这里统一转回字符串再绑定
+	$users  = array_map('strval', array_keys($userToPool));
+	$place2 = implode(',', array_fill(0, count($users), '?'));
+	$rows   = $DB->get_all_prepare(
+		"SELECT user, hxa, hxb, llmax FROM MN_zj WHERE user IN ({$place2})",
+		$users
+	) ?: [];
+	foreach ($rows as $r) {
+		$pid = $userToPool[(string)$r['user']] ?? 0;
+		if ($pid <= 0 || !isset($out[$pid])) {
+			continue;
+		}
+		$out[$pid]['hosts']++;
+		$out[$pid]['web']  += (int)rp_json_max($r['hxa'] ?? '');
+		$out[$pid]['sql']  += (int)rp_json_max($r['hxb'] ?? '');
+		$out[$pid]['flow'] += (int)rp_json_max($r['llmax'] ?? '');
+	}
+	return $out;
+}
+
+/** 资源池剩余配额;总额为 0(不限)时返回 null 表示不限 */
+function rp_remaining($pool, $usage = null)
+{
+	if ($usage === null) {
+		$usage = rp_usage($pool['id'] ?? 0);
+	}
+	$calc = function ($total, $used) {
+		$total = (int)$total;
+		return $total <= 0 ? null : ($total - (int)$used);
+	};
+	return [
+		'web'  => $calc($pool['web_space'] ?? 0, $usage['web']),
+		'sql'  => $calc($pool['sql_space'] ?? 0, $usage['sql']),
+		'flow' => $calc($pool['flow'] ?? 0, $usage['flow']),
+	];
+}
+
+/* ============================================================
+ *  从资源池开通主机
+ * ============================================================
+ *  复用核心 addzj 的宝塔调用流程(MPHX/bt_api.php),
+ *  额外做:可用节点白名单校验 + 配额校验,并把主机账号写入本池 host_users。
+ */
+
+/**
+ * @param int   $pool_id
+ * @param array $args node/user/pass/web_space/sql_space/flow/domain_count/expire_date/status
+ * @return array ['ok'=>bool,'msg'=>string,'host_id'=>int]
+ */
+function rp_open_host($pool_id, $args)
+{
+	global $DB, $conf, $date;
+
+	rp_ensure_schema();
+
+	$pool = rp_get($pool_id);
+	if (!$pool) {
+		return ['ok' => false, 'msg' => '资源池不存在'];
+	}
+	if (($pool['status'] ?? '') !== 'enabled') {
+		return ['ok' => false, 'msg' => '资源池已禁用,无法开通'];
+	}
+	if (rp_is_expired($pool)) {
+		return ['ok' => false, 'msg' => '资源池已于 ' . $pool['expire_date'] . ' 到期,无法开通'];
+	}
+
+	$node_dh  = trim((string)($args['node'] ?? ''));
+	$hostuser = trim((string)($args['user'] ?? ''));
+	$hostpass = (string)($args['pass'] ?? '');
+	$web      = (int)($args['web_space'] ?? 0);
+	$sqlspace = (int)($args['sql_space'] ?? 0);
+	$flow     = (int)($args['flow'] ?? 0);
+	$ymbds    = (int)($args['domain_count'] ?? 0);
+	$expire   = trim((string)($args['expire_date'] ?? ''));
+	$switch   = ($args['status'] ?? 'true') === 'true' ? 'true' : 'false';
+
+	if (mb_strlen($hostuser) < 6 || mb_strlen($hostpass) < 6) {
+		return ['ok' => false, 'msg' => '主机账号和密码均不能少于 6 位'];
+	}
+	if (!preg_match('/^[a-zA-Z0-9_]{6,60}$/', $hostuser)) {
+		return ['ok' => false, 'msg' => '主机账号只能用字母/数字/下划线,长度 6-60 位'];
+	}
+	if ($web < 0 || $sqlspace < 0 || $flow < 0 || $ymbds < 0) {
+		return ['ok' => false, 'msg' => '配额不能为负数'];
+	}
+	if (!rp_valid_date($expire)) {
+		return ['ok' => false, 'msg' => '到期日期格式错误,应为 yyyy-mm-dd 或留空'];
+	}
+
+	// 可用节点白名单
+	$allow = rp_decode_nodes($pool['nodes'] ?? '');
+	if ($allow && !in_array($node_dh, $allow, true)) {
+		return ['ok' => false, 'msg' => '该节点不在本资源池的可用节点内'];
+	}
+	$node = $DB->get_row_prepare("SELECT * FROM MN_bt WHERE btdh=? LIMIT 1", [$node_dh]);
+	if (!$node) {
+		return ['ok' => false, 'msg' => '节点不存在:' . $node_dh];
+	}
+
+	// 配额校验
+	$usage = rp_usage($pool_id);
+	$checks = [
+		['total' => (int)$pool['web_space'], 'used' => $usage['web'],  'need' => $web,      'label' => '网页空间',   'unit' => 'MB'],
+		['total' => (int)$pool['sql_space'], 'used' => $usage['sql'],  'need' => $sqlspace, 'label' => '数据库空间', 'unit' => 'MB'],
+		['total' => (int)$pool['flow'],      'used' => $usage['flow'], 'need' => $flow,     'label' => '流量',       'unit' => 'GB'],
+	];
+	foreach ($checks as $c) {
+		if ($c['total'] > 0 && ($c['used'] + $c['need']) > $c['total']) {
+			return ['ok' => false, 'msg' => sprintf(
+				'%s配额不足:总额 %d%s,已分配 %d%s,本次需 %d%s',
+				$c['label'], $c['total'], $c['unit'], $c['used'], $c['unit'], $c['need'], $c['unit']
+			)];
+		}
+	}
+
+	// 账号查重(本地)
+	if ($DB->get_row_prepare("SELECT id FROM MN_zj WHERE user=? LIMIT 1", [$hostuser])) {
+		return ['ok' => false, 'msg' => '该主机账号已存在,请更换'];
+	}
+
+	// 宝塔 API
+	$bt_api_file = ROOT . 'MPHX/bt_api.php';
+	if (!is_file($bt_api_file)) {
+		return ['ok' => false, 'msg' => 'bt_api 类文件缺失'];
+	}
+	require_once $bt_api_file;
+
+	$btipe = ($node['ptl'] == 'true' ? 'https' : 'http') . '://' . $node['btip'] . ':' . $node['btdk'];
+	$api   = new bt_api($btipe, $node['btmy']);
+
+	// PHP 版本:优先节点已保存的,否则自动探测最新
+	$phpVersion = $node['mrbts_php'] ?? '';
+	if ($phpVersion === '' || $phpVersion === '00') {
+		$phpList = $api->btapi_listphp();
+		if (is_array($phpList)) {
+			$versions = [];
+			foreach ($phpList as $v) {
+				if (($v['status'] ?? false) && ($v['version'] ?? '') !== '00') {
+					$versions[] = $v['version'];
+				}
+			}
+			if ($versions) {
+				usort($versions, function ($a, $b) {
+					return strcmp($b, $a);
+				});
+				$phpVersion = $versions[0];
+				$DB->query_prepare("UPDATE MN_bt SET mrbts_php=? WHERE btdh=? LIMIT 1", [$phpVersion, $node_dh]);
+			}
+		}
+	}
+	if ($phpVersion === '' || $phpVersion === '00') {
+		return ['ok' => false, 'msg' => '无法获取该节点的 PHP 版本,请先在宝塔面板安装 PHP 或在节点管理中设置默认 PHP 版本'];
+	}
+
+	// 站点目录名(防重名,与核心 addzj 同风格)
+	$now     = $date ?: date('Y-m-d H:i:s');
+	$wjler   = substr(md5($now . $hostuser . mt_rand(100, 999)), mt_rand(4, 10), 6);
+	$btserw  = 'mnbt.' . mt_rand(1, 999) . $wjler;
+	$mrml    = ($node['btos'] == '1' ? $conf['hxi'] : $conf['hxo']) . '/' . $btserw;
+	$datae   = $expire === '' ? '0000-00-00' : $expire;
+
+	$r_data = $api->webkt($hostuser, $hostpass, $btserw, '主机', 'true', 'true', $phpVersion, $mrml);
+	if (empty($r_data['siteStatus'])) {
+		return ['ok' => false, 'msg' => '宝塔创建站点失败:' . ($r_data['msg'] ?? '未知错误')];
+	}
+	$siteId = $r_data['siteId'] ?? 0;
+
+	$r_datan = $api->setdqsj($siteId, $datae);
+	if (!(($r_datan['status'] ?? '') == '1' || ($r_datan['status'] ?? '') == 'true')) {
+		@error_log('[resource_pool] setdqsj failed for host ' . $hostuser);
+	}
+
+	// FTP / 数据库 ID
+	$ftpid = '0';
+	$sqlid = '0';
+	$r_ftp = $api->sjlist('ftps');
+	$r_sql = $api->sjlist('databases');
+	if (isset($r_ftp['data']) && is_array($r_ftp['data'])) {
+		foreach ($r_ftp['data'] as $v) {
+			if (($v['name'] ?? '') === $hostuser) {
+				$ftpid = $v['id'];
+				break;
+			}
+		}
+	}
+	if (isset($r_sql['data']) && is_array($r_sql['data'])) {
+		foreach ($r_sql['data'] as $v) {
+			if (($v['name'] ?? '') === $hostuser) {
+				$sqlid = $v['id'];
+				break;
+			}
+		}
+	}
+
+	$webdx = json_encode(['max' => $web, 'dq' => 0]);
+	$sqldx = json_encode(['max' => $sqlspace, 'dq' => 0]);
+	$lldx  = json_encode(['max' => $flow, 'dq' => 0, 'statistics' => false]);
+
+	// 不显式指定 id,交给 AUTO_INCREMENT,避免并发开通时 id 撞车;
+	// 随后用唯一的 user 字段回查真实 id。写入的是核心原有字段,未改主机表结构。
+	$ok = $DB->query_prepare(
+		"INSERT INTO `MN_zj` (`ssbt`, `user`, `pass`, `sqluser`, `sqlpass`, `data`, `datae`, `qk`, `btid`, `sqldz`, `ftpid`, `ymbds`, `hxa`, `hxb`, `hxc`, `hxd`, `llmax`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+		[$node_dh, $hostuser, $hostpass, $hostuser, $hostpass, $now, $datae, $switch, $siteId, $btserw, $ftpid, (string)$ymbds, $webdx, $sqldx, '2', $sqlid, $lldx]
+	);
+	if (!$ok) {
+		return ['ok' => false, 'msg' => '宝塔已开通但本地写库失败,请联系管理员(siteId=' . $siteId . '):' . $DB->error()];
+	}
+
+	$newRow = $DB->get_row_prepare("SELECT id FROM MN_zj WHERE user=? ORDER BY id DESC LIMIT 1", [$hostuser]);
+	$newId  = $newRow ? (int)$newRow['id'] : 0;
+
+	// 记录归属:写进资源池表的 host_users
+	$bind = rp_bind_host_user($pool_id, $hostuser);
+	if (!$bind['ok']) {
+		@error_log('[resource_pool] bind host_user failed: pool=' . $pool_id . ' user=' . $hostuser . ' msg=' . $bind['msg']);
+	}
+
+	// 开关为关:宝塔侧同步停站 + 停 FTP(webkt 创建出来默认是运行状态)
+	if ($switch === 'false') {
+		@$api->siteqt($siteId, $btserw, false);
+		@$api->setftpzt($ftpid, $hostuser, '0');
+	}
+
+	if (function_exists('mnbt_do_action')) {
+		$host_row = $DB->get_row_prepare("SELECT * FROM MN_zj WHERE id=? LIMIT 1", [$newId]);
+		mnbt_do_action(
+			'host.created',
+			$host_row ?: ['id' => $newId, 'user' => $hostuser, 'ssbt' => $node_dh],
+			['source' => 'resource_pool', 'pool_id' => (int)$pool_id]
+		);
+	}
+
+	return ['ok' => true, 'msg' => '开通成功', 'host_id' => $newId, 'site' => $btserw];
+}
+
+/**
+ * 资源池名称映射:pool_id => name
+ */
+function rp_name_map()
+{
+	global $DB;
+	rp_ensure_schema();
+	$rows = $DB->get_all_prepare("SELECT id, name FROM " . RP_TABLE . " WHERE 1") ?: [];
+	$map  = [];
+	foreach ($rows as $r) {
+		$map[(int)$r['id']] = (string)$r['name'];
+	}
+	return $map;
+}
+
+/**
+ * 主机账号 => 资源池名(供主机列表注入列展示)
+ *
+ * 键统一转成字符串:纯数字的主机账号(如 123456)会被 PHP 转成 int 键,
+ * 若恰好构成连续下标,json_encode 会输出 JSON 数组而不是对象,导致前端查不到。
+ * 调用方 json_encode 时请带 JSON_FORCE_OBJECT。
+ */
+function rp_host_user_name_map()
+{
+	$out = [];
+	foreach (rp_host_user_map() as $u => $info) {
+		$out[(string)$u] = $info['pool_name'];
+	}
+	return $out;
+}
+
+/**
+ * 所有资源池开通的主机(跨池),可按资源池筛选。
+ *
+ * @param int    $pool_id 0 = 全部资源池
+ * @param string $kw      关键词(主机账号 / 网站名 / 所属宝塔)
+ * @return array 每行为 MN_zj 行 + pool_id / pool_name
+ */
+function rp_all_pool_hosts($pool_id = 0, $kw = '')
+{
+	global $DB;
+	rp_ensure_schema();
+
+	$pool_id = (int)$pool_id;
+	$kw      = trim((string)$kw);
+	$map     = rp_host_user_map();
+
+	// 按资源池过滤出目标主机账号(键可能被 PHP 转成 int,统一转回字符串)
+	$users = [];
+	foreach ($map as $u => $info) {
+		if ($pool_id > 0 && $info['pool_id'] !== $pool_id) {
+			continue;
+		}
+		$users[] = (string)$u;
+	}
+	if (!$users) {
+		return [];
+	}
+
+	$place  = implode(',', array_fill(0, count($users), '?'));
+	$params = $users;
+	$sql    = "SELECT * FROM MN_zj WHERE user IN ({$place})";
+	if ($kw !== '') {
+		$sql .= " AND (user LIKE ? OR sqldz LIKE ? OR ssbt LIKE ?)";
+		$like = '%' . $kw . '%';
+		$params[] = $like;
+		$params[] = $like;
+		$params[] = $like;
+	}
+	$sql .= " ORDER BY id DESC";
+
+	$rows = $DB->get_all_prepare($sql, $params) ?: [];
+	foreach ($rows as $i => $r) {
+		$u = (string)$r['user'];
+		$rows[$i]['pool_id']   = $map[$u]['pool_id'] ?? 0;
+		$rows[$i]['pool_name'] = $map[$u]['pool_name'] ?? '';
+	}
+	return $rows;
+}
+
+/**
+ * 未归属任何资源池的主机(供「绑定到资源池」选择)。
+ */
+function rp_unbound_hosts($limit = 500)
+{
+	global $DB;
+	rp_ensure_schema();
+	$limit = max(1, min(2000, (int)$limit));
+	$rows  = $DB->get_all_prepare("SELECT id, user, sqldz, ssbt FROM MN_zj WHERE 1 ORDER BY id DESC LIMIT {$limit}") ?: [];
+	$map   = rp_host_user_map();
+	$out   = [];
+	foreach ($rows as $r) {
+		if (!isset($map[(string)$r['user']])) {
+			$out[] = $r;
+		}
+	}
+	return $out;
+}
diff --git a/app_plugins/resource_pool/plugin.json b/app_plugins/resource_pool/plugin.json
new file mode 100644
index 0000000..b80af33
--- /dev/null
+++ b/app_plugins/resource_pool/plugin.json
@@ -0,0 +1,9 @@
+{
+  "id": "resource_pool",
+  "name": "资源池管理",
+  "version": "1.0.0",
+  "author": "池鲤-七枫云主机",
+  "description": "资源池(经销/代理配额)管理:资源池账号、可用节点白名单、网页空间/数据库空间/流量总配额、到期日期与状态;从资源池开通的主机会记录归属资源池。",
+  "requires_mnbt": "1.81",
+  "type": ["business"]
+}
diff --git a/app_plugins/resource_pool/uninstall.sql b/app_plugins/resource_pool/uninstall.sql
new file mode 100644
index 0000000..dcf7a42
--- /dev/null
+++ b/app_plugins/resource_pool/uninstall.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `MN_plugin_respool`;