'; } function csrf_check(): bool { $token = $_POST['_csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); return isset($_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], $token); } /** 当前管理员是否已登录 */ function is_admin(): bool { return !empty($_SESSION['admin_logged']); } function admin_required() { if (!is_admin()) { header('Location: ' . site_url('admin/login')); exit; } } /** 当前登录管理员的角色:super_admin | admin | user | none */ function admin_role(): string { return $_SESSION['admin_role'] ?? 'user'; } /** 当前登录管理员 ID(配置文件兜底登录时为 0) */ function admin_uid(): ?int { return $_SESSION['admin_id'] ?? null; } /** 角色 -> 能力映射(可访问的模块/操作) */ function admin_role_map(): array { return [ 'super_admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'settings', 'theme', 'users', 'system', 'orders', 'payments', 'password'], 'admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'orders', 'password'], // user 为受限角色:默认仅仪表盘权限,不预开 products/news/cases 等后台模块。 // 进入 CRM/PSI 后左导据此严格收敛——CRM 操作员(后台角色=user)只看到仪表盘 + 当前子系统功能, // 其余后台模块由其是否拥有对应 admin 能力决定;后台管理员/超管不受影响(见 admin / super_admin 行)。 'user' => ['dashboard', 'password'], // none = 无后台主角色:该账号仅作为 CRM/PSI 子系统账号存在,不拥有任何后台模块权限。 // 进入子系统后左导仍按 subsys_role 严格收敛,避免与「用户」混淆导致角色分配混乱。 'none' => ['password'], ]; } /** 当前登录管理员是否具备某项能力 */ function admin_can(string $cap): bool { $role = admin_role(); return in_array($cap, admin_role_map()[$role] ?? [], true); } /** 角色中文名 */ function admin_role_label(string $role): string { return ['super_admin' => '超级管理员', 'admin' => '管理员', 'user' => '用户', 'none' => '无'][$role] ?? '用户'; } /** 必须是指定角色,否则拦截(用于控制器构造函数) */ function role_required(string $role): void { if (!is_admin()) { header('Location: ' . site_url('admin/login')); exit; } if (admin_role() !== $role) { \Core\App::forbidden('需要 ' . admin_role_label($role) . ' 权限'); exit; } } /** 根据种子生成品牌渐变(用于占位图) */ function gradient($seed = 0): string { $angle = 110 + (intval($seed) * 37) % 160; return "linear-gradient({$angle}deg,var(--c-primary),var(--c-secondary))"; } /* ---------- 分系统权限(CRM / 进销存 PSI) ---------- */ /** * 用户在某业务系统中的角色。super_admin 在任意系统都视为最高权限管理员。 * @param string $sys crm | psi */ function subsys_role(string $sys): string { if (admin_role() === 'super_admin') return 'super_admin'; $key = $sys . '_role'; return $_SESSION[$key] ?? 'none'; } /** 是否为某系统的管理员(含超管) */ function subsys_admin(string $sys): bool { return in_array(subsys_role($sys), ['super_admin', 'admin'], true); } /** 是否为某系统的用户(含管理员、超管) */ function subsys_user(string $sys): bool { return subsys_role($sys) !== 'none'; } /** 是否能进入某业务系统(拥有该系统任意角色即可:超管/管理员/用户) */ function subsys_can_enter(string $sys): bool { return subsys_user($sys); } /** 分系统页面清单(key 与子系统 nav 的 k 对应;dashboard 始终可见) */ function subsys_pages(string $sys): array { return $sys === 'psi' ? ['dashboard', 'materials', 'products', 'suppliers', 'purchases', 'sales', 'stock', 'orders', 'sales_orders', 'purchase_orders', 'outbounds', 'reports', 'reminders'] : ['dashboard', 'customers', 'leads', 'followups', 'contacts']; } /** 当前 PSI 用户未读的紧急事件数量(用于导航铃铛徽标) */ function psi_unread_events(): int { if (!subsys_user('psi')) return 0; $uid = (int) ($_SESSION['admin_uid'] ?? 0); if ($uid <= 0) return 0; try { $events = (new \App\Models\PSI\Event())->all(); } catch (\Throwable $e) { return 0; } $n = 0; foreach ($events as $ev) { $read = json_decode($ev['read_by'] ?? '[]', true) ?: []; if (!in_array($uid, $read, true)) $n++; } return $n; } /** * 当前登录用户在 $sys 系统各页面的「可见」权限数组。 * 子系统管理员(含超管,subsys_admin)拥有该系统全部页面; * 仅“用户”角色读 session 中的 {sys}_perms(登录时写入)做细粒度控制; * 旧账号无 perms 记录则默认全部可见(向后兼容,不会突然锁死)。 */ function subsys_page_perms(string $sys): array { // 关键修复:以“分系统角色”判定管理员,而非仅看主角色是否为 super_admin。 // 否则 CRM/PSI 管理员(crm_role=admin、主角色为“管理员”)会被误判为普通用户, // 一旦 {sys}_perms 受限就只剩仪表盘,导致左导菜单残缺、子页面 403。 if (subsys_admin($sys)) { return array_fill_keys(subsys_pages($sys), true) + ['dashboard' => true]; } $perms = $_SESSION[$sys . '_perms'] ?? null; $out = ['dashboard' => true]; foreach (subsys_pages($sys) as $p) { if ($p === 'dashboard') continue; $out[$p] = ($perms === null) ? true : !empty($perms[$p]); } return $out; } /** 当前用户能否进入 $sys 系统的某页面 */ function subsys_page_can(string $sys, string $page): bool { return !empty(subsys_page_perms($sys)[$page]); } /** 过滤子系统侧边导航:隐藏无权限页面项(dashboard 永留) */ function subsys_filter_nav(string $sys, array $nav): array { return array_values(array_filter($nav, function ($n) use ($sys) { if (($n['k'] ?? '') === 'dashboard') return true; return subsys_page_can($sys, $n['k']); })); } /** * 登录后落地页:依据子系统权限优先进入对应子系统仪表盘。 * 设计目标:拥有 CRM / PSI 权限的账号登录后直达「CRM / PSI 仪表盘」, * 而非总后台仪表盘;总后台仪表盘仅留给「无任何子系统权限」的纯后台账号。 * - 仅拥有 CRM:进入 CRM 仪表盘 * - 仅拥有 PSI:进入 PSI 仪表盘 * - 同时拥有 CRM+PSI:默认进入 CRM 仪表盘(左侧导航可切换 PSI) * - 无任何子系统权限(纯内容管理员 / 编辑):进入总后台仪表盘 */ function login_landing(): string { $crm = subsys_user('crm'); $psi = subsys_user('psi'); if ($crm && !$psi) return 'CRM'; if ($psi && !$crm) return 'PSI'; if ($crm && $psi) return 'CRM'; return 'admin'; } /** * 后台(admin)左侧导航全量项。admin 主后台与 CRM / PSI 子系统布局共用, * 保证「后台框架一致」:进入 CRM / PSI 后左侧仍是同一套完整后台菜单(当前子系统主项高亮)。 * 各页面项按角色能力(admin_can)过滤,子系统入口按 subsys_user 显隐, * 与 App 路由层的 capMap / 权限拦截保持一致。 */ function admin_nav_items(): array { $baseItems = [ ['k' => '', 'label' => '仪表盘', 'ic' => 'dashboard', 'url' => 'admin'], ['k' => 'products', 'label' => '产品管理', 'ic' => 'snowflake', 'url' => 'admin/products'], ['k' => 'categories', 'label' => '分类管理', 'ic' => 'folders', 'url' => 'admin/categories'], ['k' => 'news', 'label' => '新闻管理', 'ic' => 'newspaper', 'url' => 'admin/news'], ['k' => 'cases', 'label' => '客户案例', 'ic' => 'handshake', 'url' => 'admin/cases'], ['k' => 'pages', 'label' => '单页管理', 'ic' => 'file-text', 'url' => 'admin/pages'], ['k' => 'banners', 'label' => '轮播管理', 'ic' => 'images', 'url' => 'admin/banners'], ['k' => 'settings', 'label' => '站点设置', 'ic' => 'settings', 'url' => 'admin/settings'], ['k' => 'theme', 'label' => '风格设置', 'ic' => 'palette', 'url' => 'admin/theme'], ]; $nav = []; foreach ($baseItems as $it) { // 仪表盘始终可见;其余按角色能力 admin_can 过滤(无权限则隐藏且不可直访) if ($it['k'] === '' || admin_can($it['k'])) $nav[] = $it; } // 子系统入口:拥有对应系统角色的管理员可见(点击进入 CRM / PSI) if (subsys_user('crm')) $nav[] = ['k' => 'crm', 'label' => '客户管理 CRM', 'ic' => 'handshake', 'url' => 'CRM']; if (subsys_user('psi')) $nav[] = ['k' => 'psi', 'label' => '进销存 PSI', 'ic' => 'package', 'url' => 'PSI']; // 仅超级管理员可见「用户管理 / 订单管理 / 支付设置 / 系统设置 / 数据库管理 / 数据库升级」 if (admin_role() === 'super_admin') { $nav[] = ['k' => 'users', 'label' => '用户管理', 'ic' => 'users', 'url' => 'admin/users']; $nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders']; $nav[] = ['k' => 'payments', 'label' => '支付设置', 'ic' => 'wallet', 'url' => 'admin/payments']; $nav[] = ['k' => 'system', 'label' => '系统设置', 'ic' => 'settings', 'url' => 'admin/system']; $nav[] = ['k' => 'db', 'label' => '数据库管理', 'ic' => 'database', 'url' => 'admin/db']; $nav[] = ['k' => 'upgrade', 'label' => '数据库升级', 'ic' => 'upload', 'url' => 'admin/upgrade', 'badge' => (\Core\Db::driver() === 'mysql' ? db_pending_upgrades() : 0) ?: null]; } elseif (admin_role() === 'admin') { $nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders']; } return $nav; } /** * 分系统(CRM/PSI)侧边导航:统一来源,含「用户管理」(仅该系统管理员可见)。 * 与 layouts/subsys.php 共用,避免逐个控制器重复维护导航数组。 */ function subsys_nav(string $sys): array { $pages = $sys === 'psi' ? [ ['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'PSI'], ['k' => 'materials', 'label' => '物料管理', 'url' => 'PSI/materials'], ['k' => 'products', 'label' => '成品管理', 'url' => 'PSI/products'], ['k' => 'suppliers', 'label' => '供应商', 'url' => 'PSI/suppliers'], ['k' => 'purchases', 'label' => '采购入库', 'url' => 'PSI/purchases'], ['k' => 'sales', 'label' => '销售出库', 'url' => 'PSI/sales'], ['k' => 'stock', 'label' => '库存流水', 'url' => 'PSI/stock'], ['k' => 'orders', 'label' => '订单管理', 'url' => 'PSI/orders'], ['k' => 'sales_orders', 'label' => '销售订单', 'url' => 'PSI/sales_orders'], ['k' => 'purchase_orders', 'label' => '采购订单', 'url' => 'PSI/purchase_orders'], ['k' => 'outbounds', 'label' => '出库单', 'url' => 'PSI/outbounds'], ['k' => 'reports', 'label' => '报表中心', 'url' => 'PSI/reports'], ['k' => 'reminders', 'label' => '紧急提醒', 'url' => 'PSI/reminders'], ] : [ ['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'CRM'], ['k' => 'customers', 'label' => '客户管理', 'url' => 'CRM/customers'], ['k' => 'leads', 'label' => '商机线索', 'url' => 'CRM/leads'], ['k' => 'followups', 'label' => '跟进记录', 'url' => 'CRM/followups'], ['k' => 'contacts', 'label' => '客户联系人', 'url' => 'CRM/contacts'], ]; // 仅该系统管理员可管理本系统用户 if (subsys_admin($sys)) { $pages[] = ['k' => 'users', 'label' => '用户管理', 'url' => strtoupper($sys) . '/users']; if ($sys === 'psi') { $pages[] = ['k' => 'notifications', 'label' => '通知设置', 'url' => 'PSI/notifications']; } } // 仅主角色为超管/管理员时显示「管理后台」入口 if (in_array(admin_role(), ['super_admin', 'admin'], true)) { $pages[] = ['k' => 'admin', 'label' => '管理后台', 'url' => 'admin']; } return $pages; } /** PSI 系统完整导航(含订单/出库/报表,所有控制器统一调用) */ function psi_nav(): array { return [ ['k' => 'dashboard', 'label' => '仪表盘', 'icon' => admin_icon('home', 16) ?: '📊', 'url' => 'PSI'], ['k' => 'materials', 'label' => '物料管理', 'icon' => admin_icon('box', 16) ?: '🧵', 'url' => 'PSI/materials'], ['k' => 'products', 'label' => '成品管理', 'icon' => admin_icon('shirt', 16) ?: '👕', 'url' => 'PSI/products'], ['k' => 'suppliers', 'label' => '供应商', 'icon' => admin_icon('buildings', 16) ?: '🏭', 'url' => 'PSI/suppliers'], ['k' => 'purchases', 'label' => '采购入库', 'icon' => admin_icon('download', 16) ?: '📥', 'url' => 'PSI/purchases'], ['k' => 'sales', 'label' => '销售出库', 'icon' => admin_icon('upload', 16) ?: '📤', 'url' => 'PSI/sales'], ['k' => 'stock', 'label' => '库存流水', 'icon' => admin_icon('package', 16) ?: '📦', 'url' => 'PSI/stock'], ['k' => 'orders', 'label' => '订单管理', 'icon' => admin_icon('receipt', 16) ?: '🧾', 'url' => 'PSI/orders'], ['k' => 'sales_orders', 'label' => '销售订单', 'icon' => admin_icon('file-text', 16) ?: '📝', 'url' => 'PSI/sales_orders'], ['k' => 'purchase_orders', 'label' => '采购订单', 'icon' => admin_icon('file-text', 16) ?: '📋', 'url' => 'PSI/purchase_orders'], ['k' => 'outbounds', 'label' => '出库单', 'icon' => admin_icon('truck', 16) ?: '🚚', 'url' => 'PSI/outbounds'], ['k' => 'reports', 'label' => '报表中心', 'icon' => admin_icon('chart-bar', 16) ?: '📊', 'url' => 'PSI/reports'], ]; } /** 生成单据号:前缀 + 秒级时间 + 进程内计数器 + 随机,保证唯一 */ function psi_gen_no(string $prefix): string { static $c = 0; $c++; return strtoupper($prefix) . date('YmdHis') . str_pad($c, 4, '0', STR_PAD_LEFT) . mt_rand(10, 99); } /** 根据已交付数量重算销售订单状态(pending/partial/delivered) */ function psi_recompute_so(int $soId): void { $items = (new \App\Models\PSI\SalesOrderItem())->whereAll('so_id', $soId); $total = 0; $delivered = 0; foreach ($items as $it) { $total += (int)($it['qty'] ?? 0); $delivered += (int)($it['delivered_qty'] ?? 0); } $status = $total <= 0 ? 'pending' : ($delivered >= $total ? 'delivered' : ($delivered > 0 ? 'partial' : 'pending')); (new \App\Models\PSI\SalesOrder())->update($soId, ['status' => $status]); } /** 打印页外壳:独立 HTML + A4 样式 + 自动打印 */ function psi_print_shell(string $title, string $body): string { $css = 'body{font-family:-apple-system,"Microsoft YaHei",sans-serif;color:#111;margin:0;padding:24px;background:#fff;}' . '.doc{width:210mm;max-width:100%;margin:0 auto;}' . '@media print{body{padding:0;}.no-print{display:none!important;}@page{margin:12mm;}}' . '.doc h2{text-align:center;margin:0 0 4px;font-size:20px;}' . '.doc .sub{text-align:center;color:#666;margin-bottom:16px;font-size:13px;}' . '.doc .meta{display:flex;flex-wrap:wrap;gap:6px 28px;font-size:13px;margin:12px 0;border-bottom:1px dashed #ccc;padding-bottom:10px;}' . '.doc .meta b{color:#374151;}' . '.doc table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px;}' . '.doc th,.doc td{border:1px solid #bbb;padding:7px 9px;}' . '.doc th{background:#f3f4f6;}' . '.doc .total{text-align:right;font-weight:700;margin-top:12px;font-size:14px;}' . '.doc .sign{display:flex;justify-content:space-between;margin-top:40px;font-size:13px;color:#374151;}' . '.btn-print{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;border:1px solid #0ea5e9;background:#0ea5e9;color:#fff;cursor:pointer;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.15);}'; return '' . e($title) . '' . '' . '' . '
' . $body . '
' . '' . ''; } /** 一次性提示消息(跨重定向,取值后清空) */ function flash(string $msg, string $type = 'ok'): void { $_SESSION['_flash'] = ['msg' => $msg, 'type' => $type]; } function flash_html(): string { if (empty($_SESSION['_flash'])) return ''; $f = $_SESSION['_flash']; unset($_SESSION['_flash']); $cls = $f['type'] === 'err' ? 'alert alert-err' : 'alert alert-ok'; return '
' . e($f['msg']) . '
'; } /* ---------- mbstring 兼容层:远端 PHP 未启用 mbstring 扩展时提供兜底实现 ---------- */ if (!function_exists('mb_substr')) { function mb_substr(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string { $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); if ($chars === false) { $chars = preg_split('//', $str, -1, PREG_SPLIT_NO_EMPTY) ?: []; } $len = $length ?? count($chars); return implode('', array_slice($chars, $start, $len)); } } if (!function_exists('mb_strlen')) { function mb_strlen(string $str, string $encoding = 'UTF-8'): int { $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); return $chars === false ? strlen($str) : count($chars); } } if (!function_exists('mb_strtolower')) { function mb_strtolower(string $str, string $encoding = 'UTF-8'): string { return strtolower($str); } } if (!function_exists('mb_strtoupper')) { function mb_strtoupper(string $str, string $encoding = 'UTF-8'): string { return strtoupper($str); } } if (!function_exists('mb_check_encoding')) { function mb_check_encoding($var, ?string $encoding = null): bool { if (is_array($var) || is_object($var)) return false; $str = (string)$var; if ($encoding === null || strtoupper((string)$encoding) === 'UTF-8') { return (bool) preg_match('/\A(?: [\x00-\x7F] | [\xC2-\xDF][\x80-\xBF] | \xE0[\xA0-\xBF][\x80-\xBF] | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} | \xED[\x80-\x9F][\x80-\xBF] | \xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2} )*\z/x', $str); } return true; } } if (!function_exists('mb_convert_encoding')) { function mb_convert_encoding(string $str, string $to, ?string $from = null): string { if (function_exists('iconv')) { $conv = @iconv($from ?? 'UTF-8', $to . '//IGNORE', $str); if ($conv !== false) return $conv; } return $str; } } /** * 内联 SVG 图标(Lucide 线性风格,stroke=currentColor)。 * 取代后台原有 emoji 图标:清晰、随主题变色、跨平台一致。 */ function admin_icon(string $name, int $size = 20): string { static $paths = null; if ($paths === null) { $paths = [ 'dashboard' => '', 'snowflake' => '', 'folders' => '', 'newspaper' => '', 'handshake' => '', 'file-text' => '', 'images' => '', 'settings' => '', 'palette' => '', 'users' => '', 'receipt' => '', 'wallet' => '', 'upload' => '', 'database' => '', 'package' => '', 'logout' => '', 'key' => '', 'globe' => '', 'menu' => '', 'plus' => '', 'user' => '', 'pencil' => '', 'shield' => '', 'lightbulb' => '', 'phone' => '', 'shopping-bag' => '', 'truck' => '', 'inbox' => '', 'cart' => '', 'user-plus' => '', 'key' => '', 'lock' => '', 'bar-chart' => '', 'printer' => '', 'check' => '', 'clipboard' => '', 'trending-up' => '', 'alert' => '', ]; } $p = $paths[$name] ?? $paths['file-text']; return ''; } /* ---------- 数据库升级:升级包目录与待升级计数 ---------- */ /** 升级包目录(放置 *.sql 后,后台「数据库升级」即提示可升级) */ function db_upgrade_dir(): string { return BASE_PATH . '/install/upgrades'; } /** 将字节数格式化为人类可读大小 */ function human_size(int $bytes): string { if ($bytes < 1024) return $bytes . ' B'; $units = ['KB', 'MB', 'GB', 'TB']; $i = -1; do { $bytes /= 1024; $i++; } while ($bytes >= 1024 && $i < count($units) - 1); return round($bytes, 2) . ' ' . $units[$i]; } /** * 统计待升级的 SQL 文件数量(未记录或内容已变更)。 * 用于在导航上提示「可升级」。失败安全:任何异常都返回 0。 */ function db_pending_upgrades(): int { try { if (\Core\Db::driver() !== 'mysql') return 0; $dir = db_upgrade_dir(); if (!is_dir($dir)) return 0; $files = glob($dir . '/*.sql') ?: []; if (!$files) return 0; \Core\Installer::ensureUpgradeLog(); $applied = \Core\Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR); $n = 0; foreach ($files as $f) { $name = basename($f); $h = md5_file($f); if (!isset($applied[$name]) || $applied[$name] !== $h) { $n++; } } return $n; } catch (\Throwable $e) { return 0; } } /* ---------- 安全响应头(质量红线:所有后台/API 统一应用) ---------- */ /** 生成每次请求唯一的 CSP nonce(同请求内多次调用返回同一值,并去除 base64 填充符以兼容 CSP) */ function csp_nonce(): string { static $n; if ($n === null) { $n = rtrim(base64_encode(random_bytes(16)), '='); } return $n; } function apply_security_headers(): void { if (headers_sent()) return; $nonce = csp_nonce(); // 通用安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options 等)已统一在 // Nginx 服务器层下发(含静态资源),无需在此重复。 // 此处仅补充依赖动态随机数的「严格 CSP」——nonce 每次请求不同,必须走 PHP。 header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"); } /** 当前请求的完整绝对 URL(用于 canonical 规范链接等) */ function absolute_url(): string { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? 'localhost'); $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; return $scheme . '://' . $host . $uri; } /* ---------- 图片验证码(GD 库,无第三方依赖,防机器人暴力/垃圾提交) ---------- */ /** 生成验证码字符串并存入 session,返回图片 URL */ function captcha_make(): array { if (session_status() !== PHP_SESSION_ACTIVE) @session_start(); // 排除易混淆字符:0/O、1/I/L、2/Z $pool = '3456789ABCDEFGHJKMNPQRSTUVWXY'; $code = ''; for ($i = 0; $i < 4; $i++) { $code .= $pool[random_int(0, strlen($pool) - 1)]; } $_SESSION['captcha_code'] = $code; // 添加随机参数防浏览器缓存同名图片 return ['url' => site_url('captcha/image') . '?_t=' . dechex(time() . random_int(1000, 9999))]; } function captcha_check($input): bool { if (session_status() !== PHP_SESSION_ACTIVE) @session_start(); $ok = isset($_SESSION['captcha_code']) && is_string($input) && strtoupper(trim($input)) === strtoupper($_SESSION['captcha_code']); unset($_SESSION['captcha_code']); // 一次性,防重放 return $ok; } /** 输出验证码图片(由 App 路由调用) */ function captcha_image(): void { if (session_status() !== PHP_SESSION_ACTIVE) @session_start(); $code = $_SESSION['captcha_code'] ?? ''; if (empty($code)) { // 无有效 code 时生成一个默认的,避免空白图 $code = 'XXXX'; } $w = 130; $h = 44; $img = imagecreatetruecolor($w, $h); if (!$img) { http_response_code(500); exit('验证码图片生成失败'); } // ── 背景 ── $bg = imagecolorallocate($img, 248, 250, 252); imagefilledrectangle($img, 0, 0, $w, $h, $bg); // ── 干扰线(5 条随机弧线)──── $colors = []; for ($i = 0; $i < 8; $i++) { $colors[] = imagecolorallocate($img, random_int(140, 210), random_int(140, 210), random_int(160, 220) ); } for ($i = 0; $i < 5; $i++) { $c = $colors[random_int(0, count($colors) - 1)]; imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c ); } // ── 干扰像素点 ── for ($i = 0; $i < 80; $i++) { $c = $colors[random_int(0, count($colors) - 1)]; imagesetpixel($img, random_int(0, $w), random_int(0, $h), $c); } // ── 文字(每个字符独立颜色、角度、位置)──── $len = strlen($code); $cx = 15; $cy = 30; $fontFile = BASE_PATH . '/public/assets/arial.ttf'; // 可选 TTF,若无则 fallback $hasTtf = is_file($fontFile); $dark = imagecolorallocate($img, 25, 55, 100); for ($i = 0; $i < $len; $i++) { $char = $code[$i]; $textColor = imagecolorallocate($img, random_int(20, 80), random_int(40, 100), random_int(80, 160) ); if ($hasTtf) { $size = random_int(18, 22); $angle = random_int(-15, 15); $x = $cx + ($i * ($w - 20) / $len); $y = $cy + random_int(-4, 6); imagettftext($img, $size, $angle, (int)$x, (int)$y, $textColor, $fontFile, $char); } else { // 无 TTF 字体时用内置字体(效果较差但仍可工作) $fontSize = 5; $x = $cx + ($i * ($w - 20) / $len) + random_int(-2, 2); $y = 12 + random_int(-3, 3); imagestring($img, $fontSize, (int)$x, (int)$y, $char, $textColor); } } // ── 输出 ── if (ob_get_level() > 0) ob_clean(); header('Content-Type: image/png'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); imagepng($img); imagedestroy($img); exit; } /* ---------- IP 级失败限速(fail2ban 式,文件缓存,越会话更抗爆破) ---------- */ function ip_login_blocked(string $ip): bool { $file = BASE_PATH . '/storage/login_ip.json'; if (!is_file($file)) return false; $data = json_decode(@file_get_contents($file), true) ?: []; $now = time(); if (!isset($data[$ip])) return false; return $data[$ip]['count'] >= 8; } function ip_login_register(string $ip): void { $file = BASE_PATH . '/storage/login_ip.json'; if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true); $data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : []; $now = time(); if (!isset($data[$ip]) || ($data[$ip]['time'] + 900) < $now) { $data[$ip] = ['count' => 0, 'time' => $now]; } $data[$ip]['count']++; @file_put_contents($file, json_encode($data)); } function ip_login_clear(string $ip): void { $file = BASE_PATH . '/storage/login_ip.json'; if (!is_file($file)) return; $data = json_decode(@file_get_contents($file), true) ?: []; unset($data[$ip]); @file_put_contents($file, json_encode($data)); } /* ---------- 通用 IP 级限速(可用于任意提交场景,如联系表单) ---------- */ function ip_rate_blocked(string $ip, string $bucket, int $limit, int $window): bool { $file = BASE_PATH . '/storage/rate_' . $bucket . '.json'; if (!is_file($file)) return false; $data = json_decode(@file_get_contents($file), true) ?: []; if (!isset($data[$ip])) return false; return $data[$ip]['count'] >= $limit; } function ip_rate_register(string $ip, string $bucket, int $window): void { $file = BASE_PATH . '/storage/rate_' . $bucket . '.json'; if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true); $data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : []; $now = time(); if (!isset($data[$ip]) || ($data[$ip]['time'] + $window) < $now) { $data[$ip] = ['count' => 0, 'time' => $now]; } $data[$ip]['count']++; @file_put_contents($file, json_encode($data)); } }