[], 'admin' => []]; $GLOBALS['mnbt_plugin_pages'] = ['user' => [], 'admin' => []]; $GLOBALS['mnbt_plugin_menus'] = ['user' => [], 'admin' => []]; $GLOBALS['mnbt_plugin_widgets'] = ['user' => [], 'admin' => []]; $GLOBALS['mnbt_plugin_settings_tabs'] = []; $GLOBALS['mnbt_plugin_meta'] = []; $GLOBALS['mnbt_plugin_current'] = null; $GLOBALS['mnbt_plugin_booted'] = false; // 首页接管与通用路由(V1.81 P2) $GLOBALS['mnbt_plugin_home_handlers'] = []; $GLOBALS['mnbt_plugin_routes'] = []; // 支付插件注册表(V1.81 P3) $GLOBALS['mnbt_plugin_payments'] = []; function mnbt_plugin_ensure_tables() { global $DB; static $done = false; if ($done || !isset($DB) || !is_object($DB)) { return; } $done = true; @$DB->query("CREATE TABLE IF NOT EXISTS `MN_plugin` ( `id` int(11) NOT NULL AUTO_INCREMENT, `slug` varchar(64) NOT NULL, `name` varchar(120) NOT NULL DEFAULT '', `version` varchar(32) NOT NULL DEFAULT '', `enabled` varchar(10) NOT NULL DEFAULT 'false', `config_json` mediumtext, `installed_at` varchar(50) NOT NULL DEFAULT '', `updated_at` varchar(50) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_slug` (`slug`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8"); @$DB->query("CREATE TABLE IF NOT EXISTS `MN_plugin_option` ( `id` int(11) NOT NULL AUTO_INCREMENT, `plugin_slug` varchar(64) NOT NULL, `k` varchar(120) NOT NULL, `v` mediumtext, PRIMARY KEY (`id`), UNIQUE KEY `uk_plugin_k` (`plugin_slug`,`k`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8"); } function mnbt_plugin_slug_valid($slug) { return is_string($slug) && preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/', $slug); } function mnbt_plugin_path($slug = null) { if ($slug === null) { $slug = $GLOBALS['mnbt_plugin_current']; } if (!mnbt_plugin_slug_valid($slug)) { return null; } return MNBT_PLUGIN_ROOT . $slug . '/'; } function mnbt_plugin_url($slug = null, $rel = '') { if ($slug === null) { $slug = $GLOBALS['mnbt_plugin_current']; } if (!mnbt_plugin_slug_valid($slug)) { return ''; } $base = '/app_plugins/' . rawurlencode($slug) . '/'; $rel = ltrim(str_replace('\\', '/', (string)$rel), '/'); if ($rel === '' || strpos($rel, '..') !== false) { return $base; } return $base . $rel; } function mnbt_plugin_read_json($slug) { $dir = mnbt_plugin_path($slug); if ($dir === null || !is_file($dir . 'plugin.json')) { return null; } $raw = @file_get_contents($dir . 'plugin.json'); if ($raw === false || $raw === '') { return null; } $data = json_decode($raw, true); if (!is_array($data)) { return null; } $id = isset($data['id']) ? (string)$data['id'] : $slug; if ($id !== $slug) { $data['id'] = $slug; } $data['id'] = $slug; $data['name'] = isset($data['name']) ? (string)$data['name'] : $slug; $data['version'] = isset($data['version']) ? (string)$data['version'] : '0.0.0'; $data['description'] = isset($data['description']) ? (string)$data['description'] : ''; $data['author'] = isset($data['author']) ? (string)$data['author'] : ''; // requires_plugins:声明本插件依赖的其他插件 slug 列表(必须在数据库存在且启用) if (!isset($data['requires_plugins']) || !is_array($data['requires_plugins'])) { $data['requires_plugins'] = []; } else { $clean = []; foreach ($data['requires_plugins'] as $dep) { $dep = is_string($dep) ? trim($dep) : ''; if ($dep !== '' && mnbt_plugin_slug_valid($dep) && !in_array($dep, $clean, true)) { $clean[] = $dep; } } $data['requires_plugins'] = $clean; } return $data; } /** * 检查插件依赖是否满足。 * * @param string $slug 被检查的插件 slug * @param array $meta 可选,插件的 meta 数据(含 requires_plugins) * @return array ['ok'=>bool, 'missing'=>string[] 未启用的依赖 slug 列表] */ function mnbt_plugin_check_dependencies($slug, $meta = null) { if ($meta === null) { $meta = mnbt_plugin_read_json($slug) ?: []; } $requires = isset($meta['requires_plugins']) && is_array($meta['requires_plugins']) ? $meta['requires_plugins'] : []; $missing = []; foreach ($requires as $dep) { if (!mnbt_plugin_enabled($dep)) { $missing[] = $dep; } } return ['ok' => empty($missing), 'missing' => $missing]; } function mnbt_plugin_scan_disk() { $list = []; if (!is_dir(MNBT_PLUGIN_ROOT)) { return $list; } $dirs = @scandir(MNBT_PLUGIN_ROOT) ?: []; foreach ($dirs as $dir) { if ($dir === '.' || $dir === '..' || !mnbt_plugin_slug_valid($dir)) { continue; } $base = MNBT_PLUGIN_ROOT . $dir; if (!is_dir($base) || !is_file($base . '/plugin.json') || !is_file($base . '/bootstrap.php')) { continue; } $meta = mnbt_plugin_read_json($dir); if ($meta) { $list[$dir] = $meta; } } return $list; } function mnbt_plugin_db_row($slug) { global $DB; if (!mnbt_plugin_slug_valid($slug)) { return null; } mnbt_plugin_ensure_tables(); return $DB->get_row_prepare("SELECT * FROM MN_plugin WHERE slug=? LIMIT 1", [$slug]) ?: null; } function mnbt_plugin_enabled($slug) { $row = mnbt_plugin_db_row($slug); return $row && (($row['enabled'] ?? '') === 'true' || ($row['enabled'] ?? '') === '1'); } function mnbt_plugin_list() { global $DB; mnbt_plugin_ensure_tables(); $disk = mnbt_plugin_scan_disk(); $dbRows = $DB->get_all_prepare("SELECT * FROM MN_plugin WHERE 1") ?: []; $dbMap = []; foreach ($dbRows as $r) { $dbMap[$r['slug']] = $r; } $out = []; foreach ($disk as $slug => $meta) { $row = $dbMap[$slug] ?? null; $out[] = [ 'slug' => $slug, 'name' => $meta['name'], 'version' => $meta['version'], 'description' => $meta['description'], 'author' => $meta['author'], 'meta' => $meta, 'installed' => $row ? true : false, 'enabled' => $row && (($row['enabled'] ?? '') === 'true' || ($row['enabled'] ?? '') === '1'), 'db_version' => $row['version'] ?? '', 'updated_at' => $row['updated_at'] ?? '', ]; } usort($out, function ($a, $b) { return strcmp($a['slug'], $b['slug']); }); return $out; } function mnbt_plugin_run_sql_file($file) { global $DB; if (!is_file($file)) { return true; } $sql = @file_get_contents($file); if ($sql === false || trim($sql) === '') { return true; } $parts = preg_split('/;\s*[\r\n]+/', $sql); foreach ($parts as $stmt) { // 逐行剔除 `--` 注释行,避免「注释 + SQL」同段被整体误判为注释而跳过建表/删表语句 $lines = []; foreach (explode("\n", $stmt) as $line) { $trimmed = ltrim($line); if ($trimmed === '' || strpos($trimmed, '--') === 0) { continue; } $lines[] = $line; } $stmt = trim(implode("\n", $lines)); if ($stmt === '') { continue; } @$DB->query($stmt); } return true; } function mnbt_plugin_install($slug) { global $DB, $date; if (!mnbt_plugin_slug_valid($slug)) { return '插件标识无效'; } $meta = mnbt_plugin_read_json($slug); if (!$meta) { return '未找到 plugin.json'; } if (!is_file(mnbt_plugin_path($slug) . 'bootstrap.php')) { return '缺少 bootstrap.php'; } // 依赖检查:安装时要求所有 requires_plugins 已安装(不要求启用,启用时再检查) $requires = isset($meta['requires_plugins']) && is_array($meta['requires_plugins']) ? $meta['requires_plugins'] : []; foreach ($requires as $dep) { if (!mnbt_plugin_db_row($dep)) { return '本插件依赖的插件尚未安装:' . $dep; } } mnbt_plugin_ensure_tables(); $dir = mnbt_plugin_path($slug); mnbt_plugin_run_sql_file($dir . 'install.sql'); $row = mnbt_plugin_db_row($slug); $now = isset($date) ? $date : date('Y-m-d H:i:s'); if ($row) { $DB->query_prepare( "UPDATE MN_plugin SET name=?, version=?, updated_at=? WHERE slug=?", [$meta['name'], $meta['version'], $now, $slug] ); } else { $DB->query_prepare( "INSERT INTO MN_plugin (slug, name, version, enabled, config_json, installed_at, updated_at) VALUES (?,?,?,?,?,?,?)", [$slug, $meta['name'], $meta['version'], 'false', '', $now, $now] ); } return true; } function mnbt_plugin_set_enabled($slug, $enabled) { global $DB, $date; if (!mnbt_plugin_slug_valid($slug)) { return '插件标识无效'; } $meta = mnbt_plugin_read_json($slug); if (!$meta) { return '插件不存在'; } mnbt_plugin_ensure_tables(); $row = mnbt_plugin_db_row($slug); if (!$row) { $r = mnbt_plugin_install($slug); if ($r !== true) { return $r; } $row = mnbt_plugin_db_row($slug); } // 启用时检查依赖:所有 requires_plugins 必须已启用 if ($enabled) { $dep = mnbt_plugin_check_dependencies($slug, $meta); if (!$dep['ok']) { return '本插件依赖的插件未启用:' . implode(', ', $dep['missing']); } } $flag = $enabled ? 'true' : 'false'; $now = isset($date) ? $date : date('Y-m-d H:i:s'); $DB->query_prepare( "UPDATE MN_plugin SET enabled=?, name=?, version=?, updated_at=? WHERE slug=?", [$flag, $meta['name'], $meta['version'], $now, $slug] ); return true; } function mnbt_plugin_uninstall($slug) { global $DB; if (!mnbt_plugin_slug_valid($slug)) { return '插件标识无效'; } $dir = mnbt_plugin_path($slug); if ($dir) { mnbt_plugin_run_sql_file($dir . 'uninstall.sql'); } mnbt_plugin_ensure_tables(); $DB->query_prepare("DELETE FROM MN_plugin_option WHERE plugin_slug=?", [$slug]); $DB->query_prepare("DELETE FROM MN_plugin WHERE slug=?", [$slug]); return true; } function mnbt_add_action($hook, $callback, $priority = 10) { $hook = (string)$hook; $priority = (int)$priority; if (!isset($GLOBALS['mnbt_plugin_actions'][$hook])) { $GLOBALS['mnbt_plugin_actions'][$hook] = []; } if (!isset($GLOBALS['mnbt_plugin_actions'][$hook][$priority])) { $GLOBALS['mnbt_plugin_actions'][$hook][$priority] = []; } $GLOBALS['mnbt_plugin_actions'][$hook][$priority][] = [ 'cb' => $callback, 'plugin' => $GLOBALS['mnbt_plugin_current'], ]; } function mnbt_add_filter($hook, $callback, $priority = 10) { $hook = (string)$hook; $priority = (int)$priority; if (!isset($GLOBALS['mnbt_plugin_filters'][$hook])) { $GLOBALS['mnbt_plugin_filters'][$hook] = []; } if (!isset($GLOBALS['mnbt_plugin_filters'][$hook][$priority])) { $GLOBALS['mnbt_plugin_filters'][$hook][$priority] = []; } $GLOBALS['mnbt_plugin_filters'][$hook][$priority][] = [ 'cb' => $callback, 'plugin' => $GLOBALS['mnbt_plugin_current'], ]; } function mnbt_do_action($hook) { $args = func_get_args(); array_shift($args); $hook = (string)$hook; if (empty($GLOBALS['mnbt_plugin_actions'][$hook])) { return; } $buckets = $GLOBALS['mnbt_plugin_actions'][$hook]; ksort($buckets, SORT_NUMERIC); foreach ($buckets as $list) { foreach ($list as $item) { $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $item['plugin']; try { call_user_func_array($item['cb'], $args); } catch (Throwable $e) { error_log('[MNBT plugin] action ' . $hook . ' @' . $item['plugin'] . ': ' . $e->getMessage()); } $GLOBALS['mnbt_plugin_current'] = $prev; } } } function mnbt_apply_filters($hook, $value) { $args = func_get_args(); array_shift($args); $hook = (string)$hook; if (empty($GLOBALS['mnbt_plugin_filters'][$hook])) { return $value; } $buckets = $GLOBALS['mnbt_plugin_filters'][$hook]; ksort($buckets, SORT_NUMERIC); foreach ($buckets as $list) { foreach ($list as $item) { $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $item['plugin']; try { $args[0] = $value; $value = call_user_func_array($item['cb'], $args); } catch (Throwable $e) { error_log('[MNBT plugin] filter ' . $hook . ' @' . $item['plugin'] . ': ' . $e->getMessage()); } $GLOBALS['mnbt_plugin_current'] = $prev; } } return $value; } function mnbt_register_ajax($side, $gn, $callback, $auth = null) { $side = $side === 'admin' ? 'admin' : 'user'; $gn = (string)$gn; if ($gn === '' || !is_callable($callback)) { return false; } if (isset($GLOBALS['mnbt_plugin_ajax'][$side][$gn])) { error_log('[MNBT plugin] ajax gn conflict: ' . $side . '/' . $gn); return false; } $GLOBALS['mnbt_plugin_ajax'][$side][$gn] = [ 'cb' => $callback, 'plugin' => $GLOBALS['mnbt_plugin_current'], 'auth' => $auth, ]; return true; } function mnbt_register_page($side, $page, $file, $title = '', $perm = null) { $side = $side === 'admin' ? 'admin' : 'user'; $page = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$page); if ($page === '' || $file === '') { return false; } $slug = $GLOBALS['mnbt_plugin_current']; if (!mnbt_plugin_slug_valid($slug)) { return false; } $GLOBALS['mnbt_plugin_pages'][$side][$slug . ':' . $page] = [ 'plugin' => $slug, 'page' => $page, 'file' => $file, 'title' => $title, 'perm' => $perm, ]; return true; } function mnbt_register_menu($side, $item) { $side = $side === 'admin' ? 'admin' : 'user'; if (!is_array($item) || empty($item['title'])) { return false; } $slug = $GLOBALS['mnbt_plugin_current']; $item['plugin'] = $slug; $item['order'] = isset($item['order']) ? (int)$item['order'] : 50; if (empty($item['children'])) { if (empty($item['url']) && !empty($item['page'])) { $base = $side === 'admin' ? 'plugin.php' : 'plugin.php'; $item['url'] = $base . '?p=' . rawurlencode($slug) . '&page=' . rawurlencode($item['page']); } } else { foreach ($item['children'] as $k => $child) { $item['children'][$k]['plugin'] = $slug; $item['children'][$k]['order'] = isset($child['order']) ? (int)$child['order'] : 50; if (empty($child['url']) && !empty($child['page'])) { $base = $side === 'admin' ? 'plugin.php' : 'plugin.php'; $item['children'][$k]['url'] = $base . '?p=' . rawurlencode($slug) . '&page=' . rawurlencode($child['page']); } if (!empty($child['children'])) { foreach ($child['children'] as $ck => $gc) { $item['children'][$k]['children'][$ck]['plugin'] = $slug; $item['children'][$k]['children'][$ck]['order'] = isset($gc['order']) ? (int)$gc['order'] : 50; if (empty($gc['url']) && !empty($gc['page'])) { $base = $side === 'admin' ? 'plugin.php' : 'plugin.php'; $item['children'][$k]['children'][$ck]['url'] = $base . '?p=' . rawurlencode($slug) . '&page=' . rawurlencode($gc['page']); } } } } } $GLOBALS['mnbt_plugin_menus'][$side][] = $item; return true; } /** * 注册页面接管 —— 让插件接管或包裹主题整页渲染(mnbt_render 调用) * * 当 mnbt_render($view) 被调用时,引擎会按 priority 升序遍历所有注册的 override 回调, * 第一个返回非 null 的值即生效,后续回调不再调用(短路语义)。 * * @param string $scope 'user' 或 'admin' * @param string $view 视图名(如 'set', 'list', 'sy', 'index') * @param callable $callback 签名: function(array $vars): mixed * 返回值三选一: * - null: 不接管(默认行为) * - string: 完全接管,输出该字符串 * - ['before'=>string,'after'=>string]: 包裹模式,原视图前后插入内容 * @param int $priority 优先级(数字越小越先执行),默认 10 * @return bool * * 示例 1:完全接管(替换整页) * mnbt_register_page_override('user', 'set', function ($vars) { * if (($_GET['gn'] ?? '') !== 'my_section') return null; * return '
我的自定义内容
'; * }); * * 示例 2:包裹模式(在原页面前后插入 banner) * mnbt_register_page_override('user', 'index', function ($vars) { * return [ * 'before' => '', * 'after' => '', * ]; * }); * * 示例 3:按请求参数决定是否接管 * mnbt_register_page_override('admin', 'list', function ($vars) { * if (($_GET['gn'] ?? '') === 'plugin_section') { * return render_my_plugin_page(); * } * return null; // 其他 gn 走原逻辑 * }, 5); */ function mnbt_register_page_override($scope, $view, $callback, $priority = 10) { if (!is_callable($callback)) { return false; } $scope = ($scope === 'admin') ? 'admin' : 'user'; $view = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$view); if ($view === '') { return false; } return mnbt_add_filter('render.' . $scope . '.' . $view, $callback, $priority); } /** * 注册 partial 接管 —— 让插件接管或包裹主题局部模板(mnbt_theme_include 调用) * * 当 mnbt_theme_include($view) 被调用时,引擎会按 priority 升序遍历所有注册的 override 回调, * 第一个返回非 null 的值即生效,后续回调不再调用(短路语义)。 * * @param string $scope 'user' 或 'admin' * @param string $view partial 名(如 'head', 'footer', 'sidebar') * @param callable $callback 签名: function(array $vars): mixed * 返回值三选一: * - null: 不接管(默认行为) * - string: 完全接管,输出该字符串 * - ['before'=>string,'after'=>string]: 包裹模式,原 partial 前后插入内容 * @param int $priority 优先级(数字越小越先执行),默认 10 * @return bool * * 示例:在用户端 head 末尾追加自定义 CSS * mnbt_register_partial_override('user', 'head', function ($vars) { * return ['after' => '']; * }); */ function mnbt_register_partial_override($scope, $view, $callback, $priority = 10) { if (!is_callable($callback)) { return false; } $scope = ($scope === 'admin') ? 'admin' : 'user'; $view = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$view); if ($view === '') { return false; } return mnbt_add_filter('include.' . $scope . '.' . $view, $callback, $priority); } function mnbt_plugin_option_get($slug, $key, $default = null) { global $DB; if (!mnbt_plugin_slug_valid($slug) || $key === '') { return $default; } mnbt_plugin_ensure_tables(); $row = $DB->get_row_prepare("SELECT v FROM MN_plugin_option WHERE plugin_slug=? AND k=? LIMIT 1", [$slug, (string)$key]); if (!$row) { return $default; } $v = $row['v']; if (is_string($v) && $v !== '' && ($v[0] === '{' || $v[0] === '[' || $v[0] === '"')) { $j = json_decode($v, true); if (json_last_error() === JSON_ERROR_NONE) { return $j; } } return $v; } function mnbt_plugin_option_set($slug, $key, $value) { global $DB; if (!mnbt_plugin_slug_valid($slug) || $key === '') { return false; } mnbt_plugin_ensure_tables(); if (is_array($value) || is_object($value)) { $value = json_encode($value, JSON_UNESCAPED_UNICODE); } else { $value = (string)$value; } $exist = $DB->get_row_prepare("SELECT id FROM MN_plugin_option WHERE plugin_slug=? AND k=? LIMIT 1", [$slug, (string)$key]); if ($exist) { return (bool)$DB->query_prepare("UPDATE MN_plugin_option SET v=? WHERE plugin_slug=? AND k=?", [$value, $slug, (string)$key]); } return (bool)$DB->query_prepare("INSERT INTO MN_plugin_option (plugin_slug, k, v) VALUES (?,?,?)", [$slug, (string)$key, $value]); } function mnbt_plugin_option_all($slug) { global $DB; if (!mnbt_plugin_slug_valid($slug)) { return []; } mnbt_plugin_ensure_tables(); $rows = $DB->get_all_prepare("SELECT k,v FROM MN_plugin_option WHERE plugin_slug=?", [$slug]) ?: []; $out = []; foreach ($rows as $r) { $out[$r['k']] = mnbt_plugin_option_get($slug, $r['k'], $r['v']); } return $out; } function mnbt_plugin_require_admin() { global $islogin; if (!isset($islogin) || (int)$islogin !== 1) { if (function_exists('json_exit')) { json_exit('请登陆'); } exit('{"code":"请登陆"}'); } } function mnbt_plugin_require_user() { global $islogins; if (!isset($islogins) || (int)$islogins !== 1) { if (function_exists('json_exit')) { json_exit('请登陆'); } exit('{"code":"请登陆"}'); } } function mnbt_plugin_auth_check($auth) { if ($auth === null || $auth === '' || $auth === 'none') { return true; } if ($auth === 'admin') { global $islogin; if (!isset($islogin) || (int)$islogin !== 1) { return false; } return true; } if ($auth === 'user') { global $islogins; if (!isset($islogins) || (int)$islogins !== 1) { return false; } return true; } if (is_callable($auth)) { return (bool)call_user_func($auth); } return false; } function mnbt_plugin_auth_fail($auth) { if ($auth === 'admin') { if (function_exists('json_exit')) { json_exit('请登陆后台'); } exit('{"code":"请登陆后台"}'); } if ($auth === 'user') { if (function_exists('json_exit')) { json_exit('请登陆'); } exit('{"code":"请登陆"}'); } if (is_callable($auth)) { $fnName = is_string($auth) ? $auth : gettype($auth); $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); if (isset($caller[1]['function']) && $caller[1]['function'] === 'mnbt_plugin_dispatch_route') { if (function_exists('user_info_url')) { header('Location: ' . user_info_url('account/login')); exit; } } if (function_exists('json_exit')) { json_exit('请登陆'); } exit('{"code":"请登陆"}'); } if (function_exists('json_exit')) { json_exit('权限不足'); } exit('{"code":"权限不足"}'); } function mnbt_plugin_dispatch_ajax($side, $egn) { $side = $side === 'admin' ? 'admin' : 'user'; $egn = (string)$egn; if ($egn === '' || empty($GLOBALS['mnbt_plugin_ajax'][$side][$egn])) { return false; } $item = $GLOBALS['mnbt_plugin_ajax'][$side][$egn]; if (!mnbt_plugin_auth_check($item['auth'] ?? null)) { mnbt_plugin_auth_fail($item['auth'] ?? null); } mnbt_csrf_validate_request(); $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $item['plugin']; try { call_user_func($item['cb'], $egn, $side); } catch (Throwable $e) { error_log('[MNBT plugin] ajax ' . $side . '/' . $egn . ': ' . $e->getMessage()); if (function_exists('json_exit')) { json_exit('插件接口异常'); } exit('{"code":"插件接口异常"}'); } $GLOBALS['mnbt_plugin_current'] = $prev; return true; } function mnbt_plugin_menus($side) { $side = $side === 'admin' ? 'admin' : 'user'; $items = $GLOBALS['mnbt_plugin_menus'][$side] ?? []; $items = mnbt_apply_filters('menu.' . $side, $items); usort($items, function ($a, $b) { return ($a['order'] ?? 50) - ($b['order'] ?? 50); }); return $items; } function _mnbt_plugin_render_menu_item($it, $depth = 0) { $title = htmlspecialchars($it['title'] ?? '', ENT_QUOTES, 'UTF-8'); $iconHtml = ''; if (!empty($it['icon'])) { $icon = htmlspecialchars($it['icon'], ENT_QUOTES, 'UTF-8'); $iconHtml = ' '; } if (!empty($it['children'])) { $childrenHtml = _mnbt_plugin_render_menu_children($it['children'], $depth + 1); $parentIconHtml = $iconHtml !== '' ? $iconHtml : ' '; return ''; } $url = htmlspecialchars($it['url'] ?? 'javascript:void(0)', ENT_QUOTES, 'UTF-8'); $mt = !empty($it['multitabs']) || strpos($url, 'plugin.php') !== false ? ' multitabs' : ''; return '
  • ' . $iconHtml . $title . '
  • '; } function _mnbt_plugin_render_menu_children($children, $depth = 1) { usort($children, function ($a, $b) { return ($a['order'] ?? 50) - ($b['order'] ?? 50); }); $html = ''; foreach ($children as $child) { $html .= _mnbt_plugin_render_menu_item($child, $depth); } return $html; } /** * 内部:将插件菜单树渲染成 default 主题的侧边栏 HTML(lyear 风格)。 * 作为未注册主题渲染器时的 fallback。 */ function _mnbt_plugin_render_default_menu_html($side) { $side = $side === 'admin' ? 'admin' : 'user'; $items = mnbt_plugin_menus($side); if (!$items) { return ''; } $groups = []; $leafs = []; foreach ($items as $it) { if (!empty($it['children'])) { $groups[] = $it; } else { $leafs[] = $it; } } $html = ''; foreach ($groups as $group) { $html .= _mnbt_plugin_render_menu_item($group, 1); } if (!empty($leafs)) { $leafsHtml = _mnbt_plugin_render_menu_children($leafs, 1); $html .= ''; } return $html; } /** * 渲染插件侧边栏菜单。 * * 引擎优先使用当前主题注册的菜单渲染器(通过 mnbt_register_theme_menu_renderer)。 * 若当前主题没有注册渲染器,则回退到 default 主题结构(lyear 风格)。 * * @param string $side 'user' 或 'admin' * @return string */ function mnbt_plugin_render_menu_side_html($side) { $side = $side === 'admin' ? 'admin' : 'user'; $renderer = $GLOBALS['mnbt_theme_menu_renderers'][$side] ?? null; if (is_callable($renderer)) { $items = mnbt_plugin_menus($side); return (string)call_user_func($renderer, $items); } return _mnbt_plugin_render_default_menu_html($side); } function mnbt_plugin_render_menu_admin_html() { return mnbt_plugin_render_menu_side_html('admin'); } function mnbt_plugin_render_menu_user_html() { return mnbt_plugin_render_menu_side_html('user'); } /** * 注册仪表盘小部件 * $item: title, html|callback, order, class */ function mnbt_register_widget($side, $item) { $side = $side === 'admin' ? 'admin' : 'user'; if (!is_array($item) || (empty($item['title']) && empty($item['html']) && empty($item['callback']))) { return false; } $item['plugin'] = $GLOBALS['mnbt_plugin_current']; $item['order'] = isset($item['order']) ? (int)$item['order'] : 50; $GLOBALS['mnbt_plugin_widgets'][$side][] = $item; return true; } function mnbt_plugin_widgets($side) { $side = $side === 'admin' ? 'admin' : 'user'; $items = $GLOBALS['mnbt_plugin_widgets'][$side] ?? []; $items = mnbt_apply_filters('dashboard.' . $side . '.widgets', $items); usort($items, function ($a, $b) { return ($a['order'] ?? 50) - ($b['order'] ?? 50); }); return $items; } function mnbt_plugin_render_widgets_html($side) { $items = mnbt_plugin_widgets($side); if (!$items) { return ''; } $html = '
    '; foreach ($items as $it) { $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $it['plugin'] ?? null; $body = ''; if (!empty($it['callback']) && is_callable($it['callback'])) { ob_start(); try { call_user_func($it['callback'], $side); } catch (Throwable $e) { echo '小部件错误'; error_log('[MNBT plugin] widget: ' . $e->getMessage()); } $body = ob_get_clean(); } else { $body = (string)($it['html'] ?? ''); } $GLOBALS['mnbt_plugin_current'] = $prev; $title = htmlspecialchars($it['title'] ?? '', ENT_QUOTES, 'UTF-8'); $col = htmlspecialchars($it['class'] ?? 'col-sm-6', ENT_QUOTES, 'UTF-8'); $html .= '

    ' . $title . '

    ' . $body . '
    '; } $html .= '
    '; return $html; } /** * 注册插件设置页签(出现在插件管理页或独立入口) * $item: id, title, page|url, order */ function mnbt_register_settings_tab($item) { if (!is_array($item) || empty($item['title'])) { return false; } $slug = $GLOBALS['mnbt_plugin_current']; $item['plugin'] = $slug; $item['order'] = isset($item['order']) ? (int)$item['order'] : 50; if (empty($item['url']) && !empty($item['page'])) { $item['url'] = 'plugin.php?p=' . rawurlencode($slug) . '&page=' . rawurlencode($item['page']); } $GLOBALS['mnbt_plugin_settings_tabs'][] = $item; return true; } function mnbt_plugin_settings_tabs() { $items = $GLOBALS['mnbt_plugin_settings_tabs'] ?? []; $items = mnbt_apply_filters('settings.admin.tabs', $items); usort($items, function ($a, $b) { return ($a['order'] ?? 50) - ($b['order'] ?? 50); }); return $items; } /** * 安全 HTTP 请求(仅 http/https) * @return array{ok:bool,code:int,body:string,error:string,headers:array} */ function mnbt_http_request($method, $url, $body = null, $opts = []) { $out = ['ok' => false, 'code' => 0, 'body' => '', 'error' => '', 'headers' => []]; $url = trim((string)$url); if ($url === '' || !preg_match('#^https?://#i', $url)) { $out['error'] = '仅允许 http/https URL'; return $out; } $parts = @parse_url($url); if (!$parts || empty($parts['host'])) { $out['error'] = 'URL 无效'; return $out; } $host = strtolower($parts['host']); if ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1' || preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $host)) { if (empty($opts['allow_private'])) { $out['error'] = '禁止访问内网地址'; return $out; } } if (!function_exists('curl_init')) { $out['error'] = 'curl 不可用'; return $out; } $method = strtoupper($method ?: 'GET'); $timeout = isset($opts['timeout']) ? max(1, (int)$opts['timeout']) : 15; $headers = isset($opts['headers']) && is_array($opts['headers']) ? $opts['headers'] : []; $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, 3); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, !empty($opts['insecure']) ? false : true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, !empty($opts['insecure']) ? 0 : 2); curl_setopt($ch, CURLOPT_USERAGENT, $opts['user_agent'] ?? 'MNBT-Plugin/1.83'); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); if ($body !== null) { if (is_array($body)) { $body = json_encode($body, JSON_UNESCAPED_UNICODE); $hasCt = false; foreach ($headers as $h) { if (stripos($h, 'Content-Type:') === 0) { $hasCt = true; break; } } if (!$hasCt) { $headers[] = 'Content-Type: application/json; charset=utf-8'; } } curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } } elseif ($method !== 'GET') { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($body) ? json_encode($body, JSON_UNESCAPED_UNICODE) : $body); } } if ($headers) { curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } $resp = curl_exec($ch); $err = curl_error($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($resp === false) { $out['error'] = $err ?: '请求失败'; $out['code'] = $code; return $out; } $out['ok'] = $code >= 200 && $code < 300; $out['code'] = $code; $out['body'] = (string)$resp; if (!$out['ok'] && $out['error'] === '') { $out['error'] = 'HTTP ' . $code; } return $out; } function mnbt_http_get($url, $opts = []) { return mnbt_http_request('GET', $url, null, $opts); } function mnbt_http_post($url, $body = null, $opts = []) { return mnbt_http_request('POST', $url, $body, $opts); } /** 当前插件 slug(bootstrap/钩子回调内有效) */ function mnbt_plugin_id() { return $GLOBALS['mnbt_plugin_current']; } function mnbt_plugin_find_page($side, $plugin, $page) { $key = $plugin . ':' . $page; return $GLOBALS['mnbt_plugin_pages'][$side][$key] ?? null; } function mnbt_plugin_render_page($side, $plugin, $page) { mnbt_csrf_validate_request(); $side = $side === 'admin' ? 'admin' : 'user'; $info = mnbt_plugin_find_page($side, $plugin, $page); if (!$info) { http_response_code(404); echo '插件页面不存在'; return false; } $file = $info['file']; if ($file[0] !== '/' && strpos($file, ':') === false) { $file = mnbt_plugin_path($plugin) . ltrim(str_replace('\\', '/', $file), '/'); } $realPlugin = realpath(mnbt_plugin_path($plugin)); $realFile = realpath($file); if ($realPlugin === false || $realFile === false || strpos($realFile, $realPlugin) !== 0 || !is_file($realFile)) { http_response_code(404); echo '插件页面文件无效'; return false; } $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $plugin; extract($GLOBALS, EXTR_SKIP); $title = $info['title'] ?: ($plugin . ' / ' . $page); $bufferLevel = ob_get_level(); ob_start('mnbt_csrf_inject_html'); try { include $realFile; } finally { while (ob_get_level() > $bufferLevel) ob_end_flush(); $GLOBALS['mnbt_plugin_current'] = $prev; } return true; } function mnbt_plugin_register($id, $meta = []) { if (!mnbt_plugin_slug_valid($id)) { return false; } $GLOBALS['mnbt_plugin_meta'][$id] = is_array($meta) ? $meta : []; $GLOBALS['mnbt_plugin_meta'][$id]['id'] = $id; return true; } function mnbt_plugins_boot() { global $DB; if (!empty($GLOBALS['mnbt_plugin_booted'])) { return; } $GLOBALS['mnbt_plugin_booted'] = true; if (!is_dir(MNBT_PLUGIN_ROOT)) { @mkdir(MNBT_PLUGIN_ROOT, 0755, true); } mnbt_plugin_ensure_tables(); $rows = @$DB->get_all_prepare("SELECT * FROM MN_plugin WHERE enabled=? OR enabled=?", ['true', '1']) ?: []; foreach ($rows as $row) { $slug = $row['slug'] ?? ''; if (!mnbt_plugin_slug_valid($slug)) { continue; } $boot = mnbt_plugin_path($slug) . 'bootstrap.php'; if (!is_file($boot)) { continue; } $meta = mnbt_plugin_read_json($slug); // 运行时依赖检查:依赖插件未启用则跳过 boot(防止调用未定义函数) $dep = mnbt_plugin_check_dependencies($slug, $meta); if (!$dep['ok']) { error_log('[MNBT plugin] ' . $slug . ' 依赖未满足,跳过 boot:' . implode(', ', $dep['missing'])); continue; } $GLOBALS['mnbt_plugin_current'] = $slug; $GLOBALS['mnbt_plugin_meta'][$slug] = $meta ?: ['id' => $slug]; try { include $boot; } catch (Throwable $e) { error_log('[MNBT plugin] boot ' . $slug . ': ' . $e->getMessage()); } $GLOBALS['mnbt_plugin_current'] = null; } mnbt_do_action('boot'); global $islogin, $islogins; if (isset($islogin) && (int)$islogin === 1) { mnbt_do_action('init.admin'); } if (isset($islogins) && (int)$islogins === 1) { mnbt_do_action('init.user'); } } /** * ============================================================ * V1.81 P2:首页接管与通用路由 * ============================================================ */ /** * 计算当前请求相对于站点根目录的路径(去掉 base path 与查询串)。 * 用于子目录部署:https://example.com/mnbt/landing?x=1 → /landing * @return array{path:string,method:string,base:string} */ function mnbt_plugin_request_info() { $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : ''; $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/'); if ($basePath === '.' || $basePath === '/') { $basePath = ''; } // 支持通过查询参数 _r 传递路由路径(无需 Web 服务器 rewrite 的兼容方案) if (isset($_GET['_r']) && is_string($_GET['_r']) && $_GET['_r'] !== '') { $path = $_GET['_r']; } else { $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; $path = parse_url($uri, PHP_URL_PATH) ?: ''; // 去掉 base path 前缀 if ($basePath !== '' && strpos($path, $basePath) === 0) { $path = substr($path, strlen($basePath)); } } if ($path === '' || $path === false) { $path = '/'; } // 规范化:确保以 / 开头 if ($path !== '' && $path[0] !== '/') { $path = '/' . $path; } $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; return ['path' => $path, 'method' => $method, 'base' => $basePath]; } /** * 注册首页接管回调。 * * 回调签名:function (array $ctx): mixed * - 返回 string → 视为重定向 URL,引擎会 header("Location: ...") + exit * - 返回 true → 视为已渲染(回调内自行 echo),引擎直接 exit * - 返回 false/null → 不接管,继续下一个回调 * * @param callable $callback * @param int $priority 数字越小越先执行(默认 10) * @return bool */ function mnbt_register_home($callback, $priority = 10) { if (!is_callable($callback)) { return false; } $priority = (int)$priority; if (!isset($GLOBALS['mnbt_plugin_home_handlers'][$priority])) { $GLOBALS['mnbt_plugin_home_handlers'][$priority] = []; } $GLOBALS['mnbt_plugin_home_handlers'][$priority][] = [ 'cb' => $callback, 'plugin' => $GLOBALS['mnbt_plugin_current'], ]; return true; } /** * 分发首页接管。 * 由根目录 index.php 在请求路径为 / 时调用。 * * @return bool true 表示已被插件接管(请求已终止);false 表示无插件接管,调用方走默认逻辑 */ function mnbt_plugin_dispatch_home() { if (empty($GLOBALS['mnbt_plugin_home_handlers'])) { return false; } $info = mnbt_plugin_request_info(); // 仅当路径为 / 时才视作"首页"请求 if ($info['path'] !== '/') { return false; } $buckets = $GLOBALS['mnbt_plugin_home_handlers']; ksort($buckets, SORT_NUMERIC); $ctx = [ 'path' => $info['path'], 'method' => $info['method'], 'base' => $info['base'], ]; foreach ($buckets as $list) { foreach ($list as $item) { $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $item['plugin']; try { $result = call_user_func($item['cb'], $ctx); } catch (Throwable $e) { error_log('[MNBT plugin] home @' . ($item['plugin'] ?? '?') . ': ' . $e->getMessage()); $GLOBALS['mnbt_plugin_current'] = $prev; continue; } $GLOBALS['mnbt_plugin_current'] = $prev; // 返回字符串 → 重定向 if (is_string($result) && $result !== '') { header('Location: ' . $result); exit; } // 返回 true → 已渲染 if ($result === true) { exit; } // false / null / 其他 → 不接管,继续 } } return false; } /** * 注册通用路由。 * * 路径支持命名参数:/promo/{id} → 匹配 /promo/123,回调收到 ['id'=>'123'] * 路径必须以 / 开头;尾斜杠可选(自动同时匹配带/不带尾斜杠两种形式)。 * * 回调签名:function (array $params, array $ctx): mixed * - 回调内自行 echo 输出,返回 true 或不返回(null)→ 引擎 exit 终止 * - 返回 false → 不接管,继续匹配下一个路由 * * @param string $method 'GET'/'POST'/'PUT'/'DELETE'/'HEAD'/'*'(* 匹配任意) * @param string $path 如 '/landing' 或 '/promo/{id}' * @param callable $callback * @param int $priority * @param string|callable|null $auth 鉴权要求:null/'none'=无验证, 'admin'=管理员, 'user'=用户, 回调函数=自定义验证 * @return bool */ function mnbt_register_route($method, $path, $callback, $priority = 10, $auth = null, $csrfExempt = false) { if (!is_callable($callback)) { return false; } $method = strtoupper((string)$method); if ($method === '') { $method = '*'; } $path = (string)$path; if ($path === '' || $path[0] !== '/') { $path = '/' . $path; } $paramNames = []; $regex = preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', function ($m) use (&$paramNames) { $paramNames[] = $m[1]; return '([^/]+)'; }, $path); $regex = '#^' . $regex . '/?$#'; $priority = (int)$priority; if (!isset($GLOBALS['mnbt_plugin_routes'][$priority])) { $GLOBALS['mnbt_plugin_routes'][$priority] = []; } $GLOBALS['mnbt_plugin_routes'][$priority][] = [ 'method' => $method, 'path' => $path, 'regex' => $regex, 'params' => $paramNames, 'cb' => $callback, 'plugin' => $GLOBALS['mnbt_plugin_current'], 'auth' => $auth, 'csrf_exempt' => (bool)$csrfExempt, ]; return true; } /** * 分发通用路由。 * 由 index.php 或 _router.php 在请求未命中实际文件时调用。 * * @return bool true 表示已匹配并由插件处理(请求已终止);false 表示无匹配 */ function mnbt_plugin_dispatch_route() { if (empty($GLOBALS['mnbt_plugin_routes'])) { return false; } $info = mnbt_plugin_request_info(); $buckets = $GLOBALS['mnbt_plugin_routes']; ksort($buckets, SORT_NUMERIC); foreach ($buckets as $list) { foreach ($list as $item) { if ($item['method'] !== '*' && $item['method'] !== $info['method']) { continue; } if (!preg_match($item['regex'], $info['path'], $matches)) { continue; } $params = []; array_shift($matches); foreach ($item['params'] as $i => $name) { $params[$name] = isset($matches[$i]) ? $matches[$i] : ''; } if (!mnbt_plugin_auth_check($item['auth'] ?? null)) { mnbt_plugin_auth_fail($item['auth'] ?? null); } mnbt_csrf_validate_request($item['csrf_exempt'] ?? false); $prev = $GLOBALS['mnbt_plugin_current']; $GLOBALS['mnbt_plugin_current'] = $item['plugin']; $ctx = [ 'path' => $info['path'], 'method' => $info['method'], 'base' => $info['base'], 'plugin' => $item['plugin'], 'route' => $item['path'], ]; $bufferLevel = ob_get_level(); ob_start('mnbt_csrf_inject_html'); try { $result = call_user_func($item['cb'], $params, $ctx); if (is_string($result) && $result !== '') { if (!headers_sent()) header('Content-Type: text/html; charset=UTF-8'); echo $result; } while (ob_get_level() > $bufferLevel) ob_end_flush(); } catch (Throwable $e) { while (ob_get_level() > $bufferLevel) ob_end_clean(); error_log('[MNBT plugin] route ' . $item['method'] . ' ' . $item['path'] . ' @' . ($item['plugin'] ?? '?') . ': ' . $e->getMessage()); $GLOBALS['mnbt_plugin_current'] = $prev; // 路由已匹配但回调抛异常:渲染错误页终止请求, // 不再静默 continue 落到默认行为(曾导致用户端被跳转到核心虚拟主机登录页) if (!headers_sent()) { http_response_code(500); @header('Content-Type: text/html; charset=UTF-8'); } echo '页面错误' . '' . '

    页面处理出错

    ' . '

    路由 ' . htmlspecialchars($item['method'] . ' ' . $item['path'], ENT_QUOTES, 'UTF-8') . ' 处理异常,请稍后重试或联系管理员。

    ' . '
    ' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '
    ' . '
    '; exit; } $GLOBALS['mnbt_plugin_current'] = $prev; if ($result === false) { continue; } exit; } } return false; } /** * ============================================================ * V1.81 P3:支付插件系统 * ============================================================ * * 支付方式 type 格式:{plugin_id}__{method_id} * 例:epay__alipay、alipay_official__pc * * 支付设置存储:MN_config.pay_methods 字段(JSON) * [{"plugin":"epay","method":"alipay","display_name":"支付宝","icon":"mdi-puzzle","sort":1}, ...] * * 插件 API 凭证存储:MN_plugin_option 表(通过 mnbt_plugin_option_get/set) */ /** * 注册支付插件。 * * @param string $plugin_id 插件标识(即 slug) * @param array $config [ * 'name' => '易支付', * 'description' => '彩虹易支付协议', * 'methods' => [ * 'alipay' => ['label'=>'支付宝', 'icon'=>'mdi-puzzle'], * 'wxpay' => ['label'=>'微信支付', 'icon'=>'mdi-wechat'], * ], * 'build' => function ($method, $order, $plugin_config) { * // $method: 'alipay' * // $order: ['out_trade_no'=>..., 'name'=>..., 'money'=>..., 'notify_url'=>..., 'return_url'=>..., 'order_row'=>...] * // $plugin_config: 该插件的所有选项(来自 MN_plugin_option) * // 返回 HTML 表单字符串,或返回 false 表示不接管 * }, * ] * @return bool */ function mnbt_register_payment($plugin_id, $config) { $plugin_id = (string)$plugin_id; if ($plugin_id === '' || !is_array($config)) { return false; } // 校验 build 回调 if (!isset($config['build']) || !is_callable($config['build'])) { return false; } if (!isset($config['methods']) || !is_array($config['methods'])) { $config['methods'] = []; } $config['plugin_id'] = $plugin_id; $GLOBALS['mnbt_plugin_payments'][$plugin_id] = $config; return true; } /** * 获取所有已注册的支付插件。 * @return array ['epay' => ['name'=>..., 'methods'=>[...]], ...] */ function mnbt_get_payment_plugins() { return isset($GLOBALS['mnbt_plugin_payments']) ? $GLOBALS['mnbt_plugin_payments'] : []; } /** * 构造支付方式的 type 标识。 * @param string $plugin_id * @param string $method_id * @return string 如 "epay__alipay" */ function mnbt_pay_type($plugin_id, $method_id) { return $plugin_id . '__' . $method_id; } /** * 从 type 解析出 plugin_id 和 method_id。 * @param string $type * @return array|false ['plugin'=>'epay', 'method'=>'alipay'] 或 false */ function mnbt_pay_parse_type($type) { $type = (string)$type; if (strpos($type, '__') === false) { return false; } $parts = explode('__', $type, 2); if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { return false; } return ['plugin' => $parts[0], 'method' => $parts[1]]; } /** * 获取已启用的付款方式列表(从 MN_config.pay_methods JSON 解析)。 * @return array [['plugin'=>..., 'method'=>..., 'display_name'=>..., 'icon'=>..., 'sort'=>...], ...] 按 sort 排序 */ function mnbt_get_enabled_payment_methods() { global $DB, $siteid; if (!isset($DB)) { return []; } $siteid = isset($siteid) ? $siteid : 1; $row = $DB->get_row_prepare("SELECT pay_methods FROM MN_config WHERE id=? LIMIT 1", [$siteid]); if (!$row || empty($row['pay_methods'])) { return []; } $list = json_decode($row['pay_methods'], true); if (!is_array($list)) { return []; } // 按 sort 排序 usort($list, function ($a, $b) { $sa = isset($a['sort']) ? (int)$a['sort'] : 99; $sb = isset($b['sort']) ? (int)$b['sort'] : 99; return $sa - $sb; }); return $list; } /** * 保存付款方式配置到 MN_config.pay_methods。 * @param array $methods [['plugin'=>..., 'method'=>..., 'display_name'=>..., 'icon'=>..., 'sort'=>...], ...] * @return bool */ function mnbt_save_payment_methods($methods) { global $DB, $siteid; if (!isset($DB)) { return false; } $siteid = isset($siteid) ? $siteid : 1; $json = json_encode($methods, JSON_UNESCAPED_UNICODE); return (bool)$DB->query_prepare("UPDATE MN_config SET pay_methods=? WHERE id=?", [$json, $siteid]); } /** * 分发支付网关:根据 type 找到对应插件的 build 回调并调用。 * * @param string $type 支付方式 type,如 epay__alipay * @param array $order_context 订单上下文 ['out_trade_no'=>..., 'name'=>..., 'money'=>..., ...] * @return string|false HTML 表单字符串,或 false 表示无插件接管 */ function mnbt_pay_dispatch_gateway($type, $order_context) { // V1.84: 支付分发前统一钩子,供实名认证等插件在支付发起前拦截(无插件注册时返回 null 直接放行) $guard = mnbt_apply_filters('pay.dispatch.before', null, $type, $order_context); if (is_string($guard) && $guard !== '') { return $guard; } $parsed = mnbt_pay_parse_type($type); if (!$parsed) { return false; } $plugin_id = $parsed['plugin']; $method_id = $parsed['method']; if (!isset($GLOBALS['mnbt_plugin_payments'][$plugin_id])) { return false; } $payment = $GLOBALS['mnbt_plugin_payments'][$plugin_id]; if (!isset($payment['methods'][$method_id])) { return false; } $cb = $payment['build']; if (!is_callable($cb)) { return false; } // 加载插件选项作为配置 $plugin_config = function_exists('mnbt_plugin_option_all') ? mnbt_plugin_option_all($plugin_id) : []; $prev = isset($GLOBALS['mnbt_plugin_current']) ? $GLOBALS['mnbt_plugin_current'] : null; $GLOBALS['mnbt_plugin_current'] = $plugin_id; try { $result = call_user_func($cb, $method_id, $order_context, $plugin_config); } catch (Throwable $e) { error_log('[MNBT plugin] payment build @' . $plugin_id . '::' . $method_id . ': ' . $e->getMessage()); $GLOBALS['mnbt_plugin_current'] = $prev; return false; } $GLOBALS['mnbt_plugin_current'] = $prev; return $result; }