better-staridc-MNBT
1<?php
2/**
3 * balance 插件 - 主入口
4 *
5 * 功能:余额查询、充值(调用支付插件 API)、流水记录
6 * 依赖:user_info 插件(认证)、支付插件(epay/alipay_official)
7 * 架构:通过 P2 路由注册 /balance/* 路径;通过 order.paid 钩子处理充值结算
8 */
9
10if (!defined('IN_CRONLITE')) {
11 exit;
12}
13
14require_once __DIR__ . '/lib/balance.php';
15
16mnbt_plugin_register('balance', [
17 'name' => '余额管理',
18 'description' => '用户余额、充值、流水',
19]);
20
21/* ============================================================
22 * order.paid 钩子:处理充值订单结算
23 * ============================================================
24 * 支付插件回调验签后调 mnbt_pay_settle_order(),核心标记订单完成
25 * 并触发 order.paid。此处检查 lx=recharge,增加用户余额。
26 */
27mnbt_add_action('order.paid', function ($order_row, $ctx = []) {
28 if (!is_array($order_row)) {
29 return;
30 }
31 if (($order_row['lx'] ?? '') !== 'recharge') {
32 return;
33 }
34 $cs = json_decode($order_row['cs'] ?? '', true);
35 if (!is_array($cs)) {
36 return;
37 }
38 $user_id = (int)($cs['user_id'] ?? 0);
39 $amount_cents = (int)($cs['amount'] ?? 0);
40 if ($user_id <= 0 || $amount_cents <= 0) {
41 return;
42 }
43 // 防重复:检查该订单是否已入账
44 $exists = $GLOBALS['DB']->get_row_prepare(
45 "SELECT id FROM MN_plugin_balance_log WHERE user_id=? AND order_no=? AND type='recharge' LIMIT 1",
46 [$user_id, $order_row['ddh']]
47 );
48 if ($exists) {
49 return;
50 }
51 balance_add($user_id, $amount_cents, 'recharge', $order_row['ddh'], '余额充值');
52}, 10);
53
54/* ============================================================
55 * 页面路由
56 * ============================================================ */
57
58// 余额首页(显示余额 + 流水)
59mnbt_register_route('GET', '/balance', function ($params, $ctx) {
60 $user = balance_require_user();
61 $user_id = (int)$user['id'];
62 $balance = balance_get($user_id);
63
64 $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
65 $logs = balance_logs($user_id, $page, 15);
66
67 balance_render('balance', [
68 'page_title' => '我的余额',
69 'balance_cents' => $balance,
70 'logs' => $logs,
71 ]);
72});
73
74// 充值页面
75mnbt_register_route('GET', '/balance/recharge', function ($params, $ctx) {
76 $user = balance_require_user();
77
78 // 获取已启用的支付方式,排除余额支付自身(充值不能用余额付,循环)
79 $methods = [];
80 if (function_exists('mnbt_get_enabled_payment_methods')) {
81 $all = mnbt_get_enabled_payment_methods();
82 foreach ($all as $m) {
83 if (($m['plugin'] ?? '') === 'balance') {
84 continue;
85 }
86 $methods[] = $m;
87 }
88 }
89
90 balance_render('recharge', [
91 'page_title' => '余额充值',
92 'methods' => $methods,
93 ]);
94});
95
96/* ============================================================
97 * API 路由
98 * ============================================================ */
99
100// 余额信息 + 流水分页(SPA 数据接口)
101mnbt_register_route('GET', '/balance/api/info', function ($params, $ctx) {
102 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
103 if (!$user) {
104 balance_json('not_login', ['logged_in' => false]);
105 return;
106 }
107 $user_id = (int)$user['id'];
108 $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
109 $per = isset($_GET['per_page']) ? max(1, min(100, (int)$_GET['per_page'])) : 15;
110 $logs = balance_logs($user_id, $page, $per);
111 balance_json('ok', [
112 'logged_in' => true,
113 'balance_cents' => balance_get($user_id),
114 'balance_yuan' => balance_format(balance_get($user_id)),
115 'logs' => $logs,
116 ]);
117});
118
119// 可用支付方式(SPA 充值页数据接口,排除余额自身)
120mnbt_register_route('GET', '/balance/api/methods', function ($params, $ctx) {
121 if (!function_exists('user_info_auth_current') || !user_info_auth_current()) {
122 balance_json('not_login', ['logged_in' => false]);
123 return;
124 }
125 $methods = [];
126 if (function_exists('mnbt_get_enabled_payment_methods')) {
127 foreach (mnbt_get_enabled_payment_methods() as $m) {
128 if (($m['plugin'] ?? '') === 'balance') {
129 continue;
130 }
131 $methods[] = $m;
132 }
133 }
134 balance_json('ok', ['methods' => $methods]);
135});
136
137// 创建充值订单 → 调用支付插件
138mnbt_register_route('POST', '/balance/api/create_recharge', function ($params, $ctx) {
139 global $DB, $date, $siteurl;
140
141 $user = balance_require_user();
142 $user_id = (int)$user['id'];
143
144 $amount_yuan = isset($_POST['amount']) ? (float)$_POST['amount'] : 0;
145 $type = isset($_POST['type']) ? trim($_POST['type']) : '';
146
147 // 验证金额(最低 1 元,最高 50000 元)
148 if ($amount_yuan < 1) {
149 balance_json('充值金额至少 1 元');
150 }
151 if ($amount_yuan > 50000) {
152 balance_json('单次充值金额不能超过 50000 元');
153 }
154 $amount_cents = (int)round($amount_yuan * 100);
155
156 // 验证支付方式
157 if ($type === '' || !function_exists('mnbt_pay_parse_type') || !mnbt_pay_parse_type($type)) {
158 balance_json('请选择有效的支付方式');
159 }
160 // 充值订单禁止使用余额支付(循环)
161 $parsed_type = mnbt_pay_parse_type($type);
162 if ($parsed_type && $parsed_type['plugin'] === 'balance') {
163 balance_json('充值订单不能使用余额支付');
164 }
165
166 // 创建订单(MN_dd 表)
167 $out_trade_no = date("YmdHis") . mt_rand(100, 999);
168 $cs = json_encode([
169 'user_id' => $user_id,
170 'amount' => $amount_cents,
171 'username' => $user['username'],
172 ], 256);
173 $ip = $_SERVER["REMOTE_ADDR"] ?? '127.0.0.1';
174
175 $row1 = $DB->get_row_prepare("SELECT * FROM MN_dd WHERE 1 order by id desc limit 1");
176 $id = $row1 ? ((int)$row1['id'] + 1) : 1;
177 $ok = $DB->query_prepare(
178 "INSERT INTO MN_dd (id, cs, date, zffs, je, ddh, lx, qk, ip) VALUES (?,?,?,?,?,?,?,?,?)",
179 [$id, $cs, $date, $type, $amount_yuan, $out_trade_no, 'recharge', 'false', $ip]
180 );
181 if (!$ok) {
182 balance_json('创建订单失败,请稍后重试');
183 }
184
185 // 分发到支付插件
186 $order_context = [
187 'out_trade_no' => $out_trade_no,
188 'name' => '余额充值',
189 'money' => (string)$amount_yuan,
190 'type' => $type,
191 'siteurl' => $siteurl,
192 'pay_lx' => 'recharge',
193 ];
194
195 $html = mnbt_pay_dispatch_gateway($type, $order_context);
196 if ($html === false) {
197 balance_json('支付方式不可用,请检查支付插件是否已启用');
198 }
199
200 // 返回支付 HTML,前端用 document.write 输出跳转
201 // code 必须为 'ok':支付发起成功(HTML 已生成),否则 SPA 等客户端会按失败处理而不跳转
202 balance_json('ok', ['html' => $html]);
203});
204
205/* ============================================================
206 * 余额支付(作为支付插件注册)
207 * ============================================================
208 * 把"余额扣款"做成标准支付方式(type = balance__balance),
209 * 供 hosting_shop 等业务插件在下单页直接选用。
210 *
211 * 流程:
212 * 1. 业务插件创建 MN_dd 订单 → mnbt_pay_dispatch_gateway('balance__balance', ...)
213 * 2. 本插件 build 回调预检(登录、余额、非充值单)后返回自动提交表单
214 * 3. 表单 POST 到 /pay/balance/pay → 校验订单归属 → 原子扣款 → mnbt_pay_settle_order()
215 * 4. 结算成功后 order.paid 钩子触发业务插件处理(如 hosting_shop 开通主机)
216 * 5. 展示支付结果页
217 */
218mnbt_register_payment('balance', [
219 'name' => '余额支付',
220 'description' => '使用账户余额直接付款(需先充值)',
221 'icon' => 'mdi-wallet',
222 'methods' => [
223 'balance' => ['name' => '余额支付', 'icon' => 'mdi-cash'],
224 ],
225 'build' => function ($method, $order, $plugin_config) {
226 // 1. 充值订单禁止使用余额支付(循环)
227 if (($order['pay_lx'] ?? '') === 'recharge') {
228 return balance_pay_render_error('充值订单不能使用余额支付');
229 }
230 // 2. 依赖 user_info 插件
231 if (!function_exists('user_info_auth_current')) {
232 return balance_pay_render_error('需要先启用 user_info 插件');
233 }
234 $user = user_info_auth_current();
235 if (!$user) {
236 // 未登录 → 跳转登录页
237 $loginUrl = balance_url('account/login');
238 return '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><script>window.location.href="' . $loginUrl . '";</script></body></html>';
239 }
240 // 3. 余额预检
241 $amount_cents = (int)round((float)($order['money'] ?? 0) * 100);
242 $balance = balance_get($user['id']);
243 if ($amount_cents <= 0) {
244 return balance_pay_render_error('订单金额异常');
245 }
246 if ($balance < $amount_cents) {
247 return balance_pay_render_error(
248 '余额不足,当前余额 ¥' . balance_format($balance) . ',本次需支付 ¥' . balance_format($amount_cents),
249 balance_url('balance/recharge')
250 );
251 }
252 // 4. 构造自动提交表单 → /pay/balance/pay
253 $outTradeNo = htmlspecialchars($order['out_trade_no'], ENT_QUOTES, 'UTF-8');
254 $payUrl = balance_url('pay/balance/pay');
255 return <<<HTML
256<!DOCTYPE html>
257<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>余额支付</title></head>
258<body style="font-family:sans-serif;text-align:center;padding:60px;background:#f5f7fa;">
259<p style="color:#475569;">正在使用余额支付,请稍候...</p>
260<form id="payform" method="POST" action="{$payUrl}">
261<input type="hidden" name="out_trade_no" value="{$outTradeNo}">
262<noscript><button type="submit">点击继续</button></noscript>
263</form>
264<script>document.getElementById('payform').submit();</script>
265</body></html>
266HTML;
267 },
268]);
269
270/* 余额支付处理路由:原子扣款 + 统一结算 */
271mnbt_register_route('POST', '/pay/balance/pay', function ($params, $ctx) {
272 global $DB;
273
274 // 1. 登录校验
275 if (!function_exists('user_info_auth_current')) {
276 balance_pay_show_result('error', '需要先启用 user_info 插件');
277 return;
278 }
279 $user = user_info_auth_current();
280 if (!$user) {
281 balance_pay_show_result('error', '请先登录', ['redirect' => balance_url('account/login'), 'redirect_text' => '去登录']);
282 return;
283 }
284
285 // 2. 取订单号并查单
286 $out_trade_no = isset($_POST['out_trade_no']) ? trim((string)$_POST['out_trade_no']) : '';
287 if ($out_trade_no === '') {
288 balance_pay_show_result('error', '订单号缺失');
289 return;
290 }
291 $order = $DB->get_row_prepare("SELECT * FROM MN_dd WHERE ddh=? LIMIT 1", [$out_trade_no]);
292 if (!$order) {
293 balance_pay_show_result('error', '订单不存在');
294 return;
295 }
296
297 // 3. 订单状态校验
298 if ((string)$order['qk'] === 'true') {
299 balance_pay_show_result('error', '订单已支付,无需重复付款');
300 return;
301 }
302
303 // 4. 支付方式校验:必须为 balance__balance,防止伪造请求用余额去结其他支付方式的单
304 $parsed = function_exists('mnbt_pay_parse_type') ? mnbt_pay_parse_type($order['zffs']) : false;
305 if (!$parsed || $parsed['plugin'] !== 'balance') {
306 balance_pay_show_result('error', '订单支付方式非余额支付');
307 return;
308 }
309
310 // 5. 订单归属校验:cs.user_id 必须等于当前用户 id
311 $cs = json_decode($order['cs'] ?? '', true);
312 if (!is_array($cs) || (int)($cs['user_id'] ?? 0) !== (int)$user['id']) {
313 balance_pay_show_result('error', '订单不属于当前用户');
314 return;
315 }
316
317 // 6. 充值单拒绝(双保险,build 已挡过一次)
318 if (($order['lx'] ?? '') === 'recharge') {
319 balance_pay_show_result('error', '充值订单不能使用余额支付');
320 return;
321 }
322
323 // 7. 原子扣款(WHERE balance >= ? 保证余额不足时失败)
324 $amount_cents = (int)round((float)$order['je'] * 100);
325 if ($amount_cents <= 0) {
326 balance_pay_show_result('error', '订单金额异常');
327 return;
328 }
329 $ok = balance_deduct(
330 $user['id'],
331 $amount_cents,
332 'consume',
333 $out_trade_no,
334 '余额支付:' . ($order['lx'] ?? '')
335 );
336 if (!$ok) {
337 balance_pay_show_result(
338 'error',
339 '余额不足,扣款失败',
340 ['redirect' => balance_url('balance/recharge'), 'redirect_text' => '去充值']
341 );
342 return;
343 }
344
345 // 8. 统一结算:标记订单完成 + 触发 order.paid 钩子(由业务插件处理后续业务)
346 $result = mnbt_pay_settle_order($out_trade_no, 'TRADE_SUCCESS', (string)$order['je']);
347 if (empty($result['ok'])) {
348 // 扣款成功但结算异常 → 记录日志并提示联系管理员(余额已扣,需人工处理)
349 @error_log('[balance] settle failed but deducted: order=' . $out_trade_no . ' user=' . $user['id'] . ' amount=' . $amount_cents . ' msg=' . ($result['msg'] ?? ''));
350 balance_pay_show_result('error', '扣款成功但订单结算异常,请联系管理员。订单号:' . $out_trade_no);
351 return;
352 }
353
354 // 9. 成功
355 balance_pay_show_result('success', '支付成功', [
356 'order_no' => $out_trade_no,
357 'amount' => (string)$order['je'],
358 ]);
359});
360
361/* ============================================================
362 * 管理员端页面注册
363 * ============================================================ */
364
365mnbt_register_page('admin', 'balances', 'views/admin/balances.php', '余额管理');
366mnbt_register_page('admin', 'balance_logs', 'views/admin/logs.php', '余额流水');
367
368mnbt_register_menu('admin', [
369 'title' => '余额管理',
370 'icon' => 'mdi-wallet',
371 'order' => 71,
372 'children' => [
373 ['title' => '用户余额', 'page' => 'balances', 'icon' => 'mdi-cash-multiple', 'multitabs' => true],
374 ['title' => '流水记录', 'page' => 'balance_logs', 'icon' => 'mdi-history', 'multitabs' => true],
375 ],
376]);
377
378// 管理员端 AJAX:调整用户余额
379mnbt_register_ajax('admin', 'balance_admin_adjust', function () {
380 mnbt_plugin_require_admin();
381 $user_id = (int)($_POST['user_id'] ?? 0);
382 $amount_yuan = (float)($_POST['amount'] ?? 0);
383 $direction = $_POST['direction'] ?? ''; // add / deduct
384 $remark = trim((string)($_POST['remark'] ?? ''));
385
386 if ($user_id <= 0) {
387 json_exit('参数错误');
388 }
389 if ($amount_yuan <= 0) {
390 json_exit('金额必须大于 0');
391 }
392 if (!in_array($direction, ['add', 'deduct'], true)) {
393 json_exit('操作类型错误');
394 }
395 $amount_cents = (int)round($amount_yuan * 100);
396 if ($amount_cents <= 0) {
397 json_exit('金额必须大于 0');
398 }
399 $remark = $remark === '' ? '管理员调整' : $remark;
400
401 if ($direction === 'add') {
402 $ok = balance_add($user_id, $amount_cents, 'adjust', '', '管理员加款:' . $remark);
403 } else {
404 $ok = balance_deduct($user_id, $amount_cents, 'adjust', '', '管理员扣款:' . $remark);
405 }
406 if (!$ok) {
407 json_exit($direction === 'deduct' ? '扣款失败(余额不足)' : '加款失败');
408 }
409 json_exit('调整成功');
410});