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`;