仰望星辰工作室

better-staridc-MNBT

Z zfhsh first commit 2 天前
1<?php
2/**
3 * resource_pool 插件 - 公共函数库
4 *
5 * 归属关系存在插件自有表 MN_plugin_respool.host_users(MN_zj.user 的 JSON 数组),
6 * 不改核心主机表结构、不修改任何核心文件。
7 */
8
9if (!defined('IN_CRONLITE')) {
10 exit;
11}
12
13define('RP_TABLE', 'MN_plugin_respool');
14
15/* ============================================================
16 * 建表保障(幂等)
17 * ============================================================
18 * install.sql 已建表;此处兜底处理"手动放目录未走安装"或
19 * "旧版本升级"的情况。只在后台页面/AJAX 内调用,避免拖慢用户端。
20 */
21
22function rp_ensure_schema($force = false)
23{
24 global $DB;
25 static $done = false;
26 if (!isset($DB) || !is_object($DB)) {
27 return;
28 }
29 if ($done && !$force) {
30 return;
31 }
32 $done = true;
33
34 @$DB->query("CREATE TABLE IF NOT EXISTS `" . RP_TABLE . "` (
35 `id` int(11) NOT NULL AUTO_INCREMENT,
36 `name` varchar(120) NOT NULL DEFAULT '',
37 `username` varchar(120) NOT NULL DEFAULT '',
38 `password` varchar(255) NOT NULL DEFAULT '',
39 `nodes` text,
40 `host_users` text,
41 `web_space` int(11) NOT NULL DEFAULT '0',
42 `sql_space` int(11) NOT NULL DEFAULT '0',
43 `flow` int(11) NOT NULL DEFAULT '0',
44 `expire_date` varchar(50) NOT NULL DEFAULT '',
45 `status` varchar(20) NOT NULL DEFAULT 'enabled',
46 `remark` varchar(500) NOT NULL DEFAULT '',
47 `created_at` varchar(50) NOT NULL DEFAULT '',
48 `updated_at` varchar(50) NOT NULL DEFAULT '',
49 PRIMARY KEY (`id`),
50 UNIQUE KEY `uk_respool_username` (`username`)
51 ) ENGINE=MyISAM DEFAULT CHARSET=utf8");
52
53 // host_users:老版本(1.0.0)建的表没有这一列,补上
54 if (@$DB->get_row_prepare("SELECT `host_users` FROM " . RP_TABLE . " WHERE 1 LIMIT 1") === false) {
55 @$DB->query("ALTER TABLE `" . RP_TABLE . "` ADD COLUMN `host_users` text");
56 }
57
58 rp_migrate_from_pool_id();
59}
60
61/**
62 * 从 1.0.0 版的 MN_zj.pool_id 迁移到 host_users。
63 *
64 * 1.0.0 曾给 MN_zj 加过 pool_id 字段;现在归属关系改存在资源池表里。
65 * 若检测到该字段且仍有数据,则把归属搬到 host_users。
66 * 不删除 MN_zj.pool_id(不动核心表结构),迁移后该字段闲置不影响原功能。
67 */
68function rp_migrate_from_pool_id()
69{
70 global $DB;
71 // pool_id 不存在(正常的新装)→ 无需迁移
72 if (@$DB->get_row_prepare("SELECT `pool_id` FROM MN_zj WHERE 1 LIMIT 1") === false) {
73 return;
74 }
75 $rows = @$DB->get_all_prepare("SELECT user, pool_id FROM MN_zj WHERE pool_id IS NOT NULL AND pool_id>0");
76 if (!$rows) {
77 return;
78 }
79 $byPool = [];
80 foreach ($rows as $r) {
81 $pid = (int)$r['pool_id'];
82 $u = trim((string)$r['user']);
83 if ($pid > 0 && $u !== '') {
84 $byPool[$pid][] = $u;
85 }
86 }
87 foreach ($byPool as $pid => $users) {
88 $pool = @$DB->get_row_prepare("SELECT id, host_users FROM " . RP_TABLE . " WHERE id=? LIMIT 1", [$pid]);
89 if (!$pool) {
90 continue;
91 }
92 $merged = rp_decode_host_users($pool['host_users'] ?? '');
93 foreach ($users as $u) {
94 if (!in_array($u, $merged, true)) {
95 $merged[] = $u;
96 }
97 }
98 @$DB->query_prepare(
99 "UPDATE " . RP_TABLE . " SET host_users=? WHERE id=?",
100 [json_encode(array_values($merged), JSON_UNESCAPED_UNICODE), $pid]
101 );
102 }
103 // 迁移完成,清空 pool_id 避免下次重复迁移
104 @$DB->query("UPDATE MN_zj SET pool_id=NULL WHERE pool_id IS NOT NULL");
105}
106
107/* ============================================================
108 * 工具
109 * ============================================================ */
110
111/** 当前时间字符串 */
112function rp_now()
113{
114 global $date;
115 return $date ?: date('Y-m-d H:i:s');
116}
117
118/** 资源池状态可选值 */
119function rp_statuses()
120{
121 return [
122 'enabled' => '启用',
123 'disabled' => '禁用',
124 ];
125}
126
127/** nodes 字段解码为节点代号数组 */
128function rp_decode_nodes($raw)
129{
130 if (is_array($raw)) {
131 return array_values(array_filter(array_map('strval', $raw), 'strlen'));
132 }
133 $raw = trim((string)$raw);
134 if ($raw === '') {
135 return [];
136 }
137 $arr = json_decode($raw, true);
138 if (!is_array($arr)) {
139 return [];
140 }
141 $out = [];
142 foreach ($arr as $v) {
143 $v = trim((string)$v);
144 if ($v !== '' && !in_array($v, $out, true)) {
145 $out[] = $v;
146 }
147 }
148 return $out;
149}
150
151/** host_users 字段解码为主机账号数组 */
152function rp_decode_host_users($raw)
153{
154 if (is_array($raw)) {
155 $arr = $raw;
156 } else {
157 $raw = trim((string)$raw);
158 if ($raw === '') {
159 return [];
160 }
161 $arr = json_decode($raw, true);
162 if (!is_array($arr)) {
163 return [];
164 }
165 }
166 $out = [];
167 foreach ($arr as $v) {
168 $v = trim((string)$v);
169 if ($v !== '' && !in_array($v, $out, true)) {
170 $out[] = $v;
171 }
172 }
173 return $out;
174}
175
176/** 全部宝塔节点(用于可用节点勾选) */
177function rp_all_nodes()
178{
179 global $DB;
180 return $DB->get_all_prepare("SELECT id, btdh, btip, btos FROM MN_bt WHERE 1 ORDER BY id ASC") ?: [];
181}
182
183/** 日期格式校验:空串或 yyyy-mm-dd */
184function rp_valid_date($d)
185{
186 $d = trim((string)$d);
187 if ($d === '') {
188 return true;
189 }
190 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
191 return false;
192 }
193 $parts = explode('-', $d);
194 return checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
195}
196
197/** 资源池是否已过期 */
198function rp_is_expired($pool)
199{
200 $d = trim((string)($pool['expire_date'] ?? ''));
201 if ($d === '' || $d === '0000-00-00') {
202 return false;
203 }
204 return strtotime($d . ' 23:59:59') < time();
205}
206
207/** 资源池当前是否可用于开通(启用且未过期) */
208function rp_is_usable($pool)
209{
210 return is_array($pool)
211 && ($pool['status'] ?? '') === 'enabled'
212 && !rp_is_expired($pool);
213}
214
215/** 配额显示:0 表示不限 */
216function rp_quota_text($value, $unit)
217{
218 $value = (int)$value;
219 return $value <= 0 ? '不限' : ($value . $unit);
220}
221
222/* ============================================================
223 * 资源池 CRUD
224 * ============================================================ */
225
226/** 按 ID 取资源池 */
227function rp_get($id)
228{
229 global $DB;
230 $id = (int)$id;
231 if ($id <= 0) {
232 return null;
233 }
234 rp_ensure_schema();
235 $row = $DB->get_row_prepare("SELECT * FROM " . RP_TABLE . " WHERE id=? LIMIT 1", [$id]);
236 return $row ?: null;
237}
238
239/** 按用户名取资源池 */
240function rp_get_by_username($username)
241{
242 global $DB;
243 $username = trim((string)$username);
244 if ($username === '') {
245 return null;
246 }
247 rp_ensure_schema();
248 $row = $DB->get_row_prepare("SELECT * FROM " . RP_TABLE . " WHERE username=? LIMIT 1", [$username]);
249 return $row ?: null;
250}
251
252/**
253 * 分页列出资源池。
254 *
255 * @param int $page 页码(从 1 开始)
256 * @param int $per_page 每页条数
257 * @param string $kw 关键词(匹配资源池名 / 用户名)
258 * @param string $status 状态筛选(''=全部)
259 * @return array ['list'=>array, 'total'=>int, 'per_page'=>int, 'page'=>int]
260 */
261function rp_list($page = 1, $per_page = 20, $kw = '', $status = '')
262{
263 global $DB;
264 rp_ensure_schema();
265
266 $page = max(1, (int)$page);
267 $per_page = max(1, min(200, (int)$per_page));
268 $kw = trim((string)$kw);
269 $status = trim((string)$status);
270
271 $where = 'WHERE 1';
272 $params = [];
273 if ($kw !== '') {
274 $where .= ' AND (name LIKE ? OR username LIKE ?)';
275 $params[] = '%' . $kw . '%';
276 $params[] = '%' . $kw . '%';
277 }
278 if ($status !== '' && isset(rp_statuses()[$status])) {
279 $where .= ' AND status=?';
280 $params[] = $status;
281 }
282
283 $total = (int)$DB->count_prepare("SELECT count(*) FROM " . RP_TABLE . " {$where}", $params);
284 $offset = ($page - 1) * $per_page;
285 $list = $DB->get_all_prepare(
286 "SELECT * FROM " . RP_TABLE . " {$where} ORDER BY id DESC LIMIT {$offset},{$per_page}",
287 $params
288 ) ?: [];
289
290 return ['list' => $list, 'total' => $total, 'per_page' => $per_page, 'page' => $page];
291}
292
293/**
294 * 校验资源池表单数据。
295 *
296 * @param array $in 表单输入
297 * @param int|null $id 编辑时的资源池 ID(用于用户名查重排除自身)
298 * @return array ['ok'=>bool, 'msg'=>string, 'data'=>array]
299 */
300function rp_validate($in, $id = null)
301{
302 $name = trim((string)($in['name'] ?? ''));
303 $username = trim((string)($in['username'] ?? ''));
304 $password = (string)($in['password'] ?? '');
305 $nodes = rp_decode_nodes($in['nodes'] ?? []);
306 $web = (int)($in['web_space'] ?? 0);
307 $sql = (int)($in['sql_space'] ?? 0);
308 $flow = (int)($in['flow'] ?? 0);
309 $expire = trim((string)($in['expire_date'] ?? ''));
310 $status = trim((string)($in['status'] ?? 'enabled'));
311 $remark = trim((string)($in['remark'] ?? ''));
312
313 if ($name === '' || mb_strlen($name) > 120) {
314 return ['ok' => false, 'msg' => '资源池名不能为空且不超过 120 字'];
315 }
316 if (!preg_match('/^[a-zA-Z0-9_-]{4,120}$/', $username)) {
317 return ['ok' => false, 'msg' => '用户名只能用字母/数字/下划线/横线,长度 4-120 位'];
318 }
319 // 新增必填密码;编辑时留空表示不修改
320 if ($id === null) {
321 if (mb_strlen($password) < 6) {
322 return ['ok' => false, 'msg' => '密码不能少于 6 位'];
323 }
324 } elseif ($password !== '' && mb_strlen($password) < 6) {
325 return ['ok' => false, 'msg' => '密码不能少于 6 位(留空表示不修改)'];
326 }
327 if (mb_strlen($password) > 255) {
328 return ['ok' => false, 'msg' => '密码过长'];
329 }
330 if ($web < 0 || $sql < 0 || $flow < 0) {
331 return ['ok' => false, 'msg' => '配额不能为负数'];
332 }
333 if (!rp_valid_date($expire)) {
334 return ['ok' => false, 'msg' => '到期日期格式错误,应为 yyyy-mm-dd 或留空'];
335 }
336 if (!isset(rp_statuses()[$status])) {
337 return ['ok' => false, 'msg' => '资源池状态非法'];
338 }
339 if (mb_strlen($remark) > 500) {
340 return ['ok' => false, 'msg' => '备注不超过 500 字'];
341 }
342 // 可用节点必须存在于 MN_bt
343 if ($nodes) {
344 $valid = [];
345 foreach (rp_all_nodes() as $n) {
346 $valid[] = (string)$n['btdh'];
347 }
348 foreach ($nodes as $n) {
349 if (!in_array($n, $valid, true)) {
350 return ['ok' => false, 'msg' => '可用节点不存在:' . $n];
351 }
352 }
353 }
354 // 用户名唯一
355 $exist = rp_get_by_username($username);
356 if ($exist && (int)$exist['id'] !== (int)$id) {
357 return ['ok' => false, 'msg' => '该用户名已被其他资源池占用'];
358 }
359
360 return ['ok' => true, 'msg' => '', 'data' => [
361 'name' => $name,
362 'username' => $username,
363 'password' => $password,
364 'nodes' => json_encode(array_values($nodes), JSON_UNESCAPED_UNICODE),
365 'web_space' => $web,
366 'sql_space' => $sql,
367 'flow' => $flow,
368 'expire_date' => $expire,
369 'status' => $status,
370 'remark' => $remark,
371 ]];
372}
373
374/**
375 * 新增资源池。
376 * @return array ['ok'=>bool, 'msg'=>string, 'id'=>int]
377 */
378function rp_create($in)
379{
380 global $DB;
381 rp_ensure_schema();
382 $v = rp_validate($in, null);
383 if (!$v['ok']) {
384 return ['ok' => false, 'msg' => $v['msg']];
385 }
386 $d = $v['data'];
387 $now = rp_now();
388 $ok = $DB->query_prepare(
389 "INSERT INTO " . RP_TABLE . " (name, username, password, nodes, web_space, sql_space, flow, expire_date, status, remark, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
390 [$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]
391 );
392 if (!$ok) {
393 return ['ok' => false, 'msg' => '写入数据库失败:' . $DB->error()];
394 }
395 $row = rp_get_by_username($d['username']);
396 return ['ok' => true, 'msg' => '添加成功', 'id' => $row ? (int)$row['id'] : 0];
397}
398
399/**
400 * 更新资源池。密码留空表示不修改。
401 * @return array ['ok'=>bool, 'msg'=>string]
402 */
403function rp_update($id, $in)
404{
405 global $DB;
406 rp_ensure_schema();
407 $id = (int)$id;
408 $pool = rp_get($id);
409 if (!$pool) {
410 return ['ok' => false, 'msg' => '资源池不存在'];
411 }
412 $v = rp_validate($in, $id);
413 if (!$v['ok']) {
414 return ['ok' => false, 'msg' => $v['msg']];
415 }
416 $d = $v['data'];
417
418 if ($d['password'] === '') {
419 $ok = $DB->query_prepare(
420 "UPDATE " . RP_TABLE . " SET name=?, username=?, nodes=?, web_space=?, sql_space=?, flow=?, expire_date=?, status=?, remark=?, updated_at=? WHERE id=?",
421 [$d['name'], $d['username'], $d['nodes'], $d['web_space'], $d['sql_space'], $d['flow'], $d['expire_date'], $d['status'], $d['remark'], rp_now(), $id]
422 );
423 } else {
424 $ok = $DB->query_prepare(
425 "UPDATE " . RP_TABLE . " SET name=?, username=?, password=?, nodes=?, web_space=?, sql_space=?, flow=?, expire_date=?, status=?, remark=?, updated_at=? WHERE id=?",
426 [$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]
427 );
428 }
429 if (!$ok) {
430 return ['ok' => false, 'msg' => '更新失败:' . $DB->error()];
431 }
432 return ['ok' => true, 'msg' => '保存成功'];
433}
434
435/**
436 * 删除资源池。仅删除资源池记录本身,不删除已开通的主机;
437 * 归属关系随资源池记录一并消失(host_users 存在该行内)。
438 * @return array ['ok'=>bool, 'msg'=>string]
439 */
440function rp_delete($id)
441{
442 global $DB;
443 rp_ensure_schema();
444 $id = (int)$id;
445 $pool = rp_get($id);
446 if (!$pool) {
447 return ['ok' => false, 'msg' => '资源池不存在'];
448 }
449 if (!$DB->query_prepare("DELETE FROM " . RP_TABLE . " WHERE id=? LIMIT 1", [$id])) {
450 return ['ok' => false, 'msg' => '删除失败:' . $DB->error()];
451 }
452 return ['ok' => true, 'msg' => '删除成功'];
453}
454
455/** 切换资源池状态 */
456function rp_set_status($id, $status)
457{
458 global $DB;
459 rp_ensure_schema();
460 $id = (int)$id;
461 if (!isset(rp_statuses()[$status])) {
462 return ['ok' => false, 'msg' => '状态非法'];
463 }
464 if (!rp_get($id)) {
465 return ['ok' => false, 'msg' => '资源池不存在'];
466 }
467 if (!$DB->query_prepare("UPDATE " . RP_TABLE . " SET status=?, updated_at=? WHERE id=?", [$status, rp_now(), $id])) {
468 return ['ok' => false, 'msg' => '操作失败:' . $DB->error()];
469 }
470 return ['ok' => true, 'msg' => '操作成功'];
471}
472
473/* ============================================================
474 * 配额统计(按已开通主机汇总)
475 * ============================================================ */
476
477/** 从 MN_zj 的 JSON 配额字段中取 max 值 */
478function rp_json_max($raw)
479{
480 $j = json_decode((string)$raw, true);
481 return is_array($j) ? (float)($j['max'] ?? 0) : 0.0;
482}
483
484/**
485 * 资源池已开通的主机列表。
486 *
487 * 归属关系存在资源池表的 host_users(主机账号 JSON 数组),
488 * 用 MN_zj.user 反查主机行;账号在 MN_zj 已不存在的(主机被删)自动忽略。
489 */
490function rp_pool_hosts($pool_id)
491{
492 global $DB;
493 rp_ensure_schema();
494 $pool = rp_get($pool_id);
495 if (!$pool) {
496 return [];
497 }
498 $users = rp_decode_host_users($pool['host_users'] ?? '');
499 if (!$users) {
500 return [];
501 }
502 $place = implode(',', array_fill(0, count($users), '?'));
503 return $DB->get_all_prepare(
504 "SELECT * FROM MN_zj WHERE user IN ({$place}) ORDER BY id DESC",
505 $users
506 ) ?: [];
507}
508
509/**
510 * 主机账号 => 资源池 的反查表。
511 *
512 * @return array user => ['pool_id'=>int, 'pool_name'=>string]
513 */
514function rp_host_user_map()
515{
516 global $DB;
517 rp_ensure_schema();
518 $rows = $DB->get_all_prepare("SELECT id, name, host_users FROM " . RP_TABLE . " WHERE 1") ?: [];
519 $map = [];
520 foreach ($rows as $r) {
521 foreach (rp_decode_host_users($r['host_users'] ?? '') as $u) {
522 // 一个主机账号只归属一个池;先出现的优先(写入时已做互斥校验)
523 if (!isset($map[$u])) {
524 $map[$u] = ['pool_id' => (int)$r['id'], 'pool_name' => (string)$r['name']];
525 }
526 }
527 }
528 return $map;
529}
530
531/**
532 * 查某主机账号归属的资源池。
533 * @return array|null ['pool_id'=>int,'pool_name'=>string]
534 */
535function rp_find_pool_by_host_user($host_user)
536{
537 $host_user = trim((string)$host_user);
538 if ($host_user === '') {
539 return null;
540 }
541 $map = rp_host_user_map();
542 return $map[$host_user] ?? null;
543}
544
545/**
546 * 把主机账号加入资源池的 host_users。
547 * @return array ['ok'=>bool,'msg'=>string]
548 */
549function rp_bind_host_user($pool_id, $host_user)
550{
551 global $DB;
552 rp_ensure_schema();
553 $host_user = trim((string)$host_user);
554 $pool = rp_get($pool_id);
555 if (!$pool) {
556 return ['ok' => false, 'msg' => '资源池不存在'];
557 }
558 if ($host_user === '') {
559 return ['ok' => false, 'msg' => '主机账号不能为空'];
560 }
561 if (!$DB->get_row_prepare("SELECT id FROM MN_zj WHERE user=? LIMIT 1", [$host_user])) {
562 return ['ok' => false, 'msg' => '主机账号不存在:' . $host_user];
563 }
564 // 互斥:已归属其他池则拒绝
565 $exist = rp_find_pool_by_host_user($host_user);
566 if ($exist && $exist['pool_id'] !== (int)$pool['id']) {
567 return ['ok' => false, 'msg' => '该主机已归属资源池「' . $exist['pool_name'] . '」,请先解除归属'];
568 }
569 $users = rp_decode_host_users($pool['host_users'] ?? '');
570 if (in_array($host_user, $users, true)) {
571 return ['ok' => true, 'msg' => '该主机已在本资源池内'];
572 }
573 $users[] = $host_user;
574 $ok = $DB->query_prepare(
575 "UPDATE " . RP_TABLE . " SET host_users=?, updated_at=? WHERE id=?",
576 [json_encode(array_values($users), JSON_UNESCAPED_UNICODE), rp_now(), (int)$pool['id']]
577 );
578 if (!$ok) {
579 return ['ok' => false, 'msg' => '写入失败:' . $DB->error()];
580 }
581 return ['ok' => true, 'msg' => '绑定成功'];
582}
583
584/**
585 * 把主机账号从资源池的 host_users 移除。
586 * @param int|null $pool_id 为 null 时自动查归属池
587 * @return array ['ok'=>bool,'msg'=>string]
588 */
589function rp_unbind_host_user($host_user, $pool_id = null)
590{
591 global $DB;
592 rp_ensure_schema();
593 $host_user = trim((string)$host_user);
594 if ($host_user === '') {
595 return ['ok' => false, 'msg' => '主机账号不能为空'];
596 }
597 if ($pool_id === null) {
598 $found = rp_find_pool_by_host_user($host_user);
599 if (!$found) {
600 return ['ok' => false, 'msg' => '该主机没有资源池归属'];
601 }
602 $pool_id = $found['pool_id'];
603 }
604 $pool = rp_get($pool_id);
605 if (!$pool) {
606 return ['ok' => false, 'msg' => '资源池不存在'];
607 }
608 $users = rp_decode_host_users($pool['host_users'] ?? '');
609 $left = array_values(array_filter($users, function ($u) use ($host_user) {
610 return $u !== $host_user;
611 }));
612 if (count($left) === count($users)) {
613 return ['ok' => false, 'msg' => '该主机不在此资源池内'];
614 }
615 $ok = $DB->query_prepare(
616 "UPDATE " . RP_TABLE . " SET host_users=?, updated_at=? WHERE id=?",
617 [json_encode($left, JSON_UNESCAPED_UNICODE), rp_now(), (int)$pool['id']]
618 );
619 if (!$ok) {
620 return ['ok' => false, 'msg' => '写入失败:' . $DB->error()];
621 }
622 return ['ok' => true, 'msg' => '已解除归属'];
623}
624
625/**
626 * 清理失效归属:host_users 里在 MN_zj 已不存在的主机账号。
627 * @return array ['ok'=>bool,'msg'=>string,'removed'=>int]
628 */
629function rp_prune_host_users($pool_id = null)
630{
631 global $DB;
632 rp_ensure_schema();
633
634 if ($pool_id === null) {
635 $pools = $DB->get_all_prepare("SELECT id, host_users FROM " . RP_TABLE . " WHERE 1") ?: [];
636 } else {
637 $one = rp_get($pool_id);
638 $pools = $one ? [$one] : [];
639 }
640
641 $removed = 0;
642 foreach ($pools as $p) {
643 $users = rp_decode_host_users($p['host_users'] ?? '');
644 if (!$users) {
645 continue;
646 }
647 $place = implode(',', array_fill(0, count($users), '?'));
648 $rows = $DB->get_all_prepare("SELECT user FROM MN_zj WHERE user IN ({$place})", $users) ?: [];
649 $alive = [];
650 foreach ($rows as $r) {
651 $alive[] = (string)$r['user'];
652 }
653 $keep = array_values(array_filter($users, function ($u) use ($alive) {
654 return in_array($u, $alive, true);
655 }));
656 if (count($keep) !== count($users)) {
657 $removed += count($users) - count($keep);
658 $DB->query_prepare(
659 "UPDATE " . RP_TABLE . " SET host_users=?, updated_at=? WHERE id=?",
660 [json_encode($keep, JSON_UNESCAPED_UNICODE), rp_now(), (int)$p['id']]
661 );
662 }
663 }
664 return ['ok' => true, 'msg' => $removed > 0 ? ('已清理 ' . $removed . ' 条失效归属') : '没有失效归属', 'removed' => $removed];
665}
666
667/**
668 * 统计资源池已分配用量。
669 *
670 * @return array ['hosts'=>int,'web'=>int,'sql'=>int,'flow'=>int](单位 MB/MB/GB)
671 */
672function rp_usage($pool_id)
673{
674 $out = ['hosts' => 0, 'web' => 0, 'sql' => 0, 'flow' => 0];
675 foreach (rp_pool_hosts($pool_id) as $h) {
676 $out['hosts']++;
677 $out['web'] += (int)rp_json_max($h['hxa'] ?? '');
678 $out['sql'] += (int)rp_json_max($h['hxb'] ?? '');
679 $out['flow'] += (int)rp_json_max($h['llmax'] ?? '');
680 }
681 return $out;
682}
683
684/**
685 * 批量统计多个资源池的用量(避免列表页 N+1 查询)。
686 *
687 * @param array $pool_ids
688 * @return array pool_id => ['hosts'=>int,'web'=>int,'sql'=>int,'flow'=>int]
689 */
690function rp_usage_batch($pool_ids)
691{
692 global $DB;
693 rp_ensure_schema();
694
695 $ids = [];
696 foreach ((array)$pool_ids as $v) {
697 $v = (int)$v;
698 if ($v > 0 && !in_array($v, $ids, true)) {
699 $ids[] = $v;
700 }
701 }
702 $out = [];
703 foreach ($ids as $v) {
704 $out[$v] = ['hosts' => 0, 'web' => 0, 'sql' => 0, 'flow' => 0];
705 }
706 if (!$ids) {
707 return $out;
708 }
709
710 // 取这些池的 host_users,建立 主机账号 => pool_id 映射
711 $place = implode(',', array_fill(0, count($ids), '?'));
712 $pools = $DB->get_all_prepare("SELECT id, host_users FROM " . RP_TABLE . " WHERE id IN ({$place})", $ids) ?: [];
713 $userToPool = [];
714 foreach ($pools as $p) {
715 foreach (rp_decode_host_users($p['host_users'] ?? '') as $u) {
716 if (!isset($userToPool[$u])) {
717 $userToPool[$u] = (int)$p['id'];
718 }
719 }
720 }
721 if (!$userToPool) {
722 return $out;
723 }
724
725 // 一次查回所有相关主机
726 // 注意:PHP 会把纯数字字符串键转成 int,这里统一转回字符串再绑定
727 $users = array_map('strval', array_keys($userToPool));
728 $place2 = implode(',', array_fill(0, count($users), '?'));
729 $rows = $DB->get_all_prepare(
730 "SELECT user, hxa, hxb, llmax FROM MN_zj WHERE user IN ({$place2})",
731 $users
732 ) ?: [];
733 foreach ($rows as $r) {
734 $pid = $userToPool[(string)$r['user']] ?? 0;
735 if ($pid <= 0 || !isset($out[$pid])) {
736 continue;
737 }
738 $out[$pid]['hosts']++;
739 $out[$pid]['web'] += (int)rp_json_max($r['hxa'] ?? '');
740 $out[$pid]['sql'] += (int)rp_json_max($r['hxb'] ?? '');
741 $out[$pid]['flow'] += (int)rp_json_max($r['llmax'] ?? '');
742 }
743 return $out;
744}
745
746/** 资源池剩余配额;总额为 0(不限)时返回 null 表示不限 */
747function rp_remaining($pool, $usage = null)
748{
749 if ($usage === null) {
750 $usage = rp_usage($pool['id'] ?? 0);
751 }
752 $calc = function ($total, $used) {
753 $total = (int)$total;
754 return $total <= 0 ? null : ($total - (int)$used);
755 };
756 return [
757 'web' => $calc($pool['web_space'] ?? 0, $usage['web']),
758 'sql' => $calc($pool['sql_space'] ?? 0, $usage['sql']),
759 'flow' => $calc($pool['flow'] ?? 0, $usage['flow']),
760 ];
761}
762
763/* ============================================================
764 * 从资源池开通主机
765 * ============================================================
766 * 复用核心 addzj 的宝塔调用流程(MPHX/bt_api.php),
767 * 额外做:可用节点白名单校验 + 配额校验,并把主机账号写入本池 host_users。
768 */
769
770/**
771 * @param int $pool_id
772 * @param array $args node/user/pass/web_space/sql_space/flow/domain_count/expire_date/status
773 * @return array ['ok'=>bool,'msg'=>string,'host_id'=>int]
774 */
775function rp_open_host($pool_id, $args)
776{
777 global $DB, $conf, $date;
778
779 rp_ensure_schema();
780
781 $pool = rp_get($pool_id);
782 if (!$pool) {
783 return ['ok' => false, 'msg' => '资源池不存在'];
784 }
785 if (($pool['status'] ?? '') !== 'enabled') {
786 return ['ok' => false, 'msg' => '资源池已禁用,无法开通'];
787 }
788 if (rp_is_expired($pool)) {
789 return ['ok' => false, 'msg' => '资源池已于 ' . $pool['expire_date'] . ' 到期,无法开通'];
790 }
791
792 $node_dh = trim((string)($args['node'] ?? ''));
793 $hostuser = trim((string)($args['user'] ?? ''));
794 $hostpass = (string)($args['pass'] ?? '');
795 $web = (int)($args['web_space'] ?? 0);
796 $sqlspace = (int)($args['sql_space'] ?? 0);
797 $flow = (int)($args['flow'] ?? 0);
798 $ymbds = (int)($args['domain_count'] ?? 0);
799 $expire = trim((string)($args['expire_date'] ?? ''));
800 $switch = ($args['status'] ?? 'true') === 'true' ? 'true' : 'false';
801
802 if (mb_strlen($hostuser) < 6 || mb_strlen($hostpass) < 6) {
803 return ['ok' => false, 'msg' => '主机账号和密码均不能少于 6 位'];
804 }
805 if (!preg_match('/^[a-zA-Z0-9_]{6,60}$/', $hostuser)) {
806 return ['ok' => false, 'msg' => '主机账号只能用字母/数字/下划线,长度 6-60 位'];
807 }
808 if ($web < 0 || $sqlspace < 0 || $flow < 0 || $ymbds < 0) {
809 return ['ok' => false, 'msg' => '配额不能为负数'];
810 }
811 if (!rp_valid_date($expire)) {
812 return ['ok' => false, 'msg' => '到期日期格式错误,应为 yyyy-mm-dd 或留空'];
813 }
814
815 // 可用节点白名单
816 $allow = rp_decode_nodes($pool['nodes'] ?? '');
817 if ($allow && !in_array($node_dh, $allow, true)) {
818 return ['ok' => false, 'msg' => '该节点不在本资源池的可用节点内'];
819 }
820 $node = $DB->get_row_prepare("SELECT * FROM MN_bt WHERE btdh=? LIMIT 1", [$node_dh]);
821 if (!$node) {
822 return ['ok' => false, 'msg' => '节点不存在:' . $node_dh];
823 }
824
825 // 配额校验
826 $usage = rp_usage($pool_id);
827 $checks = [
828 ['total' => (int)$pool['web_space'], 'used' => $usage['web'], 'need' => $web, 'label' => '网页空间', 'unit' => 'MB'],
829 ['total' => (int)$pool['sql_space'], 'used' => $usage['sql'], 'need' => $sqlspace, 'label' => '数据库空间', 'unit' => 'MB'],
830 ['total' => (int)$pool['flow'], 'used' => $usage['flow'], 'need' => $flow, 'label' => '流量', 'unit' => 'GB'],
831 ];
832 foreach ($checks as $c) {
833 if ($c['total'] > 0 && ($c['used'] + $c['need']) > $c['total']) {
834 return ['ok' => false, 'msg' => sprintf(
835 '%s配额不足:总额 %d%s,已分配 %d%s,本次需 %d%s',
836 $c['label'], $c['total'], $c['unit'], $c['used'], $c['unit'], $c['need'], $c['unit']
837 )];
838 }
839 }
840
841 // 账号查重(本地)
842 if ($DB->get_row_prepare("SELECT id FROM MN_zj WHERE user=? LIMIT 1", [$hostuser])) {
843 return ['ok' => false, 'msg' => '该主机账号已存在,请更换'];
844 }
845
846 // 宝塔 API
847 $bt_api_file = ROOT . 'MPHX/bt_api.php';
848 if (!is_file($bt_api_file)) {
849 return ['ok' => false, 'msg' => 'bt_api 类文件缺失'];
850 }
851 require_once $bt_api_file;
852
853 $btipe = ($node['ptl'] == 'true' ? 'https' : 'http') . '://' . $node['btip'] . ':' . $node['btdk'];
854 $api = new bt_api($btipe, $node['btmy']);
855
856 // PHP 版本:优先节点已保存的,否则自动探测最新
857 $phpVersion = $node['mrbts_php'] ?? '';
858 if ($phpVersion === '' || $phpVersion === '00') {
859 $phpList = $api->btapi_listphp();
860 if (is_array($phpList)) {
861 $versions = [];
862 foreach ($phpList as $v) {
863 if (($v['status'] ?? false) && ($v['version'] ?? '') !== '00') {
864 $versions[] = $v['version'];
865 }
866 }
867 if ($versions) {
868 usort($versions, function ($a, $b) {
869 return strcmp($b, $a);
870 });
871 $phpVersion = $versions[0];
872 $DB->query_prepare("UPDATE MN_bt SET mrbts_php=? WHERE btdh=? LIMIT 1", [$phpVersion, $node_dh]);
873 }
874 }
875 }
876 if ($phpVersion === '' || $phpVersion === '00') {
877 return ['ok' => false, 'msg' => '无法获取该节点的 PHP 版本,请先在宝塔面板安装 PHP 或在节点管理中设置默认 PHP 版本'];
878 }
879
880 // 站点目录名(防重名,与核心 addzj 同风格)
881 $now = $date ?: date('Y-m-d H:i:s');
882 $wjler = substr(md5($now . $hostuser . mt_rand(100, 999)), mt_rand(4, 10), 6);
883 $btserw = 'mnbt.' . mt_rand(1, 999) . $wjler;
884 $mrml = ($node['btos'] == '1' ? $conf['hxi'] : $conf['hxo']) . '/' . $btserw;
885 $datae = $expire === '' ? '0000-00-00' : $expire;
886
887 $r_data = $api->webkt($hostuser, $hostpass, $btserw, '主机', 'true', 'true', $phpVersion, $mrml);
888 if (empty($r_data['siteStatus'])) {
889 return ['ok' => false, 'msg' => '宝塔创建站点失败:' . ($r_data['msg'] ?? '未知错误')];
890 }
891 $siteId = $r_data['siteId'] ?? 0;
892
893 $r_datan = $api->setdqsj($siteId, $datae);
894 if (!(($r_datan['status'] ?? '') == '1' || ($r_datan['status'] ?? '') == 'true')) {
895 @error_log('[resource_pool] setdqsj failed for host ' . $hostuser);
896 }
897
898 // 按账号精确获取 FTP/数据库 ID(原为全表拉取,主机数多时会显著变慢)
899 $ftpid = $api->sjid('ftps', $hostuser);
900 $sqlid = $api->sjid('databases', $hostuser);
901
902 $webdx = json_encode(['max' => $web, 'dq' => 0]);
903 $sqldx = json_encode(['max' => $sqlspace, 'dq' => 0]);
904 $lldx = json_encode(['max' => $flow, 'dq' => 0, 'statistics' => false]);
905
906 // 不显式指定 id,交给 AUTO_INCREMENT,避免并发开通时 id 撞车;
907 // 随后用唯一的 user 字段回查真实 id。写入的是核心原有字段,未改主机表结构。
908 $ok = $DB->query_prepare(
909 "INSERT INTO `MN_zj` (`ssbt`, `user`, `pass`, `sqluser`, `sqlpass`, `data`, `datae`, `qk`, `btid`, `sqldz`, `ftpid`, `ymbds`, `hxa`, `hxb`, `hxc`, `hxd`, `llmax`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
910 [$node_dh, $hostuser, $hostpass, $hostuser, $hostpass, $now, $datae, $switch, $siteId, $btserw, $ftpid, (string)$ymbds, $webdx, $sqldx, '2', $sqlid, $lldx]
911 );
912 if (!$ok) {
913 return ['ok' => false, 'msg' => '宝塔已开通但本地写库失败,请联系管理员(siteId=' . $siteId . '):' . $DB->error()];
914 }
915
916 $newRow = $DB->get_row_prepare("SELECT id FROM MN_zj WHERE user=? ORDER BY id DESC LIMIT 1", [$hostuser]);
917 $newId = $newRow ? (int)$newRow['id'] : 0;
918
919 // 记录归属:写进资源池表的 host_users
920 $bind = rp_bind_host_user($pool_id, $hostuser);
921 if (!$bind['ok']) {
922 @error_log('[resource_pool] bind host_user failed: pool=' . $pool_id . ' user=' . $hostuser . ' msg=' . $bind['msg']);
923 }
924
925 // 开关为关:宝塔侧同步停站 + 停 FTP(webkt 创建出来默认是运行状态)
926 if ($switch === 'false') {
927 @$api->siteqt($siteId, $btserw, false);
928 @$api->setftpzt($ftpid, $hostuser, '0');
929 }
930
931 if (function_exists('mnbt_do_action')) {
932 $host_row = $DB->get_row_prepare("SELECT * FROM MN_zj WHERE id=? LIMIT 1", [$newId]);
933 mnbt_do_action(
934 'host.created',
935 $host_row ?: ['id' => $newId, 'user' => $hostuser, 'ssbt' => $node_dh],
936 ['source' => 'resource_pool', 'pool_id' => (int)$pool_id]
937 );
938 }
939
940 return ['ok' => true, 'msg' => '开通成功', 'host_id' => $newId, 'site' => $btserw];
941}
942
943/**
944 * 资源池名称映射:pool_id => name
945 */
946function rp_name_map()
947{
948 global $DB;
949 rp_ensure_schema();
950 $rows = $DB->get_all_prepare("SELECT id, name FROM " . RP_TABLE . " WHERE 1") ?: [];
951 $map = [];
952 foreach ($rows as $r) {
953 $map[(int)$r['id']] = (string)$r['name'];
954 }
955 return $map;
956}
957
958/**
959 * 主机账号 => 资源池名(供主机列表注入列展示)
960 *
961 * 键统一转成字符串:纯数字的主机账号(如 123456)会被 PHP 转成 int 键,
962 * 若恰好构成连续下标,json_encode 会输出 JSON 数组而不是对象,导致前端查不到。
963 * 调用方 json_encode 时请带 JSON_FORCE_OBJECT。
964 */
965function rp_host_user_name_map()
966{
967 $out = [];
968 foreach (rp_host_user_map() as $u => $info) {
969 $out[(string)$u] = $info['pool_name'];
970 }
971 return $out;
972}
973
974/**
975 * 所有资源池开通的主机(跨池),可按资源池筛选。
976 *
977 * @param int $pool_id 0 = 全部资源池
978 * @param string $kw 关键词(主机账号 / 网站名 / 所属宝塔)
979 * @return array 每行为 MN_zj 行 + pool_id / pool_name
980 */
981function rp_all_pool_hosts($pool_id = 0, $kw = '')
982{
983 global $DB;
984 rp_ensure_schema();
985
986 $pool_id = (int)$pool_id;
987 $kw = trim((string)$kw);
988 $map = rp_host_user_map();
989
990 // 按资源池过滤出目标主机账号(键可能被 PHP 转成 int,统一转回字符串)
991 $users = [];
992 foreach ($map as $u => $info) {
993 if ($pool_id > 0 && $info['pool_id'] !== $pool_id) {
994 continue;
995 }
996 $users[] = (string)$u;
997 }
998 if (!$users) {
999 return [];
1000 }
1001
1002 $place = implode(',', array_fill(0, count($users), '?'));
1003 $params = $users;
1004 $sql = "SELECT * FROM MN_zj WHERE user IN ({$place})";
1005 if ($kw !== '') {
1006 $sql .= " AND (user LIKE ? OR sqldz LIKE ? OR ssbt LIKE ?)";
1007 $like = '%' . $kw . '%';
1008 $params[] = $like;
1009 $params[] = $like;
1010 $params[] = $like;
1011 }
1012 $sql .= " ORDER BY id DESC";
1013
1014 $rows = $DB->get_all_prepare($sql, $params) ?: [];
1015 foreach ($rows as $i => $r) {
1016 $u = (string)$r['user'];
1017 $rows[$i]['pool_id'] = $map[$u]['pool_id'] ?? 0;
1018 $rows[$i]['pool_name'] = $map[$u]['pool_name'] ?? '';
1019 }
1020 return $rows;
1021}
1022
1023/**
1024 * 未归属任何资源池的主机(供「绑定到资源池」选择)。
1025 */
1026function rp_unbound_hosts($limit = 500)
1027{
1028 global $DB;
1029 rp_ensure_schema();
1030 $limit = max(1, min(2000, (int)$limit));
1031 $rows = $DB->get_all_prepare("SELECT id, user, sqldz, ssbt FROM MN_zj WHERE 1 ORDER BY id DESC LIMIT {$limit}") ?: [];
1032 $map = rp_host_user_map();
1033 $out = [];
1034 foreach ($rows as $r) {
1035 if (!isset($map[(string)$r['user']])) {
1036 $out[] = $r;
1037 }
1038 }
1039 return $out;
1040}