better-staridc-MNBT
1<?php
2/**
3 * hosting_shop 插件 - 主入口
4 *
5 * 功能:套餐售卖、购买下单、自动开通、资产/订单管理
6 * 依赖:user_info 插件(认证)、balance 插件(余额)、支付插件(epay/alipay_official)
7 * 架构:
8 * - 用户端:通过 P2 路由注册 /shop/* 路径
9 * - 管理员端:通过 mnbt_register_page('admin', ...) 注册到 admin/plugin.php
10 * - 开通:通过 order.paid 钩子处理 lx=hosting 订单,调用宝塔 API 开通主机
11 */
12
13if (!defined('IN_CRONLITE')) {
14 exit;
15}
16
17require_once __DIR__ . '/lib/hosting.php';
18
19// 自动升级表结构(追加新周期价格字段等)
20hosting_upgrade_schema();
21
22mnbt_plugin_register('hosting_shop', [
23 'name' => '主机售卖',
24 'description' => '虚拟主机套餐售卖、自动开通、资产订单管理',
25]);
26
27/* ============================================================
28 * order.paid 钩子:处理主机购买订单
29 * ============================================================
30 * 支付成功后由 mnbt_pay_settle_order() 触发。
31 * 1. 检查订单是否已处理(防重复)
32 * 2. 标记订单为 paid
33 * 3. 调用 hosting_open_host() 开通主机
34 */
35mnbt_add_action('order.paid', function ($order_row, $ctx = []) {
36 if (!is_array($order_row)) {
37 return;
38 }
39 if (($order_row['lx'] ?? '') !== 'hosting') {
40 return;
41 }
42 $order_no = (string)($order_row['ddh'] ?? '');
43 if ($order_no === '') {
44 return;
45 }
46
47 // 防重复:检查该订单号对应的 hosting 订单是否已存在
48 $hosting_order = hosting_order_get_by_no($order_no);
49 if (!$hosting_order) {
50 // 可能是直接走支付完成的订单,但没有 hosting 订单记录,跳过
51 return;
52 }
53 // 已开通/已失败,跳过
54 if (in_array($hosting_order['status'], ['opened', 'failed', 'paid'], true)) {
55 return;
56 }
57
58 // 标记为 paid,然后开通
59 hosting_order_set_status((int)$hosting_order['id'], 'paid', '支付完成');
60 $result = hosting_open_host((int)$hosting_order['id']);
61 if (!$result['ok']) {
62 // 开通失败已在 hosting_open_host 内标记 failed
63 @error_log('[hosting_shop] open failed order=' . $order_no . ' : ' . ($result['msg'] ?? ''));
64 }
65}, 20);
66
67/* ============================================================
68 * 用户端页面路由
69 * ============================================================ */
70
71// 售卖页(套餐列表)
72mnbt_register_route('GET', '/shop', function ($params, $ctx) {
73 hosting_require_user();
74 $plans = hosting_plan_list_active();
75
76 hosting_render('shop', [
77 'page_title' => '主机套餐',
78 'plans' => $plans,
79 ]);
80});
81
82// 下单页
83mnbt_register_route('GET', '/shop/order/{plan_id}', function ($params, $ctx) {
84 hosting_require_user();
85 $plan_id = (int)($params['plan_id'] ?? 0);
86 $plan = hosting_plan_get($plan_id);
87 if (!$plan || $plan['status'] !== 'active') {
88 http_response_code(404);
89 echo '套餐不存在或已下架';
90 return;
91 }
92 // 获取已启用的支付方式
93 $methods = function_exists('mnbt_get_enabled_payment_methods') ? mnbt_get_enabled_payment_methods() : [];
94
95 hosting_render('order', [
96 'page_title' => '购买:' . $plan['name'],
97 'plan' => $plan,
98 'methods' => $methods,
99 ]);
100});
101
102// 我的资产
103mnbt_register_route('GET', '/shop/assets', function ($params, $ctx) {
104 $user = hosting_require_user();
105 $assets = hosting_asset_list_by_user((int)$user['id']);
106
107 hosting_render('assets', [
108 'page_title' => '我的主机',
109 'assets' => $assets,
110 ]);
111});
112
113// 我的订单
114mnbt_register_route('GET', '/shop/orders', function ($params, $ctx) {
115 $user = hosting_require_user();
116 $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
117 $orders = hosting_order_list_by_user((int)$user['id'], $page, 15);
118
119 hosting_render('orders', [
120 'page_title' => '我的订单',
121 'orders' => $orders,
122 ]);
123});
124
125/* ============================================================
126 * 用户端 API 路由
127 * ============================================================ */
128
129// 上架套餐列表(SPA 数据接口)
130mnbt_register_route('GET', '/shop/api/plans', function ($params, $ctx) {
131 if (!function_exists('hosting_plan_list_active')) {
132 hosting_json('ok', ['plans' => []]);
133 return;
134 }
135 $rows = hosting_plan_list_active();
136 $plans = [];
137 foreach ($rows as $p) {
138 $plans[] = [
139 'id' => (int)$p['id'],
140 'name' => (string)$p['name'],
141 'description' => (string)($p['description'] ?? ''),
142 'category' => (string)($p['category'] ?? ''),
143 'node' => (string)($p['node'] ?? ''),
144 'spec_web' => (int)($p['spec_web'] ?? 0),
145 'spec_sql' => (int)($p['spec_sql'] ?? 0),
146 'spec_flow' => (int)($p['spec_flow'] ?? 0),
147 'spec_domain' => (int)($p['spec_domain'] ?? 0),
148 'periods' => hosting_plan_enabled_periods($p),
149 'prices' => [
150 'month' => (int)($p['price_month_cents'] ?? 0),
151 'quarter' => (int)($p['price_quarter_cents'] ?? 0),
152 'half_year' => (int)($p['price_half_year_cents'] ?? 0),
153 'year' => (int)($p['price_year_cents'] ?? 0),
154 'two_year' => (int)($p['price_two_year_cents'] ?? 0),
155 'three_year' => (int)($p['price_three_year_cents'] ?? 0),
156 ],
157 ];
158 }
159 hosting_json('ok', ['plans' => $plans]);
160});
161
162// 套餐详情(SPA 下单页数据接口)
163mnbt_register_route('GET', '/shop/api/plan/{plan_id}', function ($params, $ctx) {
164 $plan_id = (int)($params['plan_id'] ?? 0);
165 $plan = hosting_plan_get($plan_id);
166 if (!$plan || $plan['status'] !== 'active') {
167 hosting_json('套餐不存在或已下架');
168 return;
169 }
170 $methods = function_exists('mnbt_get_enabled_payment_methods') ? mnbt_get_enabled_payment_methods() : [];
171 hosting_json('ok', [
172 'plan' => [
173 'id' => (int)$plan['id'],
174 'name' => (string)$plan['name'],
175 'description' => (string)($plan['description'] ?? ''),
176 'node' => (string)($plan['node'] ?? ''),
177 'spec_web' => (int)($plan['spec_web'] ?? 0),
178 'spec_sql' => (int)($plan['spec_sql'] ?? 0),
179 'spec_flow' => (int)($plan['spec_flow'] ?? 0),
180 'spec_domain' => (int)($plan['spec_domain'] ?? 0),
181 'periods' => hosting_plan_enabled_periods($plan),
182 'prices' => [
183 'month' => (int)($plan['price_month_cents'] ?? 0),
184 'quarter' => (int)($plan['price_quarter_cents'] ?? 0),
185 'half_year' => (int)($plan['price_half_year_cents'] ?? 0),
186 'year' => (int)($plan['price_year_cents'] ?? 0),
187 'two_year' => (int)($plan['price_two_year_cents'] ?? 0),
188 'three_year' => (int)($plan['price_three_year_cents'] ?? 0),
189 ],
190 ],
191 'methods' => $methods,
192 ]);
193});
194
195// 我的资产(SPA 数据接口)
196mnbt_register_route('GET', '/shop/api/assets', function ($params, $ctx) {
197 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
198 if (!$user) {
199 hosting_json('not_login', ['logged_in' => false]);
200 return;
201 }
202 $assets = hosting_asset_list_by_user((int)$user['id']);
203 hosting_json('ok', ['logged_in' => true, 'assets' => $assets]);
204});
205
206// 我的订单(SPA 数据接口,分页)
207mnbt_register_route('GET', '/shop/api/orders', function ($params, $ctx) {
208 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
209 if (!$user) {
210 hosting_json('not_login', ['logged_in' => false]);
211 return;
212 }
213 $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
214 $per = isset($_GET['per_page']) ? max(1, min(100, (int)$_GET['per_page'])) : 15;
215 $orders = hosting_order_list_by_user((int)$user['id'], $page, $per);
216 hosting_json('ok', ['logged_in' => true, 'orders' => $orders]);
217});
218
219// 可用支付方式(SPA 下单页数据接口)
220mnbt_register_route('GET', '/shop/api/methods', function ($params, $ctx) {
221 $methods = function_exists('mnbt_get_enabled_payment_methods') ? mnbt_get_enabled_payment_methods() : [];
222 hosting_json('ok', ['methods' => $methods]);
223});
224
225// 创建购买订单 → 调用支付插件
226mnbt_register_route('POST', '/shop/api/create_order', function ($params, $ctx) {
227 global $DB, $date, $siteurl;
228
229 $user = hosting_require_user();
230 $user_id = (int)$user['id'];
231
232 $plan_id = (int)($_POST['plan_id'] ?? 0);
233 $period = isset($_POST['period']) ? trim($_POST['period']) : 'month';
234 $type = isset($_POST['type']) ? trim($_POST['type']) : '';
235
236 // 校验套餐
237 $plan = hosting_plan_get($plan_id);
238 if (!$plan || $plan['status'] !== 'active') {
239 hosting_json('套餐不存在或已下架');
240 }
241 $periods = hosting_periods();
242 if (!isset($periods[$period])) {
243 hosting_json('请选择有效的购买周期');
244 }
245 $enabled = hosting_plan_enabled_periods($plan);
246 if (!in_array($period, $enabled, true)) {
247 hosting_json('该套餐不支持此购买周期');
248 }
249 $price_field = hosting_period_price_field($period);
250 $amount_cents = $price_field ? (int)($plan[$price_field] ?? 0) : 0;
251 if ($amount_cents < 0) {
252 hosting_json('该套餐此周期价格异常');
253 }
254 // 节点已由套餐固定配置,无需前端传入
255 $node = trim((string)($plan['node'] ?? ''));
256 if ($node === '' || !hosting_node_get($node)) {
257 hosting_json('套餐未配置有效开通节点,请联系管理员');
258 }
259 // 非 0 元订单校验支付方式;0 元订单无需选择支付方式
260 if ($amount_cents > 0) {
261 if ($type === '' || !function_exists('mnbt_pay_parse_type') || !mnbt_pay_parse_type($type)) {
262 hosting_json('请选择有效的支付方式');
263 }
264 }
265
266 // 创建 hosting 订单(节点从套餐读取)
267 $create = hosting_order_create($user, $plan, $period);
268 if (empty($create['ok'])) {
269 hosting_json($create['msg'] ?? '创建订单失败');
270 }
271 $order_no = $create['order_no'];
272 $hosting_order_id = (int)$create['order_id'];
273
274 // 0 元免费套餐:直接标记 paid 并开通,无需创建 MN_dd 和调支付网关
275 if ($amount_cents === 0) {
276 hosting_order_set_status($hosting_order_id, 'paid', '免费套餐直接开通');
277 $open = hosting_open_host($hosting_order_id);
278 if (!$open['ok']) {
279 hosting_json('开通失败:' . ($open['msg'] ?? '未知错误'));
280 }
281 hosting_json('开通成功', ['redirect' => hosting_url('shop/assets')]);
282 }
283
284 // 创建 MN_dd 记录(支付系统订单)
285 $amount_yuan = (string)round($amount_cents / 100, 2);
286 $cs = json_encode([
287 'user_id' => $user_id,
288 'plan_id' => $plan_id,
289 'period' => $period,
290 'node' => $node,
291 'amount' => $amount_cents,
292 'username' => $user['username'],
293 'order_id' => $hosting_order_id,
294 ], 256);
295 $ip = $_SERVER["REMOTE_ADDR"] ?? '127.0.0.1';
296
297 $row1 = $DB->get_row_prepare("SELECT * FROM MN_dd WHERE 1 order by id desc limit 1");
298 $dd_id = $row1 ? ((int)$row1['id'] + 1) : 1;
299 $ok = $DB->query_prepare(
300 "INSERT INTO MN_dd (id, cs, date, zffs, je, ddh, lx, qk, ip) VALUES (?,?,?,?,?,?,?,?,?)",
301 [$dd_id, $cs, $date, $type, $amount_yuan, $order_no, 'hosting', 'false', $ip]
302 );
303 if (!$ok) {
304 // 回滚 hosting 订单状态
305 hosting_order_set_status($hosting_order_id, 'cancelled', '支付订单创建失败');
306 hosting_json('支付订单创建失败,请稍后重试');
307 }
308
309 // 分发到支付插件
310 $period_label = $periods[$period]['label'];
311 $order_context = [
312 'out_trade_no' => $order_no,
313 'name' => '购买主机:' . $plan['name'] . '(' . $period_label . ')',
314 'money' => $amount_yuan,
315 'type' => $type,
316 'siteurl' => $siteurl,
317 'pay_lx' => 'hosting',
318 ];
319
320 $html = mnbt_pay_dispatch_gateway($type, $order_context);
321 if ($html === false) {
322 hosting_order_set_status($hosting_order_id, 'cancelled', '支付方式不可用');
323 hosting_json('支付方式不可用,请检查支付插件是否已启用');
324 }
325
326 hosting_json('ok', ['html' => $html, 'order_no' => $order_no]);
327});
328
329/* ============================================================
330 * 管理员端页面注册
331 * ============================================================ */
332
333mnbt_register_page('admin', 'plans', 'views/admin/plans.php', '套餐管理');
334mnbt_register_page('admin', 'plan_edit', 'views/admin/plan_edit.php', '套餐编辑');
335mnbt_register_page('admin', 'orders', 'views/admin/orders.php', '订单管理');
336mnbt_register_page('admin', 'assets', 'views/admin/assets.php', '资产管理');
337
338// 侧边栏菜单(三级结构)
339mnbt_register_menu('admin', [
340 'title' => '主机售卖',
341 'icon' => 'mdi-cart',
342 'order' => 60,
343 'children' => [
344 ['title' => '套餐管理', 'page' => 'plans', 'icon' => 'mdi-package-variant', 'multitabs' => true],
345 ['title' => '订单管理', 'page' => 'orders', 'icon' => 'mdi-receipt', 'multitabs' => true],
346 ['title' => '资产管理', 'page' => 'assets', 'icon' => 'mdi-server', 'multitabs' => true],
347 ],
348]);
349
350/* ============================================================
351 * 节点 PHP 版本管理已移至系统底层
352 * 请访问管理后台 → 节点与宝塔 → 节点PHP版本
353 * ============================================================ */