better-staridc-MNBT
1<?php
2/**
3 * docker_shop 插件 - 核心函数库
4 *
5 * 提供:售卖套餐 CRUD、Docker 节点/配额套餐查询、订单管理、资产查询、Docker 账号开通。
6 * 依赖 user_info 插件(认证)、balance 插件(余额扣款)、核心 Docker 表(MN_docker_user/plan/node)。
7 */
8
9if (!defined('IN_CRONLITE')) {
10 exit;
11}
12
13/* ============================================================
14 * 周期 / schema 配置
15 * ============================================================ */
16
17/** 可用购买周期定义(months 用于计算到期时间)。 */
18function docker_shop_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 docker_shop_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 docker_shop_plan_enabled_periods($plan)
46{
47 $periods = docker_shop_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 = docker_shop_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 docker_shop_upgrade_schema()
71{
72 global $DB;
73 if (!isset($DB)) {
74 return;
75 }
76 $cols = $DB->get_all_prepare("SHOW COLUMNS FROM MN_plugin_docker_plan") ?: [];
77 $existing = [];
78 foreach ($cols as $c) {
79 $existing[] = $c['Field'];
80 }
81 $toAdd = [];
82 if (!in_array('category', $existing, true)) {
83 $toAdd[] = "ADD COLUMN `category` varchar(50) NOT NULL DEFAULT '' COMMENT '分类'";
84 }
85 if (!empty($toAdd)) {
86 $DB->query("ALTER TABLE MN_plugin_docker_plan " . implode(', ', $toAdd));
87 }
88}
89
90/* ============================================================
91 * URL / 渲染辅助
92 * ============================================================ */
93
94/** 生成带站点 base path 前缀的 URL。 */
95function docker_shop_url($path = '')
96{
97 $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
98 $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
99 if ($basePath === '.' || $basePath === '/') {
100 $basePath = '';
101 }
102 // 使用查询参数路由(index.php?_r=/path),避免依赖 Web 服务器 rewrite
103 $p = ltrim($path, '/');
104 $qpos = strpos($p, '?');
105 if ($qpos !== false) {
106 $route = substr($p, 0, $qpos);
107 $query = substr($p, $qpos + 1);
108 return $basePath . '/index.php?_r=/' . $route . '&' . $query;
109 }
110 return $basePath . '/index.php?_r=/' . $p;
111}
112
113/** 管理员端插件页面 URL(admin/plugin.php?p=docker_shop&page=xxx)。 */
114function docker_shop_admin_url($page, $extra = '')
115{
116 $base = 'plugin.php?p=docker_shop&page=' . rawurlencode($page);
117 if ($extra !== '') {
118 $base .= '&' . ltrim($extra, '&');
119 }
120 return $base;
121}
122
123/** 金额(分)→ 元(保留 2 位小数)。 */
124function docker_shop_format_cents($cents)
125{
126 return number_format((int)$cents / 100, 2, '.', '');
127}
128
129/** 输出 JSON 并退出(与 user_info/balance 插件一致:{ code, ...extra })。 */
130function docker_shop_json($code, $extra = [])
131{
132 @header('Content-Type: application/json; charset=UTF-8');
133 $payload = ['code' => $code];
134 if (is_array($extra)) {
135 $payload = array_merge($payload, $extra);
136 }
137 echo json_encode($payload, JSON_UNESCAPED_UNICODE);
138 exit;
139}
140
141/** 获取当前登录的 user_info 用户,未登录跳转登录页。 */
142function docker_shop_require_user()
143{
144 if (!function_exists('user_info_auth_current')) {
145 http_response_code(500);
146 echo '需要先启用 user_info 插件';
147 exit;
148 }
149 $user = user_info_auth_current();
150 if (!$user) {
151 header('Location: ' . docker_shop_url('account/login'));
152 exit;
153 }
154 return $user;
155}
156
157/** 渲染用户端视图。 */
158function docker_shop_render($view, $vars = [])
159{
160 $vars['current_user'] = $vars['current_user'] ?? (function_exists('user_info_auth_current') ? user_info_auth_current() : null);
161 extract($vars, EXTR_SKIP);
162 $viewFile = mnbt_plugin_path('docker_shop') . 'views/' . $view . '.php';
163 if (!is_file($viewFile)) {
164 http_response_code(500);
165 echo 'View not found: ' . htmlspecialchars($view);
166 return;
167 }
168 include $viewFile;
169}
170
171/** 渲染管理员端视图。 */
172function docker_shop_render_admin($view, $vars = [])
173{
174 extract($vars, EXTR_SKIP);
175 $viewFile = mnbt_plugin_path('docker_shop') . 'views/admin/' . $view . '.php';
176 if (!is_file($viewFile)) {
177 http_response_code(500);
178 echo 'Admin view not found: ' . htmlspecialchars($view);
179 return;
180 }
181 include $viewFile;
182}
183
184/* ============================================================
185 * 节点 / 配额套餐查询
186 * ============================================================ */
187
188/** 可用 Docker 节点列表(用户端,仅返回 id/名称,不暴露 IP/端口/密钥)。 */
189function docker_shop_node_list()
190{
191 global $DB;
192 return $DB->get_all_prepare("SELECT id, name FROM MN_docker_node WHERE qk='true' ORDER BY id ASC") ?: [];
193}
194
195/** 获取单个 Docker 节点(仅返回启用中的节点,已停用节点视为不存在,防止超卖到停用节点)。 */
196function docker_shop_node_get($node_id)
197{
198 global $DB;
199 return $DB->get_row_prepare("SELECT * FROM MN_docker_node WHERE id=? AND qk='true' LIMIT 1", [(int)$node_id]) ?: null;
200}
201
202/** 全部 Docker 节点列表(管理员端,含完整信息)。 */
203function docker_shop_node_list_all()
204{
205 global $DB;
206 return $DB->get_all_prepare("SELECT * FROM MN_docker_node ORDER BY id ASC") ?: [];
207}
208
209/** 上架中的配额套餐列表(管理员下拉用)。 */
210function docker_shop_base_plan_list()
211{
212 global $DB;
213 return $DB->get_all_prepare("SELECT * FROM MN_docker_plan WHERE qk='true' ORDER BY id ASC") ?: [];
214}
215
216/** 获取单个配额套餐。 */
217function docker_shop_base_plan_get($plan_id)
218{
219 global $DB;
220 return $DB->get_row_prepare("SELECT * FROM MN_docker_plan WHERE id=? LIMIT 1", [(int)$plan_id]) ?: null;
221}
222
223/* ============================================================
224 * 售卖套餐管理
225 * ============================================================ */
226
227/** 获取单个售卖套餐。 */
228function docker_shop_plan_get($plan_id)
229{
230 global $DB;
231 return $DB->get_row_prepare("SELECT * FROM MN_plugin_docker_plan WHERE id=? LIMIT 1", [(int)$plan_id]) ?: null;
232}
233
234/** 获取上架售卖套餐列表(按 sort 升序)。 */
235function docker_shop_plan_list_active()
236{
237 global $DB;
238 return $DB->get_all_prepare("SELECT * FROM MN_plugin_docker_plan WHERE status='active' ORDER BY sort ASC, id ASC") ?: [];
239}
240
241/** 获取全部售卖套餐列表(管理员)。 */
242function docker_shop_plan_list_all()
243{
244 global $DB;
245 return $DB->get_all_prepare("SELECT * FROM MN_plugin_docker_plan ORDER BY sort ASC, id ASC") ?: [];
246}
247
248/** 保存售卖套餐(新增或更新)。 */
249function docker_shop_plan_save($data)
250{
251 global $DB, $date;
252 $now = $date ?: date('Y-m-d H:i:s');
253
254 $periods = docker_shop_periods();
255 $enabled = [];
256 $rawEnabled = isset($data['enabled_periods']) && is_array($data['enabled_periods']) ? $data['enabled_periods'] : [];
257 foreach ($rawEnabled as $p) {
258 if (isset($periods[$p])) {
259 $enabled[] = $p;
260 }
261 }
262
263 $fields = [
264 'name' => trim((string)($data['name'] ?? '')),
265 'description' => trim((string)($data['description'] ?? '')),
266 'category' => trim((string)($data['category'] ?? '')),
267 'node' => max(0, (int)($data['node'] ?? 0)),
268 'base_plan_id' => max(0, (int)($data['base_plan_id'] ?? 0)),
269 'price_month_cents' => max(0, (int)($data['price_month_cents'] ?? 0)),
270 'price_quarter_cents' => max(0, (int)($data['price_quarter_cents'] ?? 0)),
271 'price_half_year_cents' => max(0, (int)($data['price_half_year_cents'] ?? 0)),
272 'price_year_cents' => max(0, (int)($data['price_year_cents'] ?? 0)),
273 'price_two_year_cents' => max(0, (int)($data['price_two_year_cents'] ?? 0)),
274 'price_three_year_cents' => max(0, (int)($data['price_three_year_cents'] ?? 0)),
275 'enabled_periods' => implode(',', $enabled),
276 'status' => ($data['status'] ?? 'active') === 'inactive' ? 'inactive' : 'active',
277 'sort' => max(0, (int)($data['sort'] ?? 50)),
278 'updated_at' => $now,
279 ];
280 if ($fields['name'] === '') {
281 return '套餐名称不能为空';
282 }
283 if ($fields['node'] <= 0 || !docker_shop_node_get($fields['node'])) {
284 return '请选择有效的开通节点';
285 }
286 if ($fields['base_plan_id'] <= 0 || !docker_shop_base_plan_get($fields['base_plan_id'])) {
287 return '请选择有效的配额套餐';
288 }
289 // 至少启用一个有效周期
290 if ($enabled === []) {
291 return '请至少选择一个购买周期';
292 }
293
294 $id = (int)($data['id'] ?? 0);
295 if ($id > 0) {
296 $ok = $DB->query_prepare(
297 "UPDATE MN_plugin_docker_plan SET name=?, description=?, category=?, node=?, base_plan_id=?, 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=?",
298 [$fields['name'], $fields['description'], $fields['category'], $fields['node'], $fields['base_plan_id'], $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]
299 );
300 return $ok ? true : '更新失败';
301 }
302 $fields['created_at'] = $now;
303 $ok = $DB->query_prepare(
304 "INSERT INTO MN_plugin_docker_plan (name, description, category, node, base_plan_id, 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
305 [$fields['name'], $fields['description'], $fields['category'], $fields['node'], $fields['base_plan_id'], $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']]
306 );
307 return $ok ? true : '新增失败';
308}
309
310/** 删除售卖套餐。 */
311function docker_shop_plan_delete($plan_id)
312{
313 global $DB;
314 return (bool)$DB->query_prepare("DELETE FROM MN_plugin_docker_plan WHERE id=? LIMIT 1", [(int)$plan_id]);
315}
316
317/** 构造用户端可用的售卖套餐 JSON(含基准配额信息)。 */
318function docker_shop_plan_to_api($plan)
319{
320 $base = $plan['base_plan_id'] > 0 ? docker_shop_base_plan_get($plan['base_plan_id']) : null;
321 $node = $plan['node'] > 0 ? docker_shop_node_get($plan['node']) : null;
322 return [
323 'id' => (int)$plan['id'],
324 'name' => (string)$plan['name'],
325 'description' => (string)($plan['description'] ?? ''),
326 'category' => (string)($plan['category'] ?? ''),
327 'base_plan' => $base ? [
328 'id' => (int)$base['id'],
329 'name' => (string)$base['name'],
330 'cpu_max' => (string)$base['cpu_max'],
331 'mem_max' => (string)$base['mem_max'],
332 'disk_max' => (string)$base['disk_max'],
333 'proxy_max'=> (string)$base['proxy_max'],
334 ] : null,
335 'node' => $node ? ['id' => (int)$node['id'], 'name' => (string)$node['name']] : null,
336 'periods' => docker_shop_plan_enabled_periods($plan),
337 'prices' => [
338 'month' => (int)($plan['price_month_cents'] ?? 0),
339 'quarter' => (int)($plan['price_quarter_cents'] ?? 0),
340 'half_year' => (int)($plan['price_half_year_cents'] ?? 0),
341 'year' => (int)($plan['price_year_cents'] ?? 0),
342 'two_year' => (int)($plan['price_two_year_cents'] ?? 0),
343 'three_year' => (int)($plan['price_three_year_cents'] ?? 0),
344 ],
345 ];
346}
347
348/* ============================================================
349 * 订单管理
350 * ============================================================ */
351
352/** 按订单号查询订单。 */
353function docker_shop_order_get_by_no($order_no)
354{
355 global $DB;
356 return $DB->get_row_prepare("SELECT * FROM MN_plugin_docker_order WHERE order_no=? LIMIT 1", [$order_no]) ?: null;
357}
358
359/** 按 ID 查询订单。 */
360function docker_shop_order_get($order_id)
361{
362 global $DB;
363 return $DB->get_row_prepare("SELECT * FROM MN_plugin_docker_order WHERE id=? LIMIT 1", [(int)$order_id]) ?: null;
364}
365
366/** 用户的订单列表(分页)。 */
367function docker_shop_order_list_by_user($user_id, $page = 1, $per_page = 20)
368{
369 global $DB;
370 $user_id = (int)$user_id;
371 $page = max(1, (int)$page);
372 $per_page = max(1, min(100, (int)$per_page));
373 $offset = ($page - 1) * $per_page;
374 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_docker_order WHERE user_id=?", [$user_id]);
375 $total = $count_row ? (int)$count_row['cnt'] : 0;
376 $list = $DB->get_all_prepare(
377 "SELECT * FROM MN_plugin_docker_order WHERE user_id=? ORDER BY id DESC LIMIT {$offset},{$per_page}",
378 [$user_id]
379 ) ?: [];
380 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
381}
382
383/** 全部订单列表(管理员,分页 + 简单筛选)。 */
384function docker_shop_order_list_all($page = 1, $per_page = 30, $filters = [])
385{
386 global $DB;
387 $page = max(1, (int)$page);
388 $per_page = max(1, min(200, (int)$per_page));
389 $offset = ($page - 1) * $per_page;
390
391 $where = '1';
392 $params = [];
393 if (!empty($filters['status'])) {
394 $where .= ' AND status=?';
395 $params[] = $filters['status'];
396 }
397 if (!empty($filters['user_id'])) {
398 $where .= ' AND user_id=?';
399 $params[] = (int)$filters['user_id'];
400 }
401 if (!empty($filters['order_no'])) {
402 $where .= ' AND order_no=?';
403 $params[] = $filters['order_no'];
404 }
405
406 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_docker_order WHERE {$where}", $params);
407 $total = $count_row ? (int)$count_row['cnt'] : 0;
408 $list = $DB->get_all_prepare(
409 "SELECT * FROM MN_plugin_docker_order WHERE {$where} ORDER BY id DESC LIMIT {$offset},{$per_page}",
410 $params
411 ) ?: [];
412 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
413}
414
415/**
416 * 创建购买订单(未支付)。
417 *
418 * @param array $user user_info 当前用户
419 * @param array $plan 售卖套餐行
420 * @param string $period 购买周期
421 * @return array ['ok'=>bool, 'order_no'=>string, 'order_id'=>int, 'amount_cents'=>int, 'msg'=>string]
422 */
423function docker_shop_order_create($user, $plan, $period)
424{
425 global $DB, $date;
426 $periods = docker_shop_periods();
427 if (!isset($periods[$period])) {
428 return ['ok' => false, 'msg' => '无效的购买周期'];
429 }
430 $enabled = docker_shop_plan_enabled_periods($plan);
431 if (!in_array($period, $enabled, true)) {
432 return ['ok' => false, 'msg' => '该套餐不支持此购买周期'];
433 }
434 $field = docker_shop_period_price_field($period);
435 $amount_cents = $field ? (int)($plan[$field] ?? 0) : 0;
436 if ($amount_cents < 0) {
437 return ['ok' => false, 'msg' => '该套餐此周期价格异常'];
438 }
439 $node = (int)($plan['node'] ?? 0);
440 $base_plan_id = (int)($plan['base_plan_id'] ?? 0);
441 if ($node <= 0 || !docker_shop_node_get($node)) {
442 return ['ok' => false, 'msg' => '套餐未配置有效开通节点'];
443 }
444 // 配额套餐必须处于上架状态(MN_docker_plan.qk='true'),下架套餐不可下单
445 $base_plan = $base_plan_id > 0 ? docker_shop_base_plan_get($base_plan_id) : null;
446 if (!$base_plan || $base_plan['qk'] !== 'true') {
447 return ['ok' => false, 'msg' => '套餐未配置有效配额套餐或配额套餐已下架'];
448 }
449 $now = $date ?: date('Y-m-d H:i:s');
450 $order_no = date("YmdHis") . mt_rand(1000, 9999);
451 $ok = $DB->query_prepare(
452 "INSERT INTO MN_plugin_docker_order (user_id, plan_id, plan_name, period, amount_cents, order_no, node, docker_user_id, status, remark, created_at, paid_at, opened_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
453 [(int)$user['id'], (int)$plan['id'], $plan['name'], $period, $amount_cents, $order_no, $node, 0, 'pending', '', $now, '', '']
454 );
455 if (!$ok) {
456 return ['ok' => false, 'msg' => '订单写入失败'];
457 }
458 // 取自增 ID
459 $row = $DB->get_row_prepare("SELECT id FROM MN_plugin_docker_order WHERE order_no=? LIMIT 1", [$order_no]);
460 $order_id = $row ? (int)$row['id'] : 0;
461 return ['ok' => true, 'order_no' => $order_no, 'order_id' => $order_id, 'amount_cents' => $amount_cents];
462}
463
464/** 更新订单状态。 */
465function docker_shop_order_set_status($order_id, $status, $remark = '')
466{
467 global $DB, $date;
468 $now = $date ?: date('Y-m-d H:i:s');
469 $extra = '';
470 $params = [$status];
471 if ($status === 'paid') {
472 $extra = ', paid_at=?';
473 $params[] = $now;
474 } elseif ($status === 'opened') {
475 $extra = ', opened_at=?';
476 $params[] = $now;
477 }
478 if ($remark !== '') {
479 $extra .= ', remark=?';
480 $params[] = $remark;
481 }
482 $params[] = (int)$order_id;
483 return (bool)$DB->query_prepare(
484 "UPDATE MN_plugin_docker_order SET status=?{$extra} WHERE id=?",
485 $params
486 );
487}
488
489/* ============================================================
490 * 资产管理
491 * ============================================================ */
492
493/** 用户资产列表(含 MN_docker_user 状态冗余)。 */
494function docker_shop_asset_list_by_user($user_id)
495{
496 global $DB;
497 return $DB->get_all_prepare(
498 "SELECT a.*, d.container_status, d.container_id, d.service_name, d.disk_usage, d.disk_usage_at, d.qk AS docker_qk, d.data AS docker_created_at,
499 n.name AS node_name
500 FROM MN_plugin_docker_asset a
501 LEFT JOIN MN_docker_user d ON d.id = a.docker_user_id
502 LEFT JOIN MN_docker_node n ON n.id = d.ssbt
503 WHERE a.user_id=?
504 ORDER BY a.id DESC",
505 [(int)$user_id]
506 ) ?: [];
507}
508
509/** 全部资产列表(管理员,分页)。 */
510function docker_shop_asset_list_all($page = 1, $per_page = 30)
511{
512 global $DB;
513 $page = max(1, (int)$page);
514 $per_page = max(1, min(200, (int)$per_page));
515 $offset = ($page - 1) * $per_page;
516 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_docker_asset WHERE 1");
517 $total = $count_row ? (int)$count_row['cnt'] : 0;
518 $list = $DB->get_all_prepare(
519 "SELECT a.*, d.container_status, d.container_id, d.service_name, d.disk_usage, d.disk_usage_at, d.qk AS docker_qk, d.data AS docker_created_at,
520 n.name AS node_name
521 FROM MN_plugin_docker_asset a
522 LEFT JOIN MN_docker_user d ON d.id = a.docker_user_id
523 LEFT JOIN MN_docker_node n ON n.id = d.ssbt
524 ORDER BY a.id DESC LIMIT {$offset},{$per_page}"
525 ) ?: [];
526 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
527}
528
529/* ============================================================
530 * Docker 账号开通(核心)
531 * ============================================================ */
532
533/**
534 * 开通 Docker 账号:写入 MN_docker_user + 资产表,回填订单。
535 *
536 * @param int $order_id MN_plugin_docker_order.id
537 * @return array ['ok'=>bool, 'msg'=>string, 'docker_user_id'=>int, 'docker_username'=>string, 'docker_password'=>string]
538 */
539function docker_shop_open_account($order_id)
540{
541 global $DB, $date, $conf;
542 $order = docker_shop_order_get($order_id);
543 if (!$order) {
544 return ['ok' => false, 'msg' => '订单不存在'];
545 }
546 if ($order['status'] !== 'paid') {
547 return ['ok' => false, 'msg' => '订单状态非已支付,无法开通'];
548 }
549 if ((int)$order['docker_user_id'] > 0) {
550 return ['ok' => false, 'msg' => '该订单已开通', 'docker_user_id' => (int)$order['docker_user_id']];
551 }
552 $plan = docker_shop_plan_get($order['plan_id']);
553 if (!$plan) {
554 docker_shop_order_set_status($order_id, 'failed', '售卖套餐不存在');
555 return ['ok' => false, 'msg' => '售卖套餐不存在'];
556 }
557 $node = docker_shop_node_get($order['node']);
558 if (!$node) {
559 docker_shop_order_set_status($order_id, 'failed', '节点不存在或已停用');
560 return ['ok' => false, 'msg' => '节点不存在或已停用'];
561 }
562 // 配额套餐必须处于上架状态(MN_docker_plan.qk='true'),下架套餐不可开通
563 $base_plan = docker_shop_base_plan_get($plan['base_plan_id']);
564 if (!$base_plan || $base_plan['qk'] !== 'true') {
565 docker_shop_order_set_status($order_id, 'failed', '配额套餐不存在或已下架');
566 return ['ok' => false, 'msg' => '配额套餐不存在或已下架'];
567 }
568
569 // 依赖核心 Docker 认证函数(bcrypt 哈希)
570 if (!function_exists('docker_auth_password_hash')) {
571 $memberFile = ROOT . 'MPHX/docker.member.php';
572 if (is_file($memberFile)) {
573 include_once $memberFile;
574 }
575 }
576 if (!function_exists('docker_auth_password_hash')) {
577 docker_shop_order_set_status($order_id, 'failed', 'Docker 认证组件缺失');
578 return ['ok' => false, 'msg' => 'Docker 认证组件缺失,请联系管理员'];
579 }
580
581 // 生成 Docker 登录名:优先 user_info username;冲突则追加随机后缀
582 $user_row = $DB->get_row_prepare("SELECT * FROM MN_plugin_user WHERE id=? LIMIT 1", [(int)$order['user_id']]);
583 $base_username = $user_row ? (string)($user_row['username'] ?? '') : '';
584 if ($base_username === '' || !docker_shop_docker_username_available($base_username)) {
585 $base_username = ($base_username === '' ? 'dn' . $order['user_id'] : $base_username) . '_' . substr(md5($order['order_no'] . mt_rand(100, 999)), 0, 4);
586 }
587 $docker_username = $base_username;
588
589 // 生成随机密码(12 位)
590 $docker_password = docker_shop_random_password(12);
591
592 // 计算到期时间
593 $periods = docker_shop_periods();
594 $periodCfg = $periods[$order['period']] ?? $periods['month'];
595 $datae = date('Y-m-d', strtotime('+' . (int)$periodCfg['months'] . ' months', time()));
596
597 $now = $date ?: date('Y-m-d H:i:s');
598 $hash = docker_auth_password_hash($docker_password);
599
600 $ok = $DB->query_prepare(
601 "INSERT INTO MN_docker_user (username, password_hash, email, ssbt, data, datae, qk, plan_id, container_status, created_at) VALUES (?,?,?,?,?,?,'active',?, 'none',?)",
602 [$docker_username, $hash, ($user_row['email'] ?? ''), (int)$node['id'], $now, $datae, (int)$base_plan['id'], $now]
603 );
604 if (!$ok) {
605 docker_shop_order_set_status($order_id, 'failed', 'Docker 账号写入失败');
606 return ['ok' => false, 'msg' => 'Docker 账号写入失败,请联系管理员'];
607 }
608 $docker_row = $DB->get_row_prepare("SELECT id FROM MN_docker_user WHERE username=? LIMIT 1", [$docker_username]);
609 $docker_user_id = $docker_row ? (int)$docker_row['id'] : 0;
610
611 // 回填订单
612 docker_shop_order_set_status($order_id, 'opened', 'Docker 账号已开通:' . $docker_username);
613 if ($docker_user_id > 0) {
614 $DB->query_prepare("UPDATE MN_plugin_docker_order SET docker_user_id=? WHERE id=?", [$docker_user_id, $order_id]);
615 }
616
617 // 写入资产表(明文账号密码,同 MN_zj.user/pass 惯例)
618 $DB->query_prepare(
619 "INSERT INTO MN_plugin_docker_asset (user_id, order_id, docker_user_id, plan_id, plan_name, docker_username, docker_password, expire_at, status, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
620 [(int)$order['user_id'], $order_id, $docker_user_id, (int)$plan['id'], $plan['name'], $docker_username, $docker_password, $datae, 'active', $now]
621 );
622
623 // 触发 docker.user.created 钩子(与 api/docker.php kt 保持一致)
624 if (function_exists('mnbt_do_action')) {
625 mnbt_do_action('docker.user.created', [
626 'username' => $docker_username,
627 'ssbt' => (int)$node['id'],
628 'plan_id' => (int)$base_plan['id'],
629 'user_id' => (int)$order['user_id'],
630 ], ['source' => 'docker_shop', 'order_id' => $order_id]);
631 }
632
633 return [
634 'ok' => true,
635 'msg' => '开通成功',
636 'docker_user_id' => $docker_user_id,
637 'docker_username' => $docker_username,
638 'docker_password' => $docker_password,
639 'expire' => $datae,
640 ];
641}
642
643/** 校验 Docker 登录名是否可用(全局唯一)。 */
644function docker_shop_docker_username_available($username)
645{
646 global $DB;
647 return !(bool)$DB->get_row_prepare("SELECT id FROM MN_docker_user WHERE username=? LIMIT 1", [$username]);
648}
649
650/** 生成随机密码。 */
651function docker_shop_random_password($len = 12)
652{
653 $chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
654 $str = '';
655 $max = strlen($chars) - 1;
656 for ($i = 0; $i < $len; $i++) {
657 $str .= $chars[mt_rand(0, $max)];
658 }
659 return $str;
660}
661
662/**
663 * 重置 Docker 账号密码(更新 MN_docker_user.password_hash,旧 docker_token 自动失效)。
664 *
665 * @param int $asset_id MN_plugin_docker_asset.id
666 * @return array ['ok'=>bool, 'msg'=>string, 'password'=>string]
667 */
668function docker_shop_reset_password($asset_id)
669{
670 global $DB;
671 $asset = $DB->get_row_prepare("SELECT * FROM MN_plugin_docker_asset WHERE id=? LIMIT 1", [(int)$asset_id]);
672 if (!$asset) {
673 return ['ok' => false, 'msg' => '资产不存在'];
674 }
675 if ((int)$asset['docker_user_id'] <= 0) {
676 return ['ok' => false, 'msg' => 'Docker 账号未开通'];
677 }
678 if (!function_exists('docker_auth_password_hash')) {
679 $memberFile = ROOT . 'MPHX/docker.member.php';
680 if (is_file($memberFile)) {
681 include_once $memberFile;
682 }
683 }
684 if (!function_exists('docker_auth_password_hash')) {
685 return ['ok' => false, 'msg' => 'Docker 认证组件缺失'];
686 }
687 $password = docker_shop_random_password(12);
688 $hash = docker_auth_password_hash($password);
689 $ok = $DB->query_prepare("UPDATE MN_docker_user SET password_hash=? WHERE id=?", [$hash, (int)$asset['docker_user_id']]);
690 if (!$ok) {
691 return ['ok' => false, 'msg' => '密码重置失败'];
692 }
693 // 同步资产表明文
694 $DB->query_prepare("UPDATE MN_plugin_docker_asset SET docker_password=? WHERE id=?", [$password, (int)$asset_id]);
695 // 记录日志
696 if (function_exists('mnbt_log')) {
697 mnbt_log($asset['docker_username'] ?: 'Docker', 'Docker账号', '用户自助重置密码', '重置成功', $DB);
698 }
699 return ['ok' => true, 'msg' => '重置成功', 'password' => $password];
700}
701
702/**
703 * 同步 Docker 容器状态到资产(构造 bt_docker 查询已安装应用,逻辑同 docker/ajax.php my_container)。
704 * 失败不阻断,返回数据库中的状态作为兜底。
705 *
706 * @param array $asset MN_plugin_docker_asset 行
707 * @return array 资产行(含 container_status / container_id / service_name / disk_usage)
708 */
709function docker_shop_sync_container_status($asset)
710{
711 global $DB, $date;
712 $docker_user = $DB->get_row_prepare("SELECT * FROM MN_docker_user WHERE id=? LIMIT 1", [(int)$asset['docker_user_id']]);
713 if (!$docker_user) {
714 return $asset;
715 }
716 $node = docker_shop_node_get((int)$docker_user['ssbt']);
717 if (!$node || $node['qk'] !== 'true') {
718 return $asset;
719 }
720 // 复用 Docker 控制台的查找辅助函数(docker/head.php)
721 if (!function_exists('docker_find_my_installed_app')) {
722 $headFile = ROOT . 'docker/head.php';
723 if (is_file($headFile)) {
724 include_once $headFile;
725 }
726 }
727 if (!function_exists('docker_find_my_installed_app')) {
728 return $asset;
729 }
730 $url = ($node['ptl'] === 'true' ? 'https' : 'http') . '://' . $node['btip'] . ':' . $node['btdk'];
731 $btFile = ROOT . 'MPHX/bt_docker.php';
732 if (!is_file($btFile)) {
733 return $asset;
734 }
735 require_once $btFile;
736 try {
737 $bt = new bt_docker($url, $node['btmy']);
738 $apps = $bt->installed_apps();
739 $container = docker_find_my_installed_app($docker_user, $apps);
740 if ($container === false) {
741 // 节点查询失败:不清零、不改库,保留数据库中的现有状态
742 return $asset;
743 }
744 if (!$container) {
745 // 节点返回成功且确认无该应用时才允许清零
746 $DB->query_prepare("UPDATE MN_docker_user SET container_id=NULL, container_status='none', service_name=NULL WHERE id=?", [$docker_user['id']]);
747 $asset['container_status'] = 'none';
748 return $asset;
749 }
750 $status = strtolower((string)($container['status'] ?? ''));
751 $mapped = 'running';
752 if (strpos($status, 'exit') !== false || strpos($status, 'stop') !== false) $mapped = 'stopped';
753 elseif (strpos($status, 'creat') !== false) $mapped = 'creating';
754 elseif (strpos($status, 'run') !== false) $mapped = 'running';
755 $cid = substr((string)($container['container_id'] ?? ''), 0, 64);
756 $sn = substr((string)($container['service_name'] ?? ''), 0, 64);
757 $DB->query_prepare("UPDATE MN_docker_user SET container_id=?, container_status=?, service_name=? WHERE id=?", [$cid, $mapped, $sn, $docker_user['id']]);
758 // 磁盘用量采集
759 $containerPath = (string)($container['path'] ?? '');
760 if ($containerPath !== '') {
761 $sizeResult = $bt->get_path_size($containerPath);
762 $diskSize = (int)($sizeResult['size'] ?? 0);
763 if ($diskSize > 0) {
764 $DB->query_prepare("UPDATE MN_docker_user SET disk_usage=?, disk_usage_at=? WHERE id=?", [$diskSize, $date, $docker_user['id']]);
765 $asset['disk_usage'] = $diskSize;
766 }
767 }
768 $asset['container_id'] = $cid;
769 $asset['container_status'] = $mapped;
770 $asset['service_name'] = $sn;
771 } catch (Throwable $e) {
772 @error_log('[docker_shop] sync_container failed: ' . $e->getMessage());
773 }
774 return $asset;
775}