better-staridc-MNBT
1<?php
2/**
3 * hosting_shop 插件 - 核心函数库
4 *
5 * 提供:套餐 CRUD、节点列表、订单管理、资产查询、宝塔主机开通。
6 * 依赖 user_info 插件(认证)、balance 插件(余额扣款)。
7 */
8
9if (!defined('IN_CRONLITE')) {
10 exit;
11}
12
13/* ============================================================
14 * 周期 / schema 配置
15 * ============================================================ */
16
17/** 可用购买周期定义(months 用于计算到期时间)。 */
18function hosting_periods()
19{
20 return [
21 'month' => ['label' => '月付', 'months' => 1],
22 'quarter' => ['label' => '季付', 'months' => 3],
23 'half_year' => ['label' => '半年付', 'months' => 6],
24 'year' => ['label' => '年付', 'months' => 12],
25 'two_year' => ['label' => '两年付', 'months' => 24],
26 'three_year' => ['label' => '三年付', 'months' => 36],
27 ];
28}
29
30/** 周期到数据库价格字段的映射。 */
31function hosting_period_price_field($period)
32{
33 $map = [
34 'month' => 'price_month_cents',
35 'quarter' => 'price_quarter_cents',
36 'half_year' => 'price_half_year_cents',
37 'year' => 'price_year_cents',
38 'two_year' => 'price_two_year_cents',
39 'three_year' => 'price_three_year_cents',
40 ];
41 return $map[$period] ?? '';
42}
43
44/** 获取套餐启用的购买周期列表。 */
45function hosting_plan_enabled_periods($plan)
46{
47 $periods = hosting_periods();
48 $enabled = [];
49 $raw = isset($plan['enabled_periods']) ? trim((string)$plan['enabled_periods']) : '';
50 if ($raw === '') {
51 // 兼容旧数据:只要对应价格 > 0 就认为启用
52 foreach (array_keys($periods) as $p) {
53 $field = hosting_period_price_field($p);
54 if ($field && (int)($plan[$field] ?? 0) > 0) {
55 $enabled[] = $p;
56 }
57 }
58 return $enabled;
59 }
60 foreach (explode(',', $raw) as $p) {
61 $p = trim($p);
62 if (isset($periods[$p])) {
63 $enabled[] = $p;
64 }
65 }
66 return $enabled;
67}
68
69/** 自动升级套餐表结构(对已经安装的插件追加新字段)。 */
70function hosting_upgrade_schema()
71{
72 global $DB;
73 if (!isset($DB)) {
74 return;
75 }
76 $cols = $DB->get_all_prepare("SHOW COLUMNS FROM MN_plugin_hosting_plan") ?: [];
77 $existing = [];
78 foreach ($cols as $c) {
79 $existing[] = $c['Field'];
80 }
81 $toAdd = [];
82 if (!in_array('price_quarter_cents', $existing, true)) {
83 $toAdd[] = "ADD COLUMN `price_quarter_cents` int(11) NOT NULL DEFAULT '0' COMMENT '季付价格(分)'";
84 }
85 if (!in_array('price_half_year_cents', $existing, true)) {
86 $toAdd[] = "ADD COLUMN `price_half_year_cents` int(11) NOT NULL DEFAULT '0' COMMENT '半年付价格(分)'";
87 }
88 if (!in_array('price_two_year_cents', $existing, true)) {
89 $toAdd[] = "ADD COLUMN `price_two_year_cents` int(11) NOT NULL DEFAULT '0' COMMENT '两年付价格(分)'";
90 }
91 if (!in_array('price_three_year_cents', $existing, true)) {
92 $toAdd[] = "ADD COLUMN `price_three_year_cents` int(11) NOT NULL DEFAULT '0' COMMENT '三年付价格(分)'";
93 }
94 if (!in_array('enabled_periods', $existing, true)) {
95 $toAdd[] = "ADD COLUMN `enabled_periods` varchar(255) NOT NULL DEFAULT '' COMMENT '允许的购买周期,逗号分隔'";
96 }
97 if (!in_array('category', $existing, true)) {
98 $toAdd[] = "ADD COLUMN `category` varchar(50) NOT NULL DEFAULT '' COMMENT '套餐分类'";
99 }
100 if (!in_array('node', $existing, true)) {
101 $toAdd[] = "ADD COLUMN `node` varchar(250) NOT NULL DEFAULT '' COMMENT '固定开通节点 btdh'";
102 }
103 if (!empty($toAdd)) {
104 $DB->query("ALTER TABLE MN_plugin_hosting_plan " . implode(', ', $toAdd));
105 }
106 // 删除已废弃的 spec_type 字段
107 if (in_array('spec_type', $existing, true)) {
108 $DB->query("ALTER TABLE MN_plugin_hosting_plan DROP COLUMN `spec_type`");
109 }
110}
111
112/* ============================================================
113 * URL / 渲染辅助
114 * ============================================================ */
115
116/** 生成带站点 base path 前缀的 URL。 */
117function hosting_url($path = '')
118{
119 $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
120 $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
121 if ($basePath === '.' || $basePath === '/') {
122 $basePath = '';
123 }
124 // 使用查询参数路由(index.php?_r=/path),避免依赖 Web 服务器 rewrite
125 $p = ltrim($path, '/');
126 $qpos = strpos($p, '?');
127 if ($qpos !== false) {
128 $route = substr($p, 0, $qpos);
129 $query = substr($p, $qpos + 1);
130 return $basePath . '/index.php?_r=/' . $route . '&' . $query;
131 }
132 return $basePath . '/index.php?_r=/' . $p;
133}
134
135/** 生成核心物理文件 URL(如 /user/idcdl.php),带站点 base path。 */
136function hosting_core_url($path = '')
137{
138 $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
139 $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
140 if ($basePath === '.' || $basePath === '/') {
141 $basePath = '';
142 }
143 return $basePath . '/' . ltrim($path, '/');
144}
145
146/** 插件静态资源 URL。 */
147function hosting_asset_url($path = '')
148{
149 return mnbt_plugin_url('hosting_shop', 'assets/' . ltrim($path, '/'));
150}
151
152/** 管理员端插件页面 URL(admin/plugin.php?p=hosting_shop&page=xxx)。 */
153function hosting_admin_url($page, $extra = '')
154{
155 $base = 'plugin.php?p=hosting_shop&page=' . rawurlencode($page);
156 if ($extra !== '') {
157 $base .= '&' . ltrim($extra, '&');
158 }
159 return $base;
160}
161
162/** 金额(分)→ 元(保留 2 位小数)。 */
163function hosting_format_cents($cents)
164{
165 return number_format((int)$cents / 100, 2, '.', '');
166}
167
168/* ============================================================
169 * 套餐分类管理(存储于 MN_plugin_option)
170 * ============================================================ */
171
172/** 获取分类列表(一维数组)。 */
173function hosting_category_list()
174{
175 $list = mnbt_plugin_option_get('hosting_shop', 'categories', []);
176 if (!is_array($list)) {
177 $list = [];
178 }
179 // 去重、去空、重新索引
180 $clean = [];
181 foreach ($list as $v) {
182 $v = trim((string)$v);
183 if ($v !== '' && !in_array($v, $clean, true)) {
184 $clean[] = $v;
185 }
186 }
187 return $clean;
188}
189
190/** 添加分类,返回 true 或错误消息。 */
191function hosting_category_add($name)
192{
193 $name = trim((string)$name);
194 if ($name === '') {
195 return '分类名称不能为空';
196 }
197 $list = hosting_category_list();
198 if (in_array($name, $list, true)) {
199 return '分类已存在';
200 }
201 $list[] = $name;
202 mnbt_plugin_option_set('hosting_shop', 'categories', $list);
203 return true;
204}
205
206/** 删除分类,返回 true 或错误消息。 */
207function hosting_category_delete($name)
208{
209 $name = trim((string)$name);
210 $list = hosting_category_list();
211 $key = array_search($name, $list, true);
212 if ($key === false) {
213 return '分类不存在';
214 }
215 unset($list[$key]);
216 $list = array_values($list);
217 mnbt_plugin_option_set('hosting_shop', 'categories', $list);
218 // 把使用该分类的套餐清空分类
219 global $DB;
220 $DB->query_prepare("UPDATE MN_plugin_hosting_plan SET category = '' WHERE category = ?", [$name]);
221 return true;
222}
223
224/** 重命名分类,返回 true 或错误消息。 */
225function hosting_category_rename($old, $new)
226{
227 $old = trim((string)$old);
228 $new = trim((string)$new);
229 if ($old === '' || $new === '') {
230 return '分类名称不能为空';
231 }
232 if ($old === $new) {
233 return true;
234 }
235 $list = hosting_category_list();
236 $key = array_search($old, $list, true);
237 if ($key === false) {
238 return '原分类不存在';
239 }
240 if (in_array($new, $list, true)) {
241 return '新分类名称已存在';
242 }
243 $list[$key] = $new;
244 mnbt_plugin_option_set('hosting_shop', 'categories', $list);
245 // 同步更新套餐表
246 global $DB;
247 $DB->query_prepare("UPDATE MN_plugin_hosting_plan SET category = ? WHERE category = ?", [$new, $old]);
248 return true;
249}
250
251/** 统计每个分类下的套餐数量。 */
252function hosting_category_counts()
253{
254 global $DB;
255 $rows = $DB->get_all_prepare("SELECT category, COUNT(*) AS cnt FROM MN_plugin_hosting_plan WHERE category != '' GROUP BY category");
256 $map = [];
257 if (is_array($rows)) {
258 foreach ($rows as $r) {
259 $map[$r['category']] = (int)$r['cnt'];
260 }
261 }
262 return $map;
263}
264
265/** 获取当前登录的 user_info 用户,未登录跳转登录页。 */
266function hosting_require_user()
267{
268 if (!function_exists('user_info_auth_current')) {
269 http_response_code(500);
270 echo '需要先启用 user_info 插件';
271 exit;
272 }
273 $user = user_info_auth_current();
274 if (!$user) {
275 header('Location: ' . hosting_url('account/login'));
276 exit;
277 }
278 return $user;
279}
280
281/** 渲染用户端视图。 */
282function hosting_render($view, $vars = [])
283{
284 $vars['current_user'] = $vars['current_user'] ?? (function_exists('user_info_auth_current') ? user_info_auth_current() : null);
285 extract($vars, EXTR_SKIP);
286 $viewFile = mnbt_plugin_path('hosting_shop') . 'views/' . $view . '.php';
287 if (!is_file($viewFile)) {
288 http_response_code(500);
289 echo 'View not found: ' . htmlspecialchars($view);
290 return;
291 }
292 include $viewFile;
293}
294
295/** 渲染管理员端视图。 */
296function hosting_render_admin($view, $vars = [])
297{
298 extract($vars, EXTR_SKIP);
299 $viewFile = mnbt_plugin_path('hosting_shop') . 'views/admin/' . $view . '.php';
300 if (!is_file($viewFile)) {
301 http_response_code(500);
302 echo 'Admin view not found: ' . htmlspecialchars($view);
303 return;
304 }
305 include $viewFile;
306}
307
308/** 输出 JSON 并退出。 */
309function hosting_json($code, $extra = [])
310{
311 @header('Content-Type: application/json; charset=UTF-8');
312 $payload = ['code' => $code];
313 if (is_array($extra)) {
314 $payload = array_merge($payload, $extra);
315 }
316 echo json_encode($payload, JSON_UNESCAPED_UNICODE);
317 exit;
318}
319
320/* ============================================================
321 * 套餐管理
322 * ============================================================ */
323
324/** 获取单个套餐。 */
325function hosting_plan_get($plan_id)
326{
327 global $DB;
328 return $DB->get_row_prepare("SELECT * FROM MN_plugin_hosting_plan WHERE id=? LIMIT 1", [(int)$plan_id]) ?: null;
329}
330
331/** 获取上架套餐列表(按 sort 升序)。 */
332function hosting_plan_list_active()
333{
334 global $DB;
335 return $DB->get_all_prepare("SELECT * FROM MN_plugin_hosting_plan WHERE status='active' ORDER BY sort ASC, id ASC") ?: [];
336}
337
338/** 获取全部套餐列表(管理员)。 */
339function hosting_plan_list_all()
340{
341 global $DB;
342 return $DB->get_all_prepare("SELECT * FROM MN_plugin_hosting_plan ORDER BY sort ASC, id ASC") ?: [];
343}
344
345/** 保存套餐(新增或更新)。 */
346function hosting_plan_save($data)
347{
348 global $DB, $date;
349 $now = $date ?: date('Y-m-d H:i:s');
350
351 $periods = hosting_periods();
352 $enabled = [];
353 $rawEnabled = isset($data['enabled_periods']) && is_array($data['enabled_periods']) ? $data['enabled_periods'] : [];
354 foreach ($rawEnabled as $p) {
355 if (isset($periods[$p])) {
356 $enabled[] = $p;
357 }
358 }
359
360 $fields = [
361 'name' => trim((string)($data['name'] ?? '')),
362 'description' => trim((string)($data['description'] ?? '')),
363 'category' => trim((string)($data['category'] ?? '')),
364 'node' => trim((string)($data['node'] ?? '')),
365 'spec_web' => max(0, (int)($data['spec_web'] ?? 0)),
366 'spec_sql' => max(0, (int)($data['spec_sql'] ?? 0)),
367 'spec_flow' => max(0, (int)($data['spec_flow'] ?? 0)),
368 'spec_domain' => max(0, (int)($data['spec_domain'] ?? 0)),
369 'price_month_cents' => max(0, (int)($data['price_month_cents'] ?? 0)),
370 'price_quarter_cents' => max(0, (int)($data['price_quarter_cents'] ?? 0)),
371 'price_half_year_cents' => max(0, (int)($data['price_half_year_cents'] ?? 0)),
372 'price_year_cents' => max(0, (int)($data['price_year_cents'] ?? 0)),
373 'price_two_year_cents' => max(0, (int)($data['price_two_year_cents'] ?? 0)),
374 'price_three_year_cents' => max(0, (int)($data['price_three_year_cents'] ?? 0)),
375 'enabled_periods' => implode(',', $enabled),
376 'status' => ($data['status'] ?? 'active') === 'inactive' ? 'inactive' : 'active',
377 'sort' => max(0, (int)($data['sort'] ?? 50)),
378 'updated_at' => $now,
379 ];
380 if ($fields['name'] === '') {
381 return '套餐名称不能为空';
382 }
383 if ($fields['node'] === '') {
384 return '请选择固定开通节点';
385 }
386 // 至少启用一个有效周期
387 if ($enabled === []) {
388 return '请至少选择一个购买周期';
389 }
390
391 $id = (int)($data['id'] ?? 0);
392 if ($id > 0) {
393 $ok = $DB->query_prepare(
394 "UPDATE MN_plugin_hosting_plan SET name=?, description=?, category=?, node=?, spec_web=?, spec_sql=?, spec_flow=?, spec_domain=?, price_month_cents=?, price_quarter_cents=?, price_half_year_cents=?, price_year_cents=?, price_two_year_cents=?, price_three_year_cents=?, enabled_periods=?, status=?, sort=?, updated_at=? WHERE id=?",
395 [$fields['name'], $fields['description'], $fields['category'], $fields['node'], $fields['spec_web'], $fields['spec_sql'], $fields['spec_flow'], $fields['spec_domain'], $fields['price_month_cents'], $fields['price_quarter_cents'], $fields['price_half_year_cents'], $fields['price_year_cents'], $fields['price_two_year_cents'], $fields['price_three_year_cents'], $fields['enabled_periods'], $fields['status'], $fields['sort'], $fields['updated_at'], $id]
396 );
397 return $ok ? true : '更新失败';
398 }
399 $fields['created_at'] = $now;
400 $ok = $DB->query_prepare(
401 "INSERT INTO MN_plugin_hosting_plan (name, description, category, node, spec_web, spec_sql, spec_flow, spec_domain, price_month_cents, price_quarter_cents, price_half_year_cents, price_year_cents, price_two_year_cents, price_three_year_cents, enabled_periods, status, sort, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
402 [$fields['name'], $fields['description'], $fields['category'], $fields['node'], $fields['spec_web'], $fields['spec_sql'], $fields['spec_flow'], $fields['spec_domain'], $fields['price_month_cents'], $fields['price_quarter_cents'], $fields['price_half_year_cents'], $fields['price_year_cents'], $fields['price_two_year_cents'], $fields['price_three_year_cents'], $fields['enabled_periods'], $fields['status'], $fields['sort'], $fields['created_at'], $fields['updated_at']]
403 );
404 return $ok ? true : '新增失败';
405}
406
407/** 删除套餐。 */
408function hosting_plan_delete($plan_id)
409{
410 global $DB;
411 return (bool)$DB->query_prepare("DELETE FROM MN_plugin_hosting_plan WHERE id=? LIMIT 1", [(int)$plan_id]);
412}
413
414/* ============================================================
415 * 节点管理
416 * ============================================================ */
417
418/** 获取可用宝塔节点列表(用户端,仅返回节点标识,不暴露 IP/端口/系统/协议等敏感信息)。 */
419function hosting_node_list()
420{
421 global $DB;
422 return $DB->get_all_prepare("SELECT btdh FROM MN_bt ORDER BY id ASC") ?: [];
423}
424
425/** 获取单个节点。 */
426function hosting_node_get($btdh)
427{
428 global $DB;
429 return $DB->get_row_prepare("SELECT * FROM MN_bt WHERE btdh=? LIMIT 1", [$btdh]) ?: null;
430}
431
432/** 获取全部节点列表(管理员端,含完整信息)。 */
433function hosting_node_list_all()
434{
435 global $DB;
436 return $DB->get_all_prepare("SELECT * FROM MN_bt ORDER BY id ASC") ?: [];
437}
438
439/* ============================================================
440 * 节点 PHP 版本管理已移至系统底层 MPHX/bt_php.function.php
441 * 请使用 mnbt_node_get_php() / mnbt_node_set_php() 等函数
442 * ============================================================ */
443
444/* ============================================================
445 * 订单管理
446 * ============================================================ */
447
448/** 按订单号查询订单。 */
449function hosting_order_get_by_no($order_no)
450{
451 global $DB;
452 return $DB->get_row_prepare("SELECT * FROM MN_plugin_hosting_order WHERE order_no=? LIMIT 1", [$order_no]) ?: null;
453}
454
455/** 按 ID 查询订单。 */
456function hosting_order_get($order_id)
457{
458 global $DB;
459 return $DB->get_row_prepare("SELECT * FROM MN_plugin_hosting_order WHERE id=? LIMIT 1", [(int)$order_id]) ?: null;
460}
461
462/** 用户的订单列表(分页)。 */
463function hosting_order_list_by_user($user_id, $page = 1, $per_page = 20)
464{
465 global $DB;
466 $user_id = (int)$user_id;
467 $page = max(1, (int)$page);
468 $per_page = max(1, min(100, (int)$per_page));
469 $offset = ($page - 1) * $per_page;
470 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_hosting_order WHERE user_id=?", [$user_id]);
471 $total = $count_row ? (int)$count_row['cnt'] : 0;
472 $list = $DB->get_all_prepare(
473 "SELECT * FROM MN_plugin_hosting_order WHERE user_id=? ORDER BY id DESC LIMIT {$offset},{$per_page}",
474 [$user_id]
475 ) ?: [];
476 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
477}
478
479/** 全部订单列表(管理员,分页 + 简单筛选)。 */
480function hosting_order_list_all($page = 1, $per_page = 30, $filters = [])
481{
482 global $DB;
483 $page = max(1, (int)$page);
484 $per_page = max(1, min(200, (int)$per_page));
485 $offset = ($page - 1) * $per_page;
486
487 $where = '1';
488 $params = [];
489 if (!empty($filters['status'])) {
490 $where .= ' AND status=?';
491 $params[] = $filters['status'];
492 }
493 if (!empty($filters['user_id'])) {
494 $where .= ' AND user_id=?';
495 $params[] = (int)$filters['user_id'];
496 }
497 if (!empty($filters['order_no'])) {
498 $where .= ' AND order_no=?';
499 $params[] = $filters['order_no'];
500 }
501
502 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_hosting_order WHERE {$where}", $params);
503 $total = $count_row ? (int)$count_row['cnt'] : 0;
504 $list = $DB->get_all_prepare(
505 "SELECT * FROM MN_plugin_hosting_order WHERE {$where} ORDER BY id DESC LIMIT {$offset},{$per_page}",
506 $params
507 ) ?: [];
508 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
509}
510
511/**
512 * 创建购买订单(未支付)。
513 *
514 * @param array $user user_info 当前用户
515 * @param array $plan 套餐行(内含固定 node)
516 * @param string $period month/year
517 * @return array ['ok'=>bool, 'order_no'=>string, 'order_id'=>int, 'msg'=>string]
518 */
519function hosting_order_create($user, $plan, $period)
520{
521 global $DB, $date;
522 $periods = hosting_periods();
523 if (!isset($periods[$period])) {
524 return ['ok' => false, 'msg' => '无效的购买周期'];
525 }
526 $enabled = hosting_plan_enabled_periods($plan);
527 if (!in_array($period, $enabled, true)) {
528 return ['ok' => false, 'msg' => '该套餐不支持此购买周期'];
529 }
530 $field = hosting_period_price_field($period);
531 $amount_cents = $field ? (int)($plan[$field] ?? 0) : 0;
532 if ($amount_cents < 0) {
533 return ['ok' => false, 'msg' => '该套餐此周期价格异常'];
534 }
535 $node = trim((string)($plan['node'] ?? ''));
536 if ($node === '' || !hosting_node_get($node)) {
537 return ['ok' => false, 'msg' => '套餐未配置有效开通节点'];
538 }
539 $now = $date ?: date('Y-m-d H:i:s');
540 $order_no = date("YmdHis") . mt_rand(1000, 9999);
541 $ok = $DB->query_prepare(
542 "INSERT INTO MN_plugin_hosting_order (user_id, plan_id, plan_name, period, amount_cents, order_no, host_id, node, status, remark, created_at, paid_at, opened_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
543 [(int)$user['id'], (int)$plan['id'], $plan['name'], $period, $amount_cents, $order_no, 0, $node, 'pending', '', $now, '', '']
544 );
545 if (!$ok) {
546 return ['ok' => false, 'msg' => '订单写入失败'];
547 }
548 // 取自增 ID
549 $row = $DB->get_row_prepare("SELECT id FROM MN_plugin_hosting_order WHERE order_no=? LIMIT 1", [$order_no]);
550 $order_id = $row ? (int)$row['id'] : 0;
551 return ['ok' => true, 'order_no' => $order_no, 'order_id' => $order_id, 'amount_cents' => $amount_cents];
552}
553
554/** 更新订单状态。 */
555function hosting_order_set_status($order_id, $status, $remark = '')
556{
557 global $DB, $date;
558 $now = $date ?: date('Y-m-d H:i:s');
559 $extra = '';
560 $params = [$status];
561 if ($status === 'paid') {
562 $extra = ', paid_at=?';
563 $params[] = $now;
564 } elseif ($status === 'opened') {
565 $extra = ', opened_at=?';
566 $params[] = $now;
567 }
568 if ($remark !== '') {
569 $extra .= ', remark=?';
570 $params[] = $remark;
571 }
572 $params[] = (int)$order_id;
573 return (bool)$DB->query_prepare(
574 "UPDATE MN_plugin_hosting_order SET status=?{$extra} WHERE id=?",
575 $params
576 );
577}
578
579/* ============================================================
580 * 资产管理
581 * ============================================================ */
582
583/** 用户资产列表。 */
584function hosting_asset_list_by_user($user_id)
585{
586 global $DB;
587 // 用户端不查询宝塔节点敏感信息(IP、端口、密钥等),仅保留节点标识 ssbt
588 return $DB->get_all_prepare(
589 "SELECT a.*, zj.user AS host_user, zj.pass AS host_pass, zj.sqldz, zj.ssbt, zj.btid, zj.qk AS host_qk, zj.data, zj.datae
590 FROM MN_plugin_hosting_asset a
591 LEFT JOIN MN_zj zj ON zj.id = a.host_id
592 WHERE a.user_id=?
593 ORDER BY a.id DESC",
594 [(int)$user_id]
595 ) ?: [];
596}
597
598/** 全部资产列表(管理员,分页)。 */
599function hosting_asset_list_all($page = 1, $per_page = 30)
600{
601 global $DB;
602 $page = max(1, (int)$page);
603 $per_page = max(1, min(200, (int)$per_page));
604 $offset = ($page - 1) * $per_page;
605 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_hosting_asset WHERE 1");
606 $total = $count_row ? (int)$count_row['cnt'] : 0;
607 $list = $DB->get_all_prepare(
608 "SELECT a.*, zj.user AS host_user, zj.pass AS host_pass, zj.sqldz, zj.ssbt, zj.btid, zj.qk AS host_qk, zj.data, zj.datae,
609 bt.btip, bt.btdk, bt.ptl
610 FROM MN_plugin_hosting_asset a
611 LEFT JOIN MN_zj zj ON zj.id = a.host_id
612 LEFT JOIN MN_bt bt ON bt.btdh = zj.ssbt
613 ORDER BY a.id DESC LIMIT {$offset},{$per_page}"
614 ) ?: [];
615 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
616}
617
618/* ============================================================
619 * 主机开通(核心)
620 * ============================================================ */
621
622/**
623 * 开通主机:调用宝塔 API 创建站点,写入 MN_zj,回填订单和资产。
624 *
625 * @param int $order_id MN_plugin_hosting_order.id
626 * @return array ['ok'=>bool, 'msg'=>string, 'host_id'=>int]
627 */
628function hosting_open_host($order_id)
629{
630 global $DB, $date, $conf;
631 $order = hosting_order_get($order_id);
632 if (!$order) {
633 return ['ok' => false, 'msg' => '订单不存在'];
634 }
635 if ($order['status'] !== 'paid') {
636 return ['ok' => false, 'msg' => '订单状态非已支付,无法开通'];
637 }
638 if ((int)$order['host_id'] > 0) {
639 return ['ok' => false, 'msg' => '该订单已开通', 'host_id' => (int)$order['host_id']];
640 }
641 $plan = hosting_plan_get($order['plan_id']);
642 if (!$plan) {
643 hosting_order_set_status($order_id, 'failed', '套餐不存在');
644 return ['ok' => false, 'msg' => '套餐不存在'];
645 }
646 $node = hosting_node_get($order['node']);
647 if (!$node) {
648 hosting_order_set_status($order_id, 'failed', '节点不存在');
649 return ['ok' => false, 'msg' => '节点不存在'];
650 }
651
652 // 加载宝塔 API 类
653 $bt_api_file = ROOT . 'MPHX/bt_api.php';
654 if (!is_file($bt_api_file)) {
655 hosting_order_set_status($order_id, 'failed', 'bt_api 类文件缺失');
656 return ['ok' => false, 'msg' => 'bt_api 类文件缺失'];
657 }
658 require_once $bt_api_file;
659
660 $btipe = ($node['ptl'] == 'true' ? 'https' : 'http') . '://' . $node['btip'] . ':' . $node['btdk'];
661 $btkeye = $node['btmy'];
662 $api = new bt_api($btipe, $btkeye);
663
664 // 生成宝塔面板账号(保证 >=6 字符且全局唯一)
665 $user_info_id = (int)$order['user_id'];
666 $base_user = 'mb' . $user_info_id;
667 $bt_user = $base_user . substr(md5($date . $order['order_no'] . mt_rand(100, 999)), 0, 6);
668 $bt_pass = substr(md5($date . SYS_KEY . $order['order_no'] . mt_rand(1000, 9999)), 0, 12);
669
670 // 生成站点目录名(防止重名)
671 $hskr = mt_rand(4, 10);
672 $rqsj = md5($date . $bt_user . mt_rand(100, 999));
673 $wjler = substr($rqsj, $hskr, 3);
674 $btserw = 'mnbt.' . $bt_user . $wjler;
675
676 // 产品类型固定为主机(CDN 产品已下线)
677 $cp_eh_lx = '主机';
678 $cp_eh_ftp = 'true';
679 $cp_eh_sql = 'true';
680 // 确定 PHP 版本:优先使用节点已保存的,否则自动检测最新版本
681 $phpVersion = $node['mrbts_php'] ?? '';
682 if ($phpVersion === '' || $phpVersion === '00') {
683 $phpList = $api->btapi_listphp();
684 if (is_array($phpList)) {
685 $versions = [];
686 foreach ($phpList as $v) {
687 if (($v['status'] ?? false) && ($v['version'] ?? '') !== '00') {
688 $versions[] = $v['version'];
689 }
690 }
691 if (!empty($versions)) {
692 usort($versions, function($a, $b) { return strcmp($b, $a); });
693 $phpVersion = $versions[0];
694 $DB->query_prepare("UPDATE MN_bt SET mrbts_php=? WHERE btdh=? LIMIT 1", [$phpVersion, $order['node']]);
695 }
696 }
697 }
698 if ($phpVersion === '' || $phpVersion === '00') {
699 hosting_order_set_status($order_id, 'failed', '无法获取节点 PHP 版本');
700 return ['ok' => false, 'msg' => '无法获取该节点的 PHP 版本,请先在宝塔面板安装 PHP 或在节点管理中设置默认 PHP 版本'];
701 }
702 $mrwww = $phpVersion;
703 $mrml = ($node['btos'] == '1' ? $conf['hxi'] : $conf['hxo']) . '/' . $btserw;
704
705 // 计算到期时间(支持月/季/半年/年/两年/三年)
706 $now_ts = time();
707 $periodCfg = hosting_periods()[($order['period'] ?? 'month')] ?? hosting_periods()['month'];
708 $expire_ts = strtotime('+' . (int)$periodCfg['months'] . ' months', $now_ts);
709 $datae = date('Y-m-d', $expire_ts);
710
711 // 调用宝塔 API 开通
712 $r_data = $api->webkt($bt_user, $bt_pass, $btserw, $cp_eh_lx, $cp_eh_ftp, $cp_eh_sql, $phpVersion, $mrml);
713 $cjqk = $r_data['siteStatus'] ?? false;
714 $zdide = $r_data['siteId'] ?? 0;
715
716 if (!$cjqk) {
717 $err = $r_data['msg'] ?? '宝塔未返回站点ID';
718 hosting_order_set_status($order_id, 'failed', '宝塔创建失败:' . $err);
719 return ['ok' => false, 'msg' => '宝塔创建失败:' . $err];
720 }
721
722 // 设置到期时间
723 $r_datan = $api->setdqsj($zdide, $datae);
724 if (!($r_datan['status'] == '1' || $r_datan['status'] == 'true')) {
725 // 设置到期失败不致命,继续流程,但记录备注
726 @error_log('[hosting_shop] setdqsj failed for order ' . $order['order_no']);
727 }
728
729 // 按账号精确获取 FTP/数据库 ID(原为全表拉取,主机数多时会显著变慢)
730 $aedfs = $api->sjid('ftps', $bt_user);
731 $sqlfs = $api->sjid('databases', $bt_user);
732
733 // 写入 MN_zj 表
734 $webdx = json_encode(['max' => (int)$plan['spec_web'], 'dq' => 0]);
735 $sqldx = json_encode(['max' => (int)$plan['spec_sql'], 'dq' => 0]);
736 $flowratemax = json_encode(['max' => (int)$plan['spec_flow'], 'dq' => 0, 'statistics' => false]);
737 $ymbds = (string)(int)$plan['spec_domain'];
738 $now = $date ?: date('Y-m-d H:i:s');
739 $kg = 'true';
740
741 // 写入主机记录:max(id)+1 在并发下可能重复导致插入失败,失败时重新取号重试几次(与 api/api.php 开通逻辑一致)
742 $insert_ok = false;
743 for($tryi = 0; $tryi < 3; $tryi++){
744 $rowe = $DB->get_row_prepare("SELECT id FROM MN_zj WHERE 1 order by id desc limit 1");
745 $new_zj_id = $rowe ? ((int)$rowe['id'] + 1) : 1;
746 if($DB->query_prepare(
747 "INSERT INTO `MN_zj` (`id`, `ssbt`, `user`, `pass`, `sqluser`, `sqlpass`, `data`, `datae`, `qk`, `btid`, `sqldz`, `ftpid`, `ymbds`, `hxa`, `hxb`, `hxc`, `hxd`, `llmax`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
748 [$new_zj_id, $order['node'], $bt_user, $bt_pass, $bt_user, $bt_pass, $now, $datae, $kg, $zdide, $btserw, $aedfs, $ymbds, $webdx, $sqldx, '2', $sqlfs, $flowratemax]
749 )){
750 $insert_ok = true;
751 break;
752 }
753 }
754 if (!$insert_ok) {
755 // 宝塔站点已开通但本地数据库写入失败:回滚删除宝塔站点,避免留下无主站点
756 $api->delsite($zdide, $btserw);
757 hosting_order_set_status($order_id, 'failed', '本地数据库写入失败,已回滚宝塔站点');
758 return ['ok' => false, 'msg' => '本地数据库写入失败,请联系管理员'];
759 }
760
761 // 回填订单
762 hosting_order_set_status($order_id, 'opened', '主机已开通,账号:' . $bt_user);
763 $DB->query_prepare(
764 "UPDATE MN_plugin_hosting_order SET host_id=? WHERE id=?",
765 [$new_zj_id, $order_id]
766 );
767
768 // 写入资产表
769 $DB->query_prepare(
770 "INSERT INTO MN_plugin_hosting_asset (user_id, order_id, host_id, plan_id, plan_name, expire_at, status, created_at) VALUES (?,?,?,?,?,?,?,?)",
771 [$user_info_id, $order_id, $new_zj_id, (int)$plan['id'], $plan['name'], $datae, 'active', $now]
772 );
773
774 // 触发 host.created 钩子(与 admin 开通逻辑保持一致)
775 if (function_exists('mnbt_do_action')) {
776 $host_row = $DB->get_row_prepare("SELECT * FROM MN_zj WHERE id=? LIMIT 1", [$new_zj_id]);
777 mnbt_do_action('host.created', $host_row ?: ['id' => $new_zj_id, 'user' => $bt_user, 'ssbt' => $order['node']], ['source' => 'hosting_shop', 'order_id' => $order_id, 'user_id' => $user_info_id]);
778 }
779
780 return ['ok' => true, 'msg' => '开通成功', 'host_id' => $new_zj_id, 'bt_user' => $bt_user, 'bt_pass' => $bt_pass, 'expire' => $datae];
781}