better-staridc-MNBT
1<?php
2/**
3 * zjmfmanager_reserve 插件 - 主入口
4 *
5 * 功能:以代理商身份分销魔方财务(cube_finance)产品。
6 * 依赖:user_info 插件(认证)、balance 插件(余额支付/退款)。
7 * 架构:
8 * - 上游服务:lib/upstream.php(ZjmfUpstream,封装全部魔方财务 API 调用)
9 * - 辅助函数:lib/zjmf.php(URL/渲染/金额/加价计算 + 数据表操作 + 开通编排)
10 * - 用户端:通过 P2 路由注册 /reserve/* 路径
11 * - 管理员端:通过 mnbt_register_page('admin', ...) 注册到 plugin.php
12 * - 开通:通过 order.paid 钩子处理 lx=zjmf 订单,调用上游开通,失败自动退款
13 */
14
15if (!defined('IN_CRONLITE')) {
16 exit;
17}
18
19require_once __DIR__ . '/lib/zjmf.php';
20require_once __DIR__ . '/lib/upstream.php';
21
22// 确保插件数据表存在:用 option 标记一次性执行,避免每请求都跑一遍 install.sql。
23// 建表语句全部 IF NOT EXISTS,幂等;schema 版本变更时调大
24// ZJMF_SCHEMA_VERSION 即会重跑一次(对齐仓库插件 option 标记惯例)。
25define('ZJMF_SCHEMA_VERSION', '1');
26static $zjmf_tables_ready = false;
27if (!$zjmf_tables_ready && function_exists('mnbt_plugin_run_sql_file')) {
28 $zjmf_tables_ready = true;
29 $doneVer = function_exists('mnbt_plugin_option_get')
30 ? (string)mnbt_plugin_option_get('zjmfmanager_reserve', 'schema_version', '') : '';
31 if ($doneVer !== ZJMF_SCHEMA_VERSION) {
32 mnbt_plugin_run_sql_file(__DIR__ . '/install.sql');
33 if (function_exists('mnbt_plugin_option_set')) {
34 mnbt_plugin_option_set('zjmfmanager_reserve', 'schema_version', ZJMF_SCHEMA_VERSION);
35 }
36 }
37}
38
39mnbt_plugin_register('zjmfmanager_reserve', [
40 'name' => '魔方财务代理分销',
41 'description' => '商品同步加价、本地余额购买、代理商直通开通、主机管理与升降级',
42]);
43
44/* ============================================================
45 * order.paid 钩子:处理购买 / 升级订单
46 * ============================================================
47 * 支付成功后由 mnbt_pay_settle_order() 触发。
48 * 1. 按 lx=zjmf 过滤(仅处理本插件创建的 MN_dd 订单)
49 * 2. 按订单号找到本地业务订单
50 * 3. 防重复:已支付/已开通/已失败直接跳过
51 * 4. 标记 paid 后按 action 分流处理(buy→开通;升级→后续里程碑)
52 */
53mnbt_add_action('order.paid', function ($order_row, $ctx = []) {
54 if (!is_array($order_row)) {
55 return;
56 }
57 if (($order_row['lx'] ?? '') !== ZJMF_LX) {
58 return;
59 }
60 $order_no = (string)($order_row['ddh'] ?? '');
61 if ($order_no === '') {
62 return;
63 }
64
65 // 业务订单号前缀校验,防止串单
66 if (strpos($order_no, ZJMF_ORDER_PREFIX) !== 0) {
67 return;
68 }
69
70 $order = zjmf_order_get_by_no($order_no);
71 if (!$order) {
72 return;
73 }
74 // 已支付 / 已开通 / 已失败,跳过(防重复处理)
75 if (in_array($order['status'], ['paid', 'opened', 'failed'], true)) {
76 return;
77 }
78
79 zjmf_order_set_status((int)$order['id'], 'paid', '支付完成');
80
81 // 仅 buy 动作本期直接开通;升级订单由升级接口联动处理
82 if ($order['action'] === 'buy') {
83 $result = zjmf_open_host((int)$order['id']);
84 if (!$result['ok']) {
85 @error_log('[zjmfmanager_reserve] open failed order=' . $order_no
86 . ' : ' . ($result['msg'] ?? ''));
87 }
88 }
89}, 20);
90
91/* ============================================================
92 * 管理员端页面注册
93 * ============================================================ */
94
95mnbt_register_page('admin', 'suppliers', 'views/admin/suppliers.php', '供应商管理');
96mnbt_register_page('admin', 'products', 'views/admin/products.php', '商品管理');
97mnbt_register_page('admin', 'orders', 'views/admin/orders.php', '订单管理');
98mnbt_register_page('admin', 'hosts', 'views/admin/hosts.php', '主机管理');
99mnbt_register_page('admin', 'assign', 'views/admin/assign.php', '主机指派');
100mnbt_register_page('admin', 'logs', 'views/admin/logs.php', '操作日志');
101
102// 侧边栏菜单(三级结构)
103mnbt_register_menu('admin', [
104 'title' => '魔方财务分销',
105 'icon' => 'mdi-currency-cny',
106 'order' => 60,
107 'children' => [
108 ['title' => '供应商管理', 'page' => 'suppliers', 'icon' => 'mdi-settings', 'multitabs' => true],
109 ['title' => '商品管理', 'page' => 'products', 'icon' => 'mdi-package-variant', 'multitabs' => true],
110 ['title' => '订单管理', 'page' => 'orders', 'icon' => 'mdi-receipt', 'multitabs' => true],
111 ['title' => '主机管理', 'page' => 'hosts', 'icon' => 'mdi-server', 'multitabs' => true],
112 ['title' => '主机指派', 'page' => 'assign', 'icon' => 'mdi-gift', 'multitabs' => true],
113 ['title' => '操作日志', 'page' => 'logs', 'icon' => 'mdi-clipboard-text', 'multitabs' => true],
114 ],
115]);
116
117/* ============================================================
118 * 管理员端 AJAX
119 * ============================================================ */
120
121// 保存供应商(新增/编辑,密码留空不修改)
122mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
123 mnbt_plugin_require_admin();
124 global $DB, $date;
125
126 $id = (int)($_POST['id'] ?? 0);
127 $name = trim((string)($_POST['name'] ?? ''));
128 $url = trim((string)($_POST['api_url'] ?? ''));
129 $username = trim((string)($_POST['api_username'] ?? ''));
130 $password = (string)($_POST['api_password'] ?? '');
131 $timeout = max(5, min(120, (int)($_POST['api_timeout'] ?? 30)));
132 $markupType = in_array((string)($_POST['markup_type'] ?? ''), ['0', '1'], true)
133 ? (int)$_POST['markup_type'] : 0;
134 $markupValue = max(0, (int)($_POST['markup_value'] ?? 0));
135 $status = in_array((string)($_POST['status'] ?? ''), ['0', '1'], true)
136 ? (int)$_POST['status'] : 1;
137 $sort = max(0, (int)($_POST['sort'] ?? 0));
138 $remark = trim((string)($_POST['remark'] ?? ''));
139
140 if ($name === '') {
141 json_exit_error('请填写供应商名称');
142 }
143 if (mb_strlen($name) > 50) {
144 json_exit_error('供应商名称过长');
145 }
146 if ($url !== '' && !preg_match('#^https?://#i', $url)) {
147 json_exit_error('站点 URL 必须以 http:// 或 https:// 开头');
148 }
149 if (mb_strlen($url) > 255) {
150 json_exit_error('站点 URL 过长');
151 }
152 // http:// 上游为明文传输,凭据可被截获,落一条管理员可见的告警日志
153 if ($url !== '' && stripos($url, 'http://') === 0) {
154 @error_log('[zjmfmanager_reserve] 警告:供应商「' . $name . '」使用不加密的'
155 . ' http:// 上游地址,API 凭据可能被明文传输,建议改用 https://');
156 }
157 if ($url !== '' && $username === '') {
158 json_exit_error('请填写 API 用户名');
159 }
160 if ($password !== '' && mb_strlen($password) > 255) {
161 json_exit_error('API 密钥过长');
162 }
163
164 $now = $date ?: date('Y-m-d H:i:s');
165 if ($id > 0) {
166 $existing = zjmf_supplier_get($id);
167 if (!$existing) {
168 json_exit_error('供应商不存在');
169 }
170 $sql = "UPDATE MN_plugin_zjmf_supplier
171 SET name=?, api_url=?, api_username=?, api_timeout=?,
172 markup_type=?, markup_value=?, status=?, sort=?,
173 remark=?, updated_at=?";
174 $args = [$name, $url, $username, $timeout, $markupType, $markupValue,
175 $status, $sort, $remark, $now];
176 if ($password !== '') {
177 $sql .= ", api_password=?";
178 // API 密钥加密入库(读取处 ZjmfUpstream::client 统一解密)
179 $args[] = zjmf_encrypt($password);
180 }
181 $sql .= " WHERE id=?";
182 $args[] = $id;
183 $ok = $DB->query_prepare($sql, $args);
184 if (!$ok) {
185 json_exit_error('保存失败');
186 }
187 } else {
188 $ok = $DB->query_prepare(
189 "INSERT INTO MN_plugin_zjmf_supplier
190 (name, api_url, api_username, api_password, api_timeout,
191 markup_type, markup_value, status, sort, remark, created_at, updated_at)
192 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
193 [$name, $url, $username, zjmf_encrypt($password), $timeout, $markupType,
194 $markupValue, $status, $sort, $remark, $now, $now]
195 );
196 if (!$ok) {
197 json_exit_error('保存失败');
198 }
199 }
200 json_exit_success('已保存');
201});
202
203// 启用/停用供应商
204mnbt_register_ajax('admin', 'p_zjmf_admin_toggle_supplier', function () {
205 mnbt_plugin_require_admin();
206 global $DB, $date;
207
208 $id = (int)($_POST['id'] ?? 0);
209 $supplier = zjmf_supplier_get($id);
210 if (!$supplier) {
211 json_exit_error('供应商不存在');
212 }
213 $newStatus = (int)$supplier['status'] === 1 ? 0 : 1;
214 $now = $date ?: date('Y-m-d H:i:s');
215 $DB->query_prepare(
216 "UPDATE MN_plugin_zjmf_supplier SET status=?, updated_at=? WHERE id=?",
217 [$newStatus, $now, $id]
218 );
219 json_exit_success($newStatus === 1 ? '已启用' : '已停用');
220});
221
222// 删除供应商(有商品/订单/主机时拒绝)
223mnbt_register_ajax('admin', 'p_zjmf_admin_delete_supplier', function () {
224 mnbt_plugin_require_admin();
225 $id = (int)($_POST['id'] ?? 0);
226 $result = zjmf_supplier_delete($id);
227 if (empty($result['ok'])) {
228 json_exit_error($result['msg'] ?? '删除失败');
229 }
230 json_exit_success($result['msg'] ?? '已删除');
231});
232
233// 连通测试(登录 + 商品列表)
234mnbt_register_ajax('admin', 'p_zjmf_admin_test_supplier', function () {
235 mnbt_plugin_require_admin();
236 $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
237 if (!$supplier) {
238 json_exit_error('供应商不存在');
239 }
240 $result = ZjmfUpstream::testConnection($supplier);
241 if (empty($result['ok'])) {
242 json_exit_error($result['msg'] ?? '连接失败');
243 }
244 json_exit_success($result['msg'] ?? '连接成功');
245});
246
247// 查询供应商上游余额(GET /user_info → data.credit)
248mnbt_register_ajax('admin', 'p_zjmf_admin_supplier_balance', function () {
249 mnbt_plugin_require_admin();
250 $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
251 if (!$supplier) {
252 json_exit_error('供应商不存在');
253 }
254 $result = ZjmfUpstream::balance($supplier);
255 if (empty($result['ok'])) {
256 json_exit_error($result['msg'] ?? '获取失败');
257 }
258 json_exit_success('ok', [
259 'credit' => (string)($result['credit'] ?? ''),
260 'currency' => (string)($result['currency'] ?? ''),
261 ]);
262});
263
264// 拉取供应商上游商品列表(供同步弹窗选择)
265mnbt_register_ajax('admin', 'p_zjmf_admin_upstream_products', function () {
266 mnbt_plugin_require_admin();
267 $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
268 if (!$supplier) {
269 json_exit_error('供应商不存在');
270 }
271 $result = ZjmfUpstream::upstreamProducts($supplier);
272 if (empty($result['ok'])) {
273 json_exit_error($result['msg'] ?? '拉取失败');
274 }
275 // 标注已同步商品,便于弹窗勾选时识别
276 $existing = [];
277 $rows = zjmf_product_list_all();
278 foreach ($rows as $p) {
279 if ((int)$p['supplier_id'] === (int)$supplier['id']) {
280 $existing[(int)$p['up_product_id']] = true;
281 }
282 }
283 $list = [];
284 foreach (($result['data']['list'] ?? []) as $item) {
285 $list[] = [
286 'id' => (int)($item['id'] ?? 0),
287 'name' => (string)($item['name'] ?? ''),
288 'description' => (string)($item['description'] ?? ''),
289 'module' => ZjmfUpstream::itemModule($item),
290 'price' => ZjmfUpstream::itemPrice($item),
291 'synced' => isset($existing[(int)($item['id'] ?? 0)]),
292 ];
293 }
294 json_exit_success('拉取成功', [
295 'currency' => (string)($result['data']['currency_code'] ?? ''),
296 'list' => $list,
297 ]);
298});
299
300// 拉取当前账户(代理)已开通的主机列表(产品分配页用)
301// 来源:GET host/list,每台主机一行;标注本地已指派状态
302mnbt_register_ajax('admin', 'p_zjmf_admin_upstream_owned_products', function () {
303 mnbt_plugin_require_admin();
304 global $DB;
305 $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
306 if (!$supplier) {
307 json_exit_error('供应商不存在');
308 }
309
310 // 分页拉全量主机列表(limit 尽量大,翻页兜底;安全上限防死循环)
311 $items = [];
312 $sum = 0;
313 $maxPage = 1;
314 $page = 1;
315 $safety = 30;
316 while ($page <= $maxPage && $safety-- > 0) {
317 $res = ZjmfUpstream::hostList($supplier, ['page' => $page, 'limit' => 100]);
318 if (empty($res['ok'])) {
319 json_exit_error('拉取失败:' . (string)($res['msg'] ?? ''));
320 }
321 $data = $res['data'] ?? [];
322 foreach (($data['list'] ?? []) as $h) {
323 $items[] = $h;
324 }
325 $sum = (int)($data['sum'] ?? count($items));
326 $maxPage = (int)($data['max_page'] ?? 0);
327 if ($maxPage <= 0) {
328 $maxPage = 1;
329 }
330 if ($sum > 0 && count($items) >= $sum) {
331 break;
332 }
333 $page++;
334 }
335
336 // 本地已指派的主机集合(供应商 + 上游主机ID)
337 $assigned = [];
338 $rows = $DB->get_all_prepare(
339 "SELECT up_host_id FROM MN_plugin_zjmf_host
340 WHERE supplier_id=? AND up_host_id>0",
341 [(int)$supplier['id']]
342 ) ?: [];
343 foreach ($rows as $r) {
344 $assigned[(int)$r['up_host_id']] = true;
345 }
346
347 $list = [];
348 foreach ($items as $h) {
349 $upId = (int)($h['id'] ?? 0);
350 if ($upId <= 0) {
351 continue;
352 }
353 $list[] = [
354 'id' => $upId,
355 'domain' => (string)($h['domain'] ?? ''),
356 'productname' => (string)($h['productname'] ?? ''),
357 'status' => (string)($h['domainstatus'] ?? ''),
358 'status_desc' => (string)($h['domainstatus_desc'] ?? $h['domainstatus'] ?? ''),
359 'dedicatedip' => (string)($h['dedicatedip'] ?? ''),
360 'cycle' => (string)($h['cycle_desc'] ?? $h['billingcycle'] ?? ''),
361 'nextdue' => zjmf_normalize_date((string)($h['nextduedate'] ?? '')),
362 'assigned' => isset($assigned[$upId]),
363 ];
364 }
365 json_exit_success('拉取成功', [
366 'list' => $list,
367 ]);
368});
369
370// 产品分配:把上游已开通的机器直接指派给指定用户(本地绑定,不调上游购买)
371mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
372 mnbt_plugin_require_admin();
373 global $DB;
374
375 $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
376 if (!$supplier || (int)$supplier['status'] !== 1) {
377 json_exit_error('供应商不存在或已停用');
378 }
379 $user_id = (int)($_POST['user_id'] ?? 0);
380 $user = $DB->get_row_prepare(
381 "SELECT id, username, email FROM MN_plugin_user
382 WHERE id=? AND status=1 LIMIT 1",
383 [$user_id]
384 );
385 if (!$user) {
386 json_exit_error('目标用户不存在或已禁用');
387 }
388
389 $upHostIds = [];
390 if (isset($_POST['up_host_id']) && is_array($_POST['up_host_id'])) {
391 foreach ($_POST['up_host_id'] as $v) {
392 $v = (int)$v;
393 if ($v > 0) {
394 $upHostIds[] = $v;
395 }
396 }
397 }
398 if ($upHostIds === []) {
399 json_exit_error('请至少勾选一台主机');
400 }
401
402 // 一次拉取上游主机列表,匹配勾选机器的详情(产品名/周期/状态/到期)
403 $upMap = [];
404 $res = ZjmfUpstream::hostList($supplier, ['page' => 1, 'limit' => 100]);
405 if (!empty($res['ok'])) {
406 foreach (($res['data']['list'] ?? []) as $h) {
407 $upMap[(int)($h['id'] ?? 0)] = $h;
408 }
409 }
410
411 $results = [];
412 foreach ($upHostIds as $upHostId) {
413 // 幂等:该上游主机已指派则跳过
414 $exist = $DB->get_row_prepare(
415 "SELECT id FROM MN_plugin_zjmf_host
416 WHERE supplier_id=? AND up_host_id=? LIMIT 1",
417 [(int)$supplier['id'], $upHostId]
418 );
419 if ($exist) {
420 $results[] = [
421 'up_host_id' => $upHostId,
422 'domain' => (string)($upMap[$upHostId]['domain'] ?? ''),
423 'ok' => false,
424 'msg' => '该机器已指派(本地主机 #' . (int)$exist['id'] . ')',
425 'host_id' => (int)$exist['id'],
426 ];
427 continue;
428 }
429
430 $upRow = $upMap[$upHostId] ?? [];
431 $name = (string)($upRow['productname'] ?? '');
432 $cycle = (string)($upRow['cycle_desc'] ?? $upRow['billingcycle'] ?? '');
433 $status = (string)($upRow['domainstatus'] ?? '');
434 $renew = (string)($upRow['nextduedate'] ?? '');
435 $account = '';
436 $password = '';
437 // 拉取上游主机头信息(账号/密码),失败不阻断
438 // 注意:host/header 返回字段为 domainstatus/nextduedate/billingcycle/productname
439 $info = ZjmfUpstream::hostInfo($supplier, $upHostId);
440 if (!empty($info['ok']) && is_array($info['data'])) {
441 $d = $info['data'];
442 $account = (string)($d['username'] ?? '');
443 $password = (string)($d['password'] ?? '');
444 if ($name === '') {
445 $name = (string)($d['productname'] ?? $d['name'] ?? '');
446 }
447 if ($cycle === '') {
448 $cycle = (string)($d['billingcycle_desc'] ?? $d['billingcycle'] ?? $d['cycle_desc'] ?? '');
449 }
450 if ($status === '') {
451 $status = (string)($d['domainstatus'] ?? $d['status'] ?? '');
452 }
453 if ($renew === '') {
454 $renew = (string)($d['nextduedate'] ?? $d['renew_date'] ?? '');
455 }
456 }
457 if ($name === '') {
458 $name = '上游主机#' . $upHostId;
459 }
460
461 // 创建指派订单(记录用,金额 0,直接标记已开通)
462 $product = [
463 'id' => 0,
464 'supplier_id' => (int)$supplier['id'],
465 'up_product_id' => (int)($upRow['pid'] ?? 0),
466 'name' => $name,
467 ];
468 $cycleCfg = ['name' => $cycle !== '' ? $cycle : 'Monthly', 'price_cents' => 0];
469 $order = zjmf_order_create($user, $product, $cycle, $cycleCfg, 'assign', [
470 'cost_cents' => 0,
471 'up_host_id' => $upHostId,
472 'order_params' => json_encode([
473 'assign_by' => 'admin',
474 'assign_type' => 'existing_host',
475 ], JSON_UNESCAPED_UNICODE),
476 ]);
477 if (empty($order['ok'])) {
478 $results[] = [
479 'up_host_id' => $upHostId,
480 'domain' => (string)($upRow['domain'] ?? ''),
481 'ok' => false,
482 'msg' => (string)($order['msg'] ?? '订单创建失败'),
483 'host_id' => 0,
484 ];
485 continue;
486 }
487 $order_id = (int)$order['order_id'];
488
489 // 写本地主机映射(绑定该上游机器)——先建主机,成功后再标记订单
490 // opened,避免主机写入失败时订单停留在已开通的中间态
491 $hostId = zjmf_host_create([
492 'supplier_id' => (int)$supplier['id'],
493 'user_id' => (int)$user['id'],
494 'order_id' => $order_id,
495 'up_host_id' => $upHostId,
496 'up_product_id' => (int)($upRow['pid'] ?? 0),
497 'name' => $name,
498 'username' => $account,
499 'password' => $password !== '' ? zjmf_encrypt($password) : '',
500 'cycle' => $cycle,
501 'status' => zjmf_map_upstream_status($status),
502 'renew_date' => zjmf_normalize_date($renew),
503 ]);
504 if ($hostId <= 0) {
505 zjmf_order_set_status($order_id, 'failed', '本地主机映射写入失败');
506 $results[] = [
507 'up_host_id' => $upHostId,
508 'domain' => (string)($upRow['domain'] ?? ''),
509 'ok' => false,
510 'msg' => '本地主机映射写入失败',
511 'host_id' => 0,
512 ];
513 continue;
514 }
515
516 // 主机映射写入成功后再回填订单并标记 opened
517 zjmf_order_fill_opened($order_id, 0, $upHostId, $account);
518 zjmf_order_set_status($order_id, 'opened', '管理员指派');
519
520 zjmf_log((int)$user['id'], (string)($order['order_no'] ?? ''), 'assign', 'success',
521 json_encode(['up_host_id' => $upHostId, 'assign_by' => 'admin'], JSON_UNESCAPED_UNICODE),
522 (int)$supplier['id']);
523
524 $results[] = [
525 'up_host_id' => $upHostId,
526 'domain' => (string)($upRow['domain'] ?? ''),
527 'ok' => true,
528 'msg' => '指派成功',
529 'host_id' => $hostId,
530 ];
531 }
532 json_exit_success('处理完成', ['results' => $results]);
533});
534
535// 按所选供应商 + 勾选商品 ID 列表同步
536mnbt_register_ajax('admin', 'p_zjmf_admin_sync_products', function () {
537 mnbt_plugin_require_admin();
538 $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
539 if (!$supplier) {
540 json_exit_error('请先选择供应商');
541 }
542 $upIds = [];
543 if (isset($_POST['up_ids']) && is_array($_POST['up_ids'])) {
544 foreach ($_POST['up_ids'] as $v) {
545 $v = (int)$v;
546 if ($v > 0) {
547 $upIds[] = $v;
548 }
549 }
550 }
551 $result = ZjmfUpstream::syncProducts($supplier, $upIds);
552 if (empty($result['ok'])) {
553 json_exit_error($result['msg'] ?? '同步失败');
554 }
555 json_exit_success($result['msg'] ?? '同步完成');
556});
557
558// 产品分配:按关键词搜索可分配用户(user_info 独立用户表)
559mnbt_register_ajax('admin', 'p_zjmf_admin_search_users', function () {
560 mnbt_plugin_require_admin();
561 global $DB;
562 $kw = trim((string)($_POST['keyword'] ?? ''));
563 $list = [];
564 if ($kw !== '') {
565 $like = '%' . $kw . '%';
566 $list = $DB->get_all_prepare(
567 "SELECT id, username, email, qq, created_at
568 FROM MN_plugin_user
569 WHERE status=1 AND (username LIKE ? OR email LIKE ? OR id=?)
570 ORDER BY id DESC LIMIT 20",
571 [$like, $like, (int)$kw]
572 ) ?: [];
573 }
574 json_exit_success('ok', ['list' => $list]);
575});
576
577// 产品分配:同步勾选的上游商品并直接给指定用户开通(管理员代开,金额 0)
578mnbt_register_ajax('admin', 'p_zjmf_admin_assign_open', function () {
579 mnbt_plugin_require_admin();
580 global $DB;
581
582 $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
583 if (!$supplier || (int)$supplier['status'] !== 1) {
584 json_exit_error('供应商不存在或已停用');
585 }
586 $user_id = (int)($_POST['user_id'] ?? 0);
587 $user = $DB->get_row_prepare(
588 "SELECT id, username, email FROM MN_plugin_user
589 WHERE id=? AND status=1 LIMIT 1",
590 [$user_id]
591 );
592 if (!$user) {
593 json_exit_error('目标用户不存在或已禁用');
594 }
595
596 $upIds = [];
597 if (isset($_POST['up_product_id']) && is_array($_POST['up_product_id'])) {
598 foreach ($_POST['up_product_id'] as $v) {
599 $v = (int)$v;
600 if ($v > 0) {
601 $upIds[] = $v;
602 }
603 }
604 }
605 if ($upIds === []) {
606 json_exit_error('请至少勾选一个产品');
607 }
608 // 周期可选:指定周期不存在时自动回退商品第一个可用周期
609 $cycle = trim((string)($_POST['cycle'] ?? ''));
610 if ($cycle !== '' && !isset(zjmf_cycles()[$cycle])) {
611 $cycle = '';
612 }
613
614 $results = [];
615 foreach ($upIds as $upId) {
616 $product = zjmf_product_get_by_up((int)$supplier['id'], $upId);
617 if (!$product) {
618 $sync = ZjmfUpstream::syncOneProductBySupplier($supplier, $upId);
619 if (empty($sync['ok'])) {
620 $results[] = [
621 'up_product_id' => $upId,
622 'name' => '上游#' . $upId,
623 'ok' => false,
624 'msg' => '商品同步失败:' . (string)($sync['msg'] ?? ''),
625 'up_host_id' => 0,
626 ];
627 continue;
628 }
629 $product = zjmf_product_get_by_up((int)$supplier['id'], $upId);
630 }
631 if (!$product) {
632 $results[] = [
633 'up_product_id' => $upId,
634 'name' => '上游#' . $upId,
635 'ok' => false,
636 'msg' => '商品入库后仍未找到',
637 'up_host_id' => 0,
638 ];
639 continue;
640 }
641
642 // 确定周期:管理员指定 > 商品第一个有价格的周期 > Monthly > 任意
643 $cycles = zjmf_product_cycles($product);
644 $pick = ($cycle !== '' && isset($cycles[$cycle])) ? $cycle : '';
645 if ($pick === '') {
646 foreach ($cycles as $k => $cfg) {
647 if ((int)($cfg['agent_price_cents'] ?? 0) > 0
648 || (int)($cfg['price_cents'] ?? 0) > 0) {
649 $pick = $k;
650 break;
651 }
652 }
653 }
654 if ($pick === '') {
655 if (isset($cycles['Monthly'])) {
656 $pick = 'Monthly';
657 } else {
658 foreach ($cycles as $k => $v) {
659 $pick = $k;
660 break;
661 }
662 }
663 }
664 if ($pick === '') {
665 $results[] = [
666 'up_product_id' => $upId,
667 'name' => (string)$product['name'],
668 'ok' => false,
669 'msg' => '商品无可用周期',
670 'up_host_id' => 0,
671 ];
672 continue;
673 }
674
675 // 管理员代开订单:金额 0,标记后直接开通(不扣用户本地余额)
676 $cycleCfg = [
677 'name' => (string)($cycles[$pick]['name'] ?? $pick),
678 'price_cents' => 0,
679 ];
680 $order = zjmf_order_create($user, $product, $pick, $cycleCfg, 'buy', [
681 'cost_cents' => 0,
682 'order_params' => json_encode(['assign_by' => 'admin'], JSON_UNESCAPED_UNICODE),
683 ]);
684 if (empty($order['ok'])) {
685 $results[] = [
686 'up_product_id' => $upId,
687 'name' => (string)$product['name'],
688 'ok' => false,
689 'msg' => (string)($order['msg'] ?? '订单创建失败'),
690 'up_host_id' => 0,
691 ];
692 continue;
693 }
694 $order_id = (int)$order['order_id'];
695 zjmf_order_set_status($order_id, 'paid', '管理员代开');
696 $open = zjmf_open_host($order_id);
697
698 $results[] = [
699 'up_product_id' => $upId,
700 'name' => (string)$product['name'],
701 'ok' => !empty($open['ok']),
702 'msg' => (string)($open['msg'] ?? '开通失败'),
703 'up_host_id' => (int)($open['host_id'] ?? 0),
704 ];
705 }
706 json_exit_success('处理完成', ['results' => $results]);
707});
708
709// 手动添加商品(供应商 + 上游商品 ID + 名称 + 描述)
710mnbt_register_ajax('admin', 'p_zjmf_admin_add_product', function () {
711 mnbt_plugin_require_admin();
712 global $DB, $date;
713
714 $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
715 if (!$supplier || (int)$supplier['status'] !== 1) {
716 json_exit_error('供应商不存在或已停用');
717 }
718 $upId = (int)($_POST['up_product_id'] ?? 0);
719 $name = trim((string)($_POST['name'] ?? ''));
720 $description = trim((string)($_POST['description'] ?? ''));
721 if ($upId <= 0) {
722 json_exit_error('请填写上游商品 ID');
723 }
724 if ($name === '') {
725 json_exit_error('请填写商品名称');
726 }
727 if (mb_strlen($name) > 100) {
728 json_exit_error('商品名称过长');
729 }
730 if (zjmf_product_get_by_up((int)$supplier['id'], $upId)) {
731 json_exit_error('该供应商下已存在此上游商品');
732 }
733
734 $now = $date ?: date('Y-m-d H:i:s');
735 $ok = $DB->query_prepare(
736 "INSERT INTO MN_plugin_zjmf_product
737 (supplier_id, up_product_id, name, description, currency,
738 agent_price_cents, cycles, status, sort, synced_at, created_at, updated_at)
739 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
740 [(int)$supplier['id'], $upId, $name, $description, '', 0,
741 '[]', 0, 50, $now, $now, $now]
742 );
743 if (!$ok) {
744 json_exit_error('商品创建失败');
745 }
746
747 // 立即拉取代理价与各周期价格(失败不阻断,商品标记待同步)
748 $result = ZjmfUpstream::syncOneProductBySupplier($supplier, $upId);
749 if (empty($result['ok'])) {
750 json_exit_error('商品已添加,但价格同步失败:' . ($result['msg'] ?? ''));
751 }
752 json_exit_success('商品已添加,价格同步完成');
753});
754
755// 保存商品名称/简介/加价/排序/状态
756mnbt_register_ajax('admin', 'p_zjmf_admin_save_product', function () {
757 mnbt_plugin_require_admin();
758
759 $id = (int)($_POST['id'] ?? 0);
760 $product = zjmf_product_get($id);
761 if (!$product) {
762 json_exit_error('商品不存在');
763 }
764
765 $name = trim((string)($_POST['name'] ?? ''));
766 $description = (string)($_POST['description'] ?? '');
767 if ($name === '') {
768 json_exit_error('请填写商品名称');
769 }
770 if (mb_strlen($name) > 100) {
771 json_exit_error('商品名称过长');
772 }
773 if (mb_strlen($description) > 65535) {
774 json_exit_error('商品简介过长');
775 }
776
777 $markupType = in_array((string)($_POST['markup_type'] ?? ''), ['0', '1'], true)
778 ? (int)$_POST['markup_type'] : 0;
779 $markupValue = max(0, (int)($_POST['markup_value'] ?? 0));
780 $sort = max(0, (int)($_POST['sort'] ?? 50));
781 $status = in_array((string)($_POST['status'] ?? ''), ['0', '1'], true)
782 ? (int)$_POST['status'] : 0;
783
784 global $DB, $date;
785 $now = $date ?: date('Y-m-d H:i:s');
786 $ok = $DB->query_prepare(
787 "UPDATE MN_plugin_zjmf_product
788 SET name=?, description=?, markup_type=?, markup_value=?, sort=?, status=?, updated_at=?
789 WHERE id=?",
790 [$name, $description, $markupType, $markupValue, $sort, $status, $now, $id]
791 );
792 if (!$ok) {
793 json_exit_error('保存失败');
794 }
795 // 加价变化后重算各周期本地售价
796 zjmf_product_recalc_price($id);
797 json_exit_success('已保存');
798});
799
800// 保存商品各周期售价(管理员直接定价,覆盖加价规则)
801mnbt_register_ajax('admin', 'p_zjmf_admin_save_cycles', function () {
802 mnbt_plugin_require_admin();
803 global $DB, $date;
804
805 $id = (int)($_POST['id'] ?? 0);
806 $product = zjmf_product_get($id);
807 if (!$product) {
808 json_exit_error('商品不存在');
809 }
810
811 $overrides = (isset($_POST['overrides']) && is_array($_POST['overrides']))
812 ? $_POST['overrides'] : [];
813 if ($overrides === []) {
814 json_exit_error('请至少填写一个周期的售价');
815 }
816
817 $now = $date ?: date('Y-m-d H:i:s');
818 $known = zjmf_cycles();
819 $agentCents = max(0, (int)$product['agent_price_cents']);
820 $old = zjmf_product_cycles($product);
821 $save = [];
822 foreach ($overrides as $cycle => $val) {
823 $cycle = trim((string)$cycle);
824 if ($cycle === '' || !is_numeric($val)) {
825 continue;
826 }
827 $override = max(0, (int)round((float)$val * 100));
828 $entry = $old[$cycle] ?? [];
829 $save[$cycle] = [
830 'cycle' => $cycle,
831 'name' => (string)($entry['name'] ?? $known[$cycle]['name'] ?? $cycle),
832 'agent_price_cents' => (int)($entry['agent_price_cents'] ?? $agentCents),
833 'price_cents' => $override,
834 'override' => $override,
835 ];
836 }
837 if ($save === []) {
838 json_exit_error('周期售价参数无效');
839 }
840 $ok = $DB->query_prepare(
841 "UPDATE MN_plugin_zjmf_product SET cycles=?, updated_at=? WHERE id=?",
842 [json_encode(array_values($save), JSON_UNESCAPED_UNICODE), $now, $id]
843 );
844 if (!$ok) {
845 json_exit_error('保存失败');
846 }
847 json_exit_success('周期售价已保存');
848});
849
850// 切换商品上架/下架
851mnbt_register_ajax('admin', 'p_zjmf_admin_toggle_product', function () {
852 mnbt_plugin_require_admin();
853
854 $id = (int)($_POST['id'] ?? 0);
855 $product = zjmf_product_get($id);
856 if (!$product) {
857 json_exit_error('商品不存在');
858 }
859
860 global $DB, $date;
861 $now = $date ?: date('Y-m-d H:i:s');
862 $newStatus = $product['status'] == 1 ? 0 : 1;
863 $DB->query_prepare(
864 "UPDATE MN_plugin_zjmf_product SET status=?, updated_at=? WHERE id=?",
865 [$newStatus, $now, $id]
866 );
867 json_exit_success($newStatus == 1 ? '已上架' : '已下架');
868});
869
870/* ============================================================
871 * 用户端页面路由
872 * ============================================================ */
873
874// 商品列表(对游客开放,无需登录)
875mnbt_register_route('GET', '/reserve/shop', function ($params, $ctx) {
876 zjmf_render('shop', [
877 'page_title' => '商品选购',
878 'products' => zjmf_product_list_active(),
879 ]);
880});
881
882// 公开商品列表 API(供官网首页/游客展示,无需登录)
883mnbt_register_route('GET', '/reserve/api/public_products', function ($params, $ctx) {
884 $list = [];
885 foreach (zjmf_product_list_active() as $p) {
886 $min = 0;
887 foreach (zjmf_product_cycles($p) as $cfg) {
888 $c = (int)$cfg['price_cents'];
889 if ($c > 0 && ($min === 0 || $c < $min)) {
890 $min = $c;
891 }
892 }
893 $list[] = [
894 'id' => (int)$p['id'],
895 'name' => (string)$p['name'],
896 'description' => zjmf_render_description((string)($p['description'] ?? '')),
897 'currency' => (string)($p['currency'] ?? ''),
898 'min_price_cents'=> $min,
899 ];
900 }
901 zjmf_json('ok', ['list' => $list]);
902});
903
904// 下单页(选周期 + 支付方式)
905mnbt_register_route('GET', '/reserve/product/{product_id}', function ($params, $ctx) {
906 zjmf_require_user();
907 $product_id = (int)($params['product_id'] ?? 0);
908 $product = zjmf_product_get($product_id);
909 if (!$product || (int)$product['status'] !== 1) {
910 http_response_code(404);
911 echo '商品不存在或已下架';
912 return;
913 }
914 if (!zjmf_supplier_usable((int)$product['supplier_id'])) {
915 http_response_code(404);
916 echo '该商品已暂停销售';
917 return;
918 }
919 $methods = function_exists('mnbt_get_enabled_payment_methods')
920 ? mnbt_get_enabled_payment_methods() : [];
921
922 zjmf_render('order', [
923 'page_title' => '购买:' . $product['name'],
924 'product' => $product,
925 'methods' => $methods,
926 ]);
927});
928
929/* ============================================================
930 * 用户端 API 路由
931 * ============================================================ */
932
933// 创建购买订单 → 生成 MN_dd → 分发支付网关
934mnbt_register_route('POST', '/reserve/api/create_order', function ($params, $ctx) {
935 global $DB, $date, $siteurl;
936
937 $user = zjmf_require_user();
938 $user_id = (int)$user['id'];
939
940 $product_id = (int)($_POST['product_id'] ?? 0);
941 $cycle = isset($_POST['cycle']) ? trim($_POST['cycle']) : '';
942 $type = isset($_POST['type']) ? trim($_POST['type']) : '';
943
944 // 校验商品与周期
945 $product = zjmf_product_get($product_id);
946 if (!$product || (int)$product['status'] !== 1) {
947 zjmf_json('商品不存在或已下架');
948 }
949 if (!zjmf_supplier_usable((int)$product['supplier_id'])) {
950 zjmf_json('该商品已暂停销售,无法下单');
951 }
952 $cycles = zjmf_product_cycles($product);
953 if (!isset($cycles[$cycle])) {
954 zjmf_json('请选择有效的购买周期');
955 }
956 $cycleCfg = $cycles[$cycle];
957 $amount_cents = (int)$cycleCfg['price_cents'];
958 if ($amount_cents <= 0) {
959 zjmf_json('该商品此周期价格异常');
960 }
961 // 校验支付方式
962 if (!function_exists('mnbt_pay_parse_type') || !mnbt_pay_parse_type($type)) {
963 zjmf_json('请选择有效的支付方式');
964 }
965
966 // 创建本地业务订单
967 $create = zjmf_order_create($user, $product, $cycle, $cycleCfg, 'buy', [
968 'cost_cents' => (int)$product['agent_price_cents'],
969 ]);
970 if (empty($create['ok'])) {
971 zjmf_json($create['msg'] ?? '创建订单失败');
972 }
973 $order_no = $create['order_no'];
974 $order_id = (int)$create['order_id'];
975
976 // 创建 MN_dd 支付订单(lx=zjmf,qk=false 待支付)
977 $amount_yuan = (string)round($amount_cents / 100, 2);
978 $cs = json_encode([
979 'user_id' => $user_id,
980 'order_id' => $order_id,
981 'amount' => $amount_cents,
982 'username' => $user['username'],
983 ], 256);
984 $ip = $_SERVER["REMOTE_ADDR"] ?? '127.0.0.1';
985
986 $lastRow = $DB->get_row_prepare("SELECT id FROM MN_dd ORDER BY id DESC LIMIT 1");
987 $dd_id = $lastRow ? ((int)$lastRow['id'] + 1) : 1;
988 $ok = $DB->query_prepare(
989 "INSERT INTO MN_dd (id, cs, date, zffs, je, ddh, lx, qk, ip)
990 VALUES (?,?,?,?,?,?,?,?,?)",
991 [$dd_id, $cs, $date, $type, $amount_yuan, $order_no, ZJMF_LX, 'false', $ip]
992 );
993 if (!$ok) {
994 zjmf_order_set_status($order_id, 'cancelled', '支付订单创建失败');
995 zjmf_json('支付订单创建失败,请稍后重试');
996 }
997
998 // 分发到支付网关(余额插件 / 其他支付插件)
999 $order_context = [
1000 'out_trade_no' => $order_no,
1001 'name' => '购买商品:' . $product['name'] . '(' . $cycleCfg['name'] . ')',
1002 'money' => $amount_yuan,
1003 'type' => $type,
1004 'siteurl' => $siteurl,
1005 'pay_lx' => ZJMF_LX,
1006 ];
1007 $html = mnbt_pay_dispatch_gateway($type, $order_context);
1008 if ($html === false) {
1009 zjmf_order_set_status($order_id, 'cancelled', '支付方式不可用');
1010 zjmf_json('支付方式不可用,请检查支付插件是否已启用');
1011 }
1012
1013 zjmf_json('ok', [
1014 'html' => $html,
1015 'order_no' => $order_no,
1016 'msg' => '正在跳转到支付页面',
1017 ]);
1018});
1019
1020/* ============================================================
1021 * 用户端 API 路由(SPA 数据接口)
1022 * ============================================================ */
1023
1024// 上架商品列表(含各周期价格,游客可访问,供 account SPA 商城页)
1025mnbt_register_route('GET', '/reserve/api/products', function ($params, $ctx) {
1026 $list = [];
1027 foreach (zjmf_product_list_active() as $p) {
1028 $cycles = [];
1029 foreach (zjmf_product_cycles($p) as $key => $cfg) {
1030 $cycles[] = [
1031 'key' => $key,
1032 'name' => $cfg['name'],
1033 'price_cents' => $cfg['price_cents'],
1034 ];
1035 }
1036 $list[] = [
1037 'id' => (int)$p['id'],
1038 'name' => (string)$p['name'],
1039 'description' => zjmf_render_description((string)($p['description'] ?? '')),
1040 'currency' => (string)($p['currency'] ?? ''),
1041 'cycles' => $cycles,
1042 ];
1043 }
1044 zjmf_json('ok', ['list' => $list]);
1045});
1046
1047// 可用支付方式(account SPA 下单页用)
1048mnbt_register_route('GET', '/reserve/api/methods', function ($params, $ctx) {
1049 $methods = function_exists('mnbt_get_enabled_payment_methods')
1050 ? mnbt_get_enabled_payment_methods() : [];
1051 zjmf_json('ok', ['methods' => $methods]);
1052});
1053
1054// 我的主机(account SPA,脱敏后返回)
1055mnbt_register_route('GET', '/reserve/api/hosts', function ($params, $ctx) {
1056 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
1057 if (!$user) {
1058 zjmf_json('not_login', ['logged_in' => false]);
1059 return;
1060 }
1061 $hosts = [];
1062 foreach (zjmf_host_list_by_user((int)$user['id']) as $h) {
1063 // 注:缺失上游主机 ID 时的回填已收敛到开通流程内/管理端,
1064 // 用户端 GET 请求不再触发任何自动写库
1065 $supplier = zjmf_supplier_get((int)$h['supplier_id']);
1066 $hosts[] = [
1067 'id' => (int)$h['id'],
1068 'up_host_id' => (int)$h['up_host_id'],
1069 'up_product_id' => (int)$h['up_product_id'],
1070 'name' => (string)$h['name'],
1071 'username' => (string)$h['username'],
1072 'cycle' => (string)$h['cycle'],
1073 'status' => (string)$h['status'],
1074 // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
1075 'renew_date' => zjmf_normalize_date((string)$h['renew_date']),
1076 'created_at' => (string)$h['created_at'],
1077 'updated_at' => (string)$h['updated_at'],
1078 'supplier_name' => (string)($supplier['name'] ?? ''),
1079 ];
1080 }
1081 zjmf_json('ok', ['logged_in' => true, 'hosts' => $hosts]);
1082});
1083
1084// 我的订单(account SPA,分页)
1085mnbt_register_route('GET', '/reserve/api/orders', function ($params, $ctx) {
1086 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
1087 if (!$user) {
1088 zjmf_json('not_login', ['logged_in' => false]);
1089 return;
1090 }
1091 $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
1092 $per = isset($_GET['per_page']) ? max(1, min(100, (int)$_GET['per_page'])) : 15;
1093 $orders = zjmf_order_list_by_user((int)$user['id'], $page, $per);
1094 zjmf_json('ok', ['logged_in' => true, 'orders' => $orders]);
1095});
1096
1097// 我的订单
1098mnbt_register_route('GET', '/reserve/orders', function ($params, $ctx) {
1099 $user = zjmf_require_user();
1100 $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
1101 $orders = zjmf_order_list_by_user((int)$user['id'], $page, 15);
1102
1103 zjmf_render('orders', [
1104 'page_title' => '我的订单',
1105 'orders' => $orders,
1106 ]);
1107});
1108
1109// 我的主机列表
1110mnbt_register_route('GET', '/reserve/hosts', function ($params, $ctx) {
1111 $user = zjmf_require_user();
1112 $hosts = zjmf_host_list_by_user((int)$user['id']);
1113 // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
1114 // (回填上游主机 ID 已收敛到开通流程内/管理端,GET 请求不写库)
1115 foreach ($hosts as $i => $h) {
1116 $hosts[$i]['renew_date'] = zjmf_normalize_date((string)$h['renew_date']);
1117 }
1118 zjmf_render('hosts', [
1119 'page_title' => '我的主机',
1120 'hosts' => $hosts,
1121 ]);
1122});
1123
1124// 主机详情(实时状态 + 流量 + 操作)
1125mnbt_register_route('GET', '/reserve/hosts/{host_id}', function ($params, $ctx) {
1126 $user = zjmf_require_user();
1127 $host_id = (int)($params['host_id'] ?? 0);
1128 $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
1129 if (!$host) {
1130 http_response_code(404);
1131 echo '主机不存在';
1132 return;
1133 }
1134
1135 // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
1136 // (回填上游主机 ID 已收敛到开通流程内/管理端,GET 请求不写库)
1137 $host['renew_date'] = zjmf_normalize_date((string)$host['renew_date']);
1138
1139 // 实时信息(失败不致命,仅展示缓存)
1140 $info = ['ok' => false, 'msg' => ''];
1141 $traffic = ['ok' => false, 'data' => []];
1142 $dcim = ['ok' => false, 'data' => []];
1143 $osList = [];
1144 $osGroups = [];
1145 $osError = '';
1146 $supplier = zjmf_supplier_get((int)$host['supplier_id']);
1147 if ((int)$host['up_host_id'] > 0 && $supplier) {
1148 try {
1149 $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
1150 $traffic = ZjmfUpstream::hostTraffic($supplier, (int)$host['up_host_id']);
1151 // DCIM 信息(交换机端口/电源状态/重装次数/任务进度),非 DCIM 产品为空,失败不致命
1152 $dcim = ZjmfUpstream::hostDcimInfo($supplier, (int)$host['up_host_id']);
1153 // 重装系统列表(GET host/dedicatedserver?host_id=,实测可返回 cloud_os),
1154 // 失败回退 host/product 的 dcim.os
1155 $osList = is_array($info['dcim']['os'] ?? null) ? $info['dcim']['os'] : [];
1156 $osGroups = [];
1157 $osError = '';
1158 $co = ZjmfUpstream::hostDedicatedOs($supplier, (int)$host['up_host_id']);
1159 if (!empty($co['ok']) && $co['os_list'] !== []) {
1160 $osList = $co['os_list'];
1161 $osGroups = $co['groups'];
1162 } else {
1163 $osError = (string)($co['msg'] ?? '获取系统列表失败');
1164 }
1165 // 详情端点取不到状态时,从 host/list(含 domainstatus)按 ID 匹配兜底
1166 if (($info['status'] ?? '') === 'unknown') {
1167 $lr = ZjmfUpstream::hostList($supplier, ['limit' => 100]);
1168 if (!empty($lr['ok'])) {
1169 foreach (($lr['data']['list'] ?? []) as $it) {
1170 if ((int)($it['id'] ?? 0) === (int)$host['up_host_id']) {
1171 $mapped = zjmf_map_upstream_status((string)($it['domainstatus'] ?? ''));
1172 if ($mapped !== 'unknown') {
1173 $info['status'] = $mapped;
1174 $info['data'] = is_array($info['data']) ? $info['data'] : [];
1175 $info['data'] = array_merge($info['data'], $it);
1176 }
1177 break;
1178 }
1179 }
1180 }
1181 }
1182 // 拉取成功后同步缓存状态(unknown 不上写,避免误覆盖)
1183 if (!empty($info['ok']) && $info['status'] !== 'unknown' && $info['status'] !== $host['status']) {
1184 zjmf_host_update_cache((int)$host['id'], ['status' => $info['status']]);
1185 $host['status'] = $info['status'];
1186 }
1187 } catch (Throwable $e) {
1188 error_log('[zjmfmanager_reserve] host detail upstream: ' . $e->getMessage());
1189 }
1190 }
1191
1192 zjmf_render('host', [
1193 'page_title' => '主机详情:' . $host['name'],
1194 'host' => $host,
1195 'info' => $info,
1196 'traffic' => $traffic,
1197 'dcim' => $dcim,
1198 'config_options'=> is_array($info['config_options'] ?? null) ? $info['config_options'] : [],
1199 'custom_fields' => is_array($info['custom_fields'] ?? null) ? $info['custom_fields'] : [],
1200 'os_list' => $osList,
1201 'os_groups' => $osGroups,
1202 'os_error' => $osError,
1203 ]);
1204});
1205
1206// 主机操作(开机/关机/重启/重置密码/重装)
1207mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx) {
1208 $user = zjmf_require_user();
1209
1210 $host_id = (int)($_POST['host_id'] ?? 0);
1211 $action = isset($_POST['action']) ? trim($_POST['action']) : '';
1212
1213 $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
1214 if (!$host) {
1215 zjmf_json('主机不存在');
1216 }
1217 if ((int)$host['up_host_id'] <= 0) {
1218 zjmf_json('该主机缺少上游主机 ID,无法执行操作');
1219 }
1220 if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
1221 zjmf_json('服务已停用,无法执行操作');
1222 }
1223 $supplier = zjmf_supplier_get((int)$host['supplier_id']);
1224
1225 // DCIM 专用操作(救援系统/重置BMC/取消任务)
1226 $dcimActions = ['rescue', 'bmc', 'cancel_task'];
1227 if (in_array($action, $dcimActions, true)) {
1228 $extra = [];
1229 $apiAction = $action;
1230 if ($action === 'rescue') {
1231 $system = (int)($_POST['system'] ?? 0);
1232 if ($system !== 1 && $system !== 2) {
1233 zjmf_json('请选择救援系统(1 Linux / 2 Windows)');
1234 }
1235 $extra['system'] = $system;
1236 }
1237 $result = ZjmfUpstream::hostDcimAction($supplier, (int)$host['up_host_id'], $apiAction, $extra);
1238 $hostOrder = zjmf_order_get((int)$host['order_id']);
1239 $orderNo = $hostOrder ? $hostOrder['order_no'] : '';
1240 zjmf_log((int)$user['id'], $orderNo, 'host_action:' . $action,
1241 empty($result['ok']) ? 'failed' : 'success', $result['msg'] ?? '',
1242 (int)$host['supplier_id']);
1243 if (empty($result['ok'])) {
1244 zjmf_json($result['msg'] ?? '操作失败');
1245 }
1246 zjmf_json('ok', ['msg' => '操作成功']);
1247 }
1248
1249 // 重装系统:POST /provision/default (func=reinstall),只需 os 与 os_group,无需密码/端口
1250 if ($action === 'dcim_reinstall') {
1251 $os = (int)($_POST['os'] ?? 0);
1252 if ($os <= 0) {
1253 zjmf_json('请选择操作系统');
1254 }
1255 $extra = ['os' => $os, 'code' => 0];
1256 $osGroup = trim((string)($_POST['os_group'] ?? ''));
1257 if ($osGroup !== '') {
1258 $extra['os_group'] = $osGroup;
1259 }
1260 $result = ZjmfUpstream::hostAction($supplier, (int)$host['up_host_id'], 'reinstall', $extra);
1261 $hostOrder = zjmf_order_get((int)$host['order_id']);
1262 $orderNo = $hostOrder ? $hostOrder['order_no'] : '';
1263 zjmf_log((int)$user['id'], $orderNo, 'host_action:dcim_reinstall',
1264 empty($result['ok']) ? 'failed' : 'success', $result['msg'] ?? '',
1265 (int)$host['supplier_id']);
1266 if (empty($result['ok'])) {
1267 zjmf_json($result['msg'] ?? '操作失败');
1268 }
1269 zjmf_json('ok', ['msg' => '操作成功']);
1270 }
1271
1272 $func = zjmf_action_func($action);
1273 if ($func === '') {
1274 zjmf_json('不支持的操作');
1275 }
1276
1277 // 重置密码需要新密码
1278 $extra = [];
1279 if ($action === 'reset_password') {
1280 $password = trim((string)($_POST['password'] ?? ''));
1281 if ($password === '') {
1282 zjmf_json('请输入新密码');
1283 }
1284 $extra['password'] = $password;
1285 }
1286
1287 $result = ZjmfUpstream::hostAction($supplier, (int)$host['up_host_id'], $func, $extra);
1288 $hostOrder = zjmf_order_get((int)$host['order_id']);
1289 $orderNo = $hostOrder ? $hostOrder['order_no'] : '';
1290 zjmf_log((int)$user['id'], $orderNo, 'host_action:' . $action,
1291 empty($result['ok']) ? 'failed' : 'success', $result['msg'] ?? '',
1292 (int)$host['supplier_id']);
1293
1294 if (empty($result['ok'])) {
1295 zjmf_json($result['msg'] ?? '操作失败');
1296 }
1297
1298 // 操作成功:更新缓存状态与密码
1299 $status = zjmf_action_status($action);
1300 if ($status !== '') {
1301 zjmf_host_update_cache((int)$host['id'], ['status' => $status]);
1302 }
1303 // 电源类操作后主动刷新上游真实状态回写:本地主机表无独立电源字段,
1304 // 关机不写 suspend(避免缓存状态与上游 domainstatus 脱节),以上游为准
1305 if (in_array($action, ['on', 'off', 'reboot'], true)) {
1306 $fresh = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
1307 if (!empty($fresh['ok']) && $fresh['status'] !== 'unknown'
1308 && $fresh['status'] !== $host['status']) {
1309 zjmf_host_update_cache((int)$host['id'], ['status' => $fresh['status']]);
1310 }
1311 }
1312 if ($action === 'reset_password' && $extra['password'] !== '') {
1313 global $DB, $date;
1314 $now = $date ?: date('Y-m-d H:i:s');
1315 $DB->query_prepare(
1316 "UPDATE MN_plugin_zjmf_host SET password=?, updated_at=? WHERE id=?",
1317 [zjmf_encrypt($extra['password']), $now, (int)$host['id']]
1318 );
1319 }
1320
1321 zjmf_json('ok', ['msg' => '操作成功']);
1322});
1323
1324// 主机密码查看:详情页默认仅展示掩码,用户点击"显示密码"后经本路由
1325// 按需解密返回(每次查看记录操作日志),页面不再无条件渲染明文密码
1326mnbt_register_route('POST', '/reserve/api/host_password', function ($params, $ctx) {
1327 $user = zjmf_require_user();
1328
1329 $host_id = (int)($_POST['host_id'] ?? 0);
1330 $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
1331 if (!$host) {
1332 zjmf_json('主机不存在');
1333 }
1334
1335 $password = zjmf_decrypt((string)$host['password']);
1336 $source = 'local';
1337 // 本地未存密码(历史数据)且可查上游时,回退拉取上游实时密码
1338 if ($password === '' && (int)$host['up_host_id'] > 0) {
1339 $supplier = zjmf_supplier_get((int)$host['supplier_id']);
1340 if ($supplier) {
1341 try {
1342 $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
1343 if (!empty($info['ok'])) {
1344 $password = (string)($info['data']['password'] ?? '');
1345 $source = 'upstream';
1346 }
1347 } catch (Throwable $e) {
1348 // 上游查询失败按无密码处理
1349 }
1350 }
1351 }
1352
1353 $hostOrder = zjmf_order_get((int)$host['order_id']);
1354 zjmf_log((int)$user['id'], $hostOrder ? $hostOrder['order_no'] : '',
1355 'view_password', 'success',
1356 json_encode(['host_id' => (int)$host['id'], 'source' => $source], JSON_UNESCAPED_UNICODE),
1357 (int)$host['supplier_id']);
1358
1359 zjmf_json('ok', ['password' => $password]);
1360});
1361
1362/* ============================================================
1363 * 升级(配置升级 / 产品升降级)
1364 * ============================================================ */
1365
1366// 升级页(kind=config|product)
1367mnbt_register_route('GET', '/reserve/hosts/{host_id}/upgrade', function ($params, $ctx) {
1368 $user = zjmf_require_user();
1369 $host_id = (int)($params['host_id'] ?? 0);
1370 $kind = ($_GET['kind'] ?? 'config') === 'product' ? 'product' : 'config';
1371
1372 $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
1373 if (!$host) {
1374 http_response_code(404);
1375 echo '主机不存在';
1376 return;
1377 }
1378 if ((int)$host['up_host_id'] <= 0) {
1379 echo '该主机缺少上游主机 ID,无法升级';
1380 return;
1381 }
1382 if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
1383 echo '服务已停用,无法升级';
1384 return;
1385 }
1386 $supplier = zjmf_supplier_get((int)$host['supplier_id']);
1387
1388 $options = ZjmfUpstream::upgradeOptions($supplier, (int)$host['up_host_id'], $kind);
1389
1390 zjmf_render('upgrade', [
1391 'page_title' => '升级:' . $host['name'],
1392 'host' => $host,
1393 'kind' => $kind,
1394 'options' => $options,
1395 ]);
1396});
1397
1398// 升级接口(preview=1 试算差额;preview=0 确认扣款并提交)
1399mnbt_register_route('POST', '/reserve/api/upgrade', function ($params, $ctx) {
1400 $user = zjmf_require_user();
1401
1402 $host_id = (int)($_POST['host_id'] ?? 0);
1403 $kind = ($_POST['kind'] ?? 'config') === 'product' ? 'product' : 'config';
1404 $isPreview = (($_POST['preview'] ?? '') === '1');
1405
1406 $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
1407 if (!$host) {
1408 zjmf_json('主机不存在');
1409 }
1410 if ((int)$host['up_host_id'] <= 0) {
1411 zjmf_json('该主机缺少上游主机 ID,无法升级');
1412 }
1413 if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
1414 zjmf_json('服务已停用,无法升级');
1415 }
1416 $supplier = zjmf_supplier_get((int)$host['supplier_id']);
1417
1418 // 构造升级选择参数
1419 if ($kind === 'config') {
1420 $raw = (string)($_POST['config_json'] ?? '');
1421 $decoded = json_decode($raw, true);
1422 if (!is_array($decoded) || $decoded === []) {
1423 zjmf_json('请选择配置项');
1424 }
1425 $selection = ['configoption' => $decoded];
1426 } else {
1427 $newpid = (int)($_POST['newpid'] ?? 0);
1428 $billingcycle = trim((string)($_POST['billingcycle'] ?? ''));
1429 if ($newpid <= 0 || $billingcycle === '') {
1430 zjmf_json('请选择目标产品与周期');
1431 }
1432 $selection = ['newpid' => $newpid, 'billingcycle' => $billingcycle];
1433 }
1434
1435 // 试算差额
1436 $preview = ZjmfUpstream::upgradePreview($supplier, $kind, (int)$host['up_host_id'], $selection);
1437 if (empty($preview['ok'])) {
1438 zjmf_json($preview['msg'] ?? '试算失败');
1439 }
1440 $amount_cents = (int)($preview['price_cents'] ?? 0);
1441 if ($isPreview) {
1442 zjmf_json('ok', [
1443 'price_cents' => $amount_cents,
1444 'price' => zjmf_format_cents($amount_cents),
1445 ]);
1446 }
1447
1448 // 防重复:同一主机存在未完成升级单则拦截
1449 global $DB;
1450 $dup = $DB->get_row_prepare(
1451 "SELECT id FROM MN_plugin_zjmf_order
1452 WHERE host_id=? AND action=? AND status IN ('pending','paid')
1453 LIMIT 1",
1454 [(int)$host['id'], 'upgrade_' . $kind]
1455 );
1456 if ($dup) {
1457 zjmf_json('该主机已有未完成的升级订单');
1458 }
1459
1460 $action = 'upgrade_' . $kind;
1461 $orderParams = json_encode($selection, JSON_UNESCAPED_UNICODE);
1462
1463 // 创建升级订单
1464 $create = zjmf_upgrade_order_create($user, $host, $action, $amount_cents, $orderParams);
1465 if (empty($create['ok'])) {
1466 zjmf_json($create['msg'] ?? '创建升级订单失败');
1467 }
1468 $order_id = (int)$create['order_id'];
1469 $order_no = $create['order_no'];
1470
1471 // 扣余额(差额为 0 时跳过)
1472 if ($amount_cents > 0) {
1473 $deducted = function_exists('balance_deduct')
1474 && balance_deduct((int)$user['id'], $amount_cents, 'consume',
1475 $order_no, '升级扣款');
1476 if (!$deducted) {
1477 zjmf_order_set_status($order_id, 'failed', '余额不足');
1478 zjmf_json('余额不足,无法完成升级');
1479 }
1480 }
1481
1482 // 提交上游
1483 $submit = ZjmfUpstream::upgradeSubmit($supplier, $kind, (int)$host['up_host_id'], $selection);
1484 if (empty($submit['ok'])) {
1485 $msg = (string)($submit['msg'] ?? '升级提交失败');
1486 zjmf_order_set_status($order_id, 'failed', $msg);
1487 // 原路退回余额
1488 if ($amount_cents > 0 && function_exists('balance_add')) {
1489 balance_add((int)$user['id'], $amount_cents, 'refund',
1490 $order_no, '升级失败自动退款');
1491 }
1492 zjmf_log((int)$user['id'], $order_no, $action, 'failed',
1493 json_encode(['msg' => $msg], JSON_UNESCAPED_UNICODE),
1494 (int)$host['supplier_id']);
1495 zjmf_json($msg);
1496 }
1497
1498 // 升级成功:回填订单 + 更新主机缓存
1499 zjmf_order_fill_opened($order_id, (int)($submit['up_order_id'] ?? 0),
1500 (int)$host['up_host_id'], (string)$host['username']);
1501 zjmf_order_set_status($order_id, 'opened', '升级完成');
1502
1503 $cache = [];
1504 if ($kind === 'product') {
1505 $cache['up_product_id'] = (int)($_POST['newpid'] ?? 0);
1506 }
1507 $cache['cycle'] = (string)($_POST['billingcycle'] ?? $host['cycle']);
1508 $cache['renew_date'] = zjmf_normalize_date(
1509 (string)($preview['data']['renew_date'] ?? '')
1510 );
1511 zjmf_host_update_cache((int)$host['id'], $cache);
1512
1513 zjmf_log((int)$user['id'], $order_no, $action, 'success',
1514 json_encode(['amount_cents' => $amount_cents, 'selection' => $selection],
1515 JSON_UNESCAPED_UNICODE),
1516 (int)$host['supplier_id']);
1517
1518 zjmf_json('ok', ['msg' => '升级成功']);
1519});
1520
1521/* ============================================================
1522 * 管理员端 - 主机操作 AJAX
1523 * ============================================================ */
1524
1525// 刷新主机状态(管理员)
1526mnbt_register_ajax('admin', 'p_zjmf_admin_fetch_host', function () {
1527 mnbt_plugin_require_admin();
1528
1529 $id = (int)($_POST['id'] ?? 0);
1530 $host = zjmf_host_get($id);
1531 if (!$host) {
1532 json_exit_error('主机不存在');
1533 }
1534 if ((int)$host['up_host_id'] <= 0) {
1535 json_exit_error('该主机缺少上游主机 ID');
1536 }
1537 $supplier = zjmf_supplier_get((int)$host['supplier_id']);
1538 if (!$supplier) {
1539 json_exit_error('主机所属供应商不存在');
1540 }
1541 $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
1542 if (empty($info['ok'])) {
1543 json_exit_error($info['msg'] ?? '查询失败');
1544 }
1545 $cache = [];
1546 if ($info['status'] !== 'unknown') {
1547 $cache['status'] = $info['status'];
1548 }
1549 if (isset($info['data']['nextduedate']) || isset($info['data']['renewdate']) || isset($info['data']['renew_date'])) {
1550 $cache['renew_date'] = zjmf_normalize_date(
1551 (string)($info['data']['nextduedate'] ?? $info['data']['renew_date'] ?? $info['data']['renewdate'] ?? '')
1552 );
1553 }
1554 if ($cache) {
1555 zjmf_host_update_cache($id, $cache);
1556 }
1557 json_exit_success('已刷新', ['status' => $info['status']]);
1558});
1559
1560// 全部刷新主机状态(管理员):拉取 host/list 全量按 ID 匹配批量更新,
1561// 修复存量 unknown/过期缓存(比逐台 hostInfo 快且可靠)
1562mnbt_register_ajax('admin', 'p_zjmf_admin_fetch_all_hosts', function () {
1563 mnbt_plugin_require_admin();
1564
1565 // 拉取本地全部主机
1566 $hosts = [];
1567 $page = 1;
1568 $per = 200;
1569 while (true) {
1570 $res = zjmf_host_list_all($page, $per);
1571 $list = $res['list'] ?? [];
1572 if ($list === []) {
1573 break;
1574 }
1575 foreach ($list as $h) {
1576 $hosts[] = $h;
1577 }
1578 if ($page * $per >= (int)($res['total'] ?? 0)) {
1579 break;
1580 }
1581 $page++;
1582 }
1583
1584 // 按供应商拉取上游主机列表(分页全量)
1585 $supplierIds = [];
1586 foreach ($hosts as $h) {
1587 $sid = (int)$h['supplier_id'];
1588 if ($sid > 0) {
1589 $supplierIds[$sid] = true;
1590 }
1591 }
1592 $upMap = [];
1593 foreach (array_keys($supplierIds) as $sid) {
1594 $supplier = zjmf_supplier_get($sid);
1595 if (!$supplier) {
1596 continue;
1597 }
1598 $items = [];
1599 $sum = 0;
1600 $maxPage = 1;
1601 $p = 1;
1602 $safety = 30;
1603 while ($p <= $maxPage && $safety-- > 0) {
1604 $r = ZjmfUpstream::hostList($supplier, ['page' => $p, 'limit' => 100]);
1605 if (empty($r['ok'])) {
1606 break;
1607 }
1608 $d = $r['data'] ?? [];
1609 foreach (($d['list'] ?? []) as $it) {
1610 $items[(int)($it['id'] ?? 0)] = $it;
1611 }
1612 $sum = (int)($d['sum'] ?? count($items));
1613 $maxPage = (int)($d['max_page'] ?? 1);
1614 if ($maxPage <= 0) {
1615 $maxPage = 1;
1616 }
1617 if ($sum > 0 && count($items) >= $sum) {
1618 break;
1619 }
1620 $p++;
1621 }
1622 $upMap[$sid] = $items;
1623 }
1624
1625 $stat = ['total' => count($hosts), 'ok' => 0, 'fail' => 0, 'no_up' => 0,
1626 'missing' => 0, 'unknown' => 0, 'changed' => 0];
1627 foreach ($hosts as $host) {
1628 $upId = (int)$host['up_host_id'];
1629 if ($upId <= 0) {
1630 $stat['no_up']++;
1631 continue;
1632 }
1633 $item = $upMap[(int)$host['supplier_id']][$upId] ?? null;
1634 if (!$item) {
1635 $stat['missing']++;
1636 continue;
1637 }
1638 $status = zjmf_map_upstream_status((string)($item['domainstatus'] ?? ''));
1639 $renew = zjmf_normalize_date((string)($item['nextduedate'] ?? ''));
1640 $cache = [];
1641 if ($status !== 'unknown' && $status !== (string)$host['status']) {
1642 $cache['status'] = $status;
1643 $stat['changed']++;
1644 }
1645 if ($renew !== '' && $renew !== (string)$host['renew_date']) {
1646 $cache['renew_date'] = $renew;
1647 }
1648 if ($cache) {
1649 zjmf_host_update_cache((int)$host['id'], $cache);
1650 }
1651 if ($status === 'unknown') {
1652 $stat['unknown']++;
1653 }
1654 $stat['ok']++;
1655 }
1656 json_exit_success('刷新完成', $stat);
1657});