better-staridc-MNBT
1<?php
2/**
3 * balance 插件 - 余额操作函数库
4 *
5 * 余额以「分」为单位整数存储,避免浮点误差。
6 * 依赖 user_info 插件提供认证(account_token cookie / user_info_auth_current)。
7 */
8
9if (!defined('IN_CRONLITE')) {
10 exit;
11}
12
13/**
14 * 生成带站点 base path 前缀的 URL。
15 */
16function balance_url($path = '')
17{
18 $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
19 $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
20 if ($basePath === '.' || $basePath === '/') {
21 $basePath = '';
22 }
23 // 使用查询参数路由(index.php?_r=/path),避免依赖 Web 服务器 rewrite
24 $p = ltrim($path, '/');
25 $qpos = strpos($p, '?');
26 if ($qpos !== false) {
27 $route = substr($p, 0, $qpos);
28 $query = substr($p, $qpos + 1);
29 return $basePath . '/index.php?_r=/' . $route . '&' . $query;
30 }
31 return $basePath . '/index.php?_r=/' . $p;
32}
33
34/**
35 * 插件静态资源 URL。
36 */
37function balance_asset_url($path = '')
38{
39 return mnbt_plugin_url('balance', 'assets/' . ltrim($path, '/'));
40}
41
42/**
43 * 获取当前登录的 user_info 用户。
44 * 依赖 user_info 插件已启用。未登录跳转登录页。
45 */
46function balance_require_user()
47{
48 if (!function_exists('user_info_auth_current')) {
49 http_response_code(500);
50 echo '需要先启用 user_info 插件';
51 exit;
52 }
53 $user = user_info_auth_current();
54 if (!$user) {
55 header('Location: ' . balance_url('account/login'));
56 exit;
57 }
58 return $user;
59}
60
61/**
62 * 获取用户余额(分)。无记录返回 0。
63 */
64function balance_get($user_id)
65{
66 global $DB;
67 $row = $DB->get_row_prepare("SELECT balance FROM MN_plugin_balance WHERE user_id=? LIMIT 1", [(int)$user_id]);
68 return $row ? (int)$row['balance'] : 0;
69}
70
71/**
72 * 确保用户有余额记录行。
73 */
74function balance_ensure_row($user_id)
75{
76 global $DB, $date;
77 $user_id = (int)$user_id;
78 $row = $DB->get_row_prepare("SELECT id FROM MN_plugin_balance WHERE user_id=? LIMIT 1", [$user_id]);
79 if (!$row) {
80 $now = $date ?: date('Y-m-d H:i:s');
81 $DB->query_prepare(
82 "INSERT INTO MN_plugin_balance (user_id, balance, updated_at) VALUES (?,?,?)",
83 [$user_id, 0, $now]
84 );
85 }
86}
87
88/**
89 * 增加余额(充值/退款/调整)。
90 *
91 * @param int $user_id
92 * @param int $amount 金额(分,正数)
93 * @param string $type recharge / refund / adjust
94 * @param string $order_no 关联订单号
95 * @param string $remark 备注
96 * @return bool
97 */
98function balance_add($user_id, $amount, $type, $order_no = '', $remark = '')
99{
100 global $DB, $date;
101 $user_id = (int)$user_id;
102 $amount = (int)$amount;
103 if ($amount <= 0) {
104 return false;
105 }
106 balance_ensure_row($user_id);
107 $now = $date ?: date('Y-m-d H:i:s');
108 $ok = $DB->query_prepare(
109 "UPDATE MN_plugin_balance SET balance = balance + ?, updated_at = ? WHERE user_id = ?",
110 [$amount, $now, $user_id]
111 );
112 if (!$ok) {
113 return false;
114 }
115 $balance_after = balance_get($user_id);
116 $DB->query_prepare(
117 "INSERT INTO MN_plugin_balance_log (user_id, amount, balance_after, type, order_no, remark, created_at) VALUES (?,?,?,?,?,?,?)",
118 [$user_id, $amount, $balance_after, $type, $order_no, $remark, $now]
119 );
120 return true;
121}
122
123/**
124 * 扣减余额(消费)。余额不足返回 false。
125 *
126 * @param int $user_id
127 * @param int $amount 金额(分,正数)
128 * @param string $type consume
129 * @param string $order_no 关联订单号
130 * @param string $remark 备注
131 * @return bool
132 */
133function balance_deduct($user_id, $amount, $type = 'consume', $order_no = '', $remark = '')
134{
135 global $DB, $date;
136 $user_id = (int)$user_id;
137 $amount = (int)$amount;
138 if ($amount <= 0) {
139 return false;
140 }
141 balance_ensure_row($user_id);
142 $now = $date ?: date('Y-m-d H:i:s');
143 // 原子扣减:余额必须充足
144 $ok = $DB->query_prepare(
145 "UPDATE MN_plugin_balance SET balance = balance - ?, updated_at = ? WHERE user_id = ? AND balance >= ?",
146 [$amount, $now, $user_id, $amount]
147 );
148 // affected() 返回受影响行数,为 0 说明余额不足
149 if (!$ok || !$DB->affected()) {
150 return false;
151 }
152 $balance_after = balance_get($user_id);
153 $DB->query_prepare(
154 "INSERT INTO MN_plugin_balance_log (user_id, amount, balance_after, type, order_no, remark, created_at) VALUES (?,?,?,?,?,?,?)",
155 [$user_id, -$amount, $balance_after, $type, $order_no, $remark, $now]
156 );
157 return true;
158}
159
160/**
161 * 获取用户流水记录。
162 *
163 * @param int $user_id
164 * @param int $page 页码(从 1 开始)
165 * @param int $per_page 每页条数
166 * @return array ['list'=>[], 'total'=>int, 'page'=>int, 'per_page'=>int]
167 */
168function balance_logs($user_id, $page = 1, $per_page = 20)
169{
170 global $DB;
171 $user_id = (int)$user_id;
172 $page = max(1, (int)$page);
173 $per_page = max(1, min(100, (int)$per_page));
174 $offset = ($page - 1) * $per_page;
175
176 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_balance_log WHERE user_id=?", [$user_id]);
177 $total = $count_row ? (int)$count_row['cnt'] : 0;
178
179 $list = $DB->get_all_prepare(
180 "SELECT * FROM MN_plugin_balance_log WHERE user_id=? ORDER BY id DESC LIMIT {$offset},{$per_page}",
181 [$user_id]
182 ) ?: [];
183
184 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
185}
186
187/**
188 * 格式化金额:分 → 元(保留 2 位小数)。
189 */
190function balance_format($cents)
191{
192 return number_format($cents / 100, 2, '.', '');
193}
194
195/**
196 * 渲染视图。
197 */
198function balance_render($view, $vars = [])
199{
200 $vars['current_user'] = $vars['current_user'] ?? (function_exists('user_info_auth_current') ? user_info_auth_current() : null);
201 extract($vars, EXTR_SKIP);
202 $viewFile = mnbt_plugin_path('balance') . 'views/' . $view . '.php';
203 if (!is_file($viewFile)) {
204 http_response_code(500);
205 echo 'View not found: ' . htmlspecialchars($view);
206 return;
207 }
208 include $viewFile;
209}
210
211/**
212 * 输出 JSON 并退出。
213 */
214function balance_json($code, $extra = [])
215{
216 @header('Content-Type: application/json; charset=UTF-8');
217 $payload = ['code' => $code];
218 if (is_array($extra)) {
219 $payload = array_merge($payload, $extra);
220 }
221 echo json_encode($payload, JSON_UNESCAPED_UNICODE);
222 exit;
223}
224
225/* ============================================================
226 * 管理员端辅助函数
227 * ============================================================ */
228
229/**
230 * 获取所有用户余额列表(分页,关联用户名)。
231 *
232 * @param int $page
233 * @param int $per_page
234 * @param string $kw 用户名关键字
235 * @return array ['list'=>[], 'total'=>int, 'page'=>int, 'per_page'=>int]
236 */
237function balance_admin_list($page = 1, $per_page = 30, $kw = '')
238{
239 global $DB;
240 $page = max(1, (int)$page);
241 $per_page = max(1, min(200, (int)$per_page));
242 $offset = ($page - 1) * $per_page;
243
244 $where = '';
245 $params = [];
246 if ($kw !== '') {
247 $where = " WHERE u.username LIKE ?";
248 $params = ['%' . $kw . '%'];
249 }
250
251 // 以 MN_plugin_user 为主表,LEFT JOIN 余额表,保证余额为 0 或未产生余额记录的用户也显示
252 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_user u{$where}", $params);
253 $total = $count_row ? (int)$count_row['cnt'] : 0;
254
255 $list = $DB->get_all_prepare(
256 "SELECT u.id AS user_id, u.username, u.email, u.status, b.id, COALESCE(b.balance, 0) AS balance, b.updated_at
257 FROM MN_plugin_user u
258 LEFT JOIN MN_plugin_balance b ON b.user_id = u.id
259 {$where}
260 ORDER BY u.id DESC
261 LIMIT {$offset},{$per_page}",
262 $params
263 ) ?: [];
264
265 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
266}
267
268/**
269 * 获取所有用户的流水记录(分页,关联用户名)。
270 *
271 * @param int $page
272 * @param int $per_page
273 * @param array $filters ['user_id'=>int, 'type'=>string, 'order_no'=>string]
274 * @return array
275 */
276function balance_admin_logs($page = 1, $per_page = 30, $filters = [])
277{
278 global $DB;
279 $page = max(1, (int)$page);
280 $per_page = max(1, min(200, (int)$per_page));
281 $offset = ($page - 1) * $per_page;
282
283 $where = ' WHERE 1=1';
284 $params = [];
285 if (!empty($filters['user_id'])) {
286 $where .= ' AND l.user_id=?';
287 $params[] = (int)$filters['user_id'];
288 }
289 if (!empty($filters['type'])) {
290 $where .= ' AND l.type=?';
291 $params[] = $filters['type'];
292 }
293 if (!empty($filters['order_no'])) {
294 $where .= ' AND l.order_no LIKE ?';
295 $params[] = '%' . $filters['order_no'] . '%';
296 }
297
298 $count_row = $DB->get_row_prepare("SELECT COUNT(*) AS cnt FROM MN_plugin_balance_log l{$where}", $params);
299 $total = $count_row ? (int)$count_row['cnt'] : 0;
300
301 $list = $DB->get_all_prepare(
302 "SELECT l.*, u.username
303 FROM MN_plugin_balance_log l
304 LEFT JOIN MN_plugin_user u ON l.user_id=u.id
305 {$where}
306 ORDER BY l.id DESC
307 LIMIT {$offset},{$per_page}",
308 $params
309 ) ?: [];
310
311 return ['list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
312}
313
314/* ============================================================
315 * 余额支付(作为支付插件)
316 * ============================================================ */
317
318/**
319 * 渲染余额支付错误页(用于 build 回调返回的 HTML 字符串)。
320 *
321 * @param string $msg 错误信息
322 * @param string $rechargeUrl 可选,"去充值"按钮链接
323 * @return string 完整 HTML 文档
324 */
325function balance_pay_render_error($msg, $rechargeUrl = '')
326{
327 $msgEsc = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
328 $rechargeLink = '';
329 if ($rechargeUrl) {
330 $u = htmlspecialchars($rechargeUrl, ENT_QUOTES, 'UTF-8');
331 $rechargeLink = '<a href="' . $u . '" style="display:inline-block;padding:10px 24px;background:#1677ff;color:#fff;text-decoration:none;border-radius:8px;margin:8px;">去充值</a>';
332 }
333 $backUrl = balance_url('balance');
334 return <<<HTML
335<!DOCTYPE html>
336<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>余额支付</title></head>
337<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;text-align:center;padding:60px 16px;background:#f5f7fa;color:#1e293b;">
338<div style="max-width:440px;margin:0 auto;background:#fff;border-radius:16px;padding:40px 32px;box-shadow:0 4px 24px rgba(15,23,42,.06);">
339<div style="width:64px;height:64px;border-radius:50%;background:#b91c1c15;color:#b91c1c;font-size:32px;line-height:64px;margin:0 auto 16px;">✕</div>
340<h1 style="font-size:22px;font-weight:600;margin:0 0 8px;color:#b91c1c;">余额支付失败</h1>
341<p style="color:#475569;margin:0 0 8px;">{$msgEsc}</p>
342<div style="margin-top:24px;">{$rechargeLink}<a href="{$backUrl}" style="display:inline-block;padding:10px 24px;background:#fff;color:#475569;text-decoration:none;border-radius:8px;margin:8px;border:1px solid #e2e8f0;">返回余额页</a></div>
343</div>
344</body></html>
345HTML;
346}
347
348/**
349 * 输出余额支付结果页并退出。
350 *
351 * @param string $type success / error
352 * @param string $msg 结果说明
353 * @param array $extra ['order_no'=>..., 'amount'=>..., 'redirect'=>..., 'redirect_text'=>...]
354 */
355function balance_pay_show_result($type, $msg, $extra = [])
356{
357 @header('Content-Type: text/html; charset=UTF-8');
358 $isSuccess = $type === 'success';
359 $title = $isSuccess ? '支付成功' : '支付失败';
360 $color = $isSuccess ? '#16a34a' : '#b91c1c';
361 $icon = $isSuccess ? '✓' : '✕';
362 $msgEsc = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
363
364 $orderNo = isset($extra['order_no']) ? (string)$extra['order_no'] : '';
365 $amount = isset($extra['amount']) ? (string)$extra['amount'] : '';
366 $redirect = isset($extra['redirect']) ? (string)$extra['redirect'] : '';
367 $redirectText = isset($extra['redirect_text']) ? (string)$extra['redirect_text'] : '继续';
368
369 $orderLine = $orderNo ? '<div style="font-size:12px;color:#94a3b8;margin-top:12px;">订单号:' . htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') . '</div>' : '';
370 $amountLine = $amount ? '<div style="font-size:14px;color:#475569;margin-top:8px;">支付金额:¥' . htmlspecialchars($amount, ENT_QUOTES, 'UTF-8') . '</div>' : '';
371
372 $links = '';
373 if ($redirect) {
374 $u = htmlspecialchars($redirect, ENT_QUOTES, 'UTF-8');
375 $t = htmlspecialchars($redirectText, ENT_QUOTES, 'UTF-8');
376 $links .= '<a href="' . $u . '" style="display:inline-block;padding:10px 24px;background:#1677ff;color:#fff;text-decoration:none;border-radius:8px;margin:8px;">' . $t . '</a>';
377 }
378 $backUrl = balance_url('balance');
379 $links .= '<a href="' . $backUrl . '" style="display:inline-block;padding:10px 24px;background:#fff;color:#475569;text-decoration:none;border-radius:8px;margin:8px;border:1px solid #e2e8f0;">返回余额页</a>';
380
381 echo <<<HTML
382<!DOCTYPE html>
383<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{$title}</title></head>
384<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;text-align:center;padding:60px 16px;background:#f5f7fa;color:#1e293b;">
385<div style="max-width:440px;margin:0 auto;background:#fff;border-radius:16px;padding:40px 32px;box-shadow:0 4px 24px rgba(15,23,42,.06);">
386<div style="width:64px;height:64px;border-radius:50%;background:{$color}15;color:{$color};font-size:32px;line-height:64px;margin:0 auto 16px;">{$icon}</div>
387<h1 style="font-size:22px;font-weight:600;margin:0 0 8px;color:{$color};">{$title}</h1>
388<p style="color:#475569;margin:0 0 8px;">{$msgEsc}</p>
389{$amountLine}
390{$orderLine}
391<div style="margin-top:24px;">{$links}</div>
392</div>
393</body></html>
394HTML;
395 exit;
396}