Files
coolcoth.com/app/Core/Helper.php
T
Lucanlee 6d3962574c perf/security: V0.9.5-0.9.6 db_pending_upgrades 缓存 + config 凭据外置
- V0.9.5 (P3): db_pending_upgrades() 加 static 请求内缓存,同请求只算一次
- V0.9.6 (安全): config.php 移除明文密码,改从 gitignored 的 config.secret.php 或
  环境变量读取;新增 config.secret.php.example 模板;.gitignore 忽略 config/config.secret.php
  (修复 Webhook reset 反复覆盖明文凭据的隐患;服务器部署前需自建 config.secret.php)
2026-08-09 00:34:54 +08:00

875 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 全局辅助函数(视图与控制器中可直接调用)
*/
if (!function_exists('site_url')) {
function base_url(): string
{
$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
// 由 DOCUMENT_ROOT + SCRIPT_FILENAME 推导站点根路径,兼容内置服务器路由模式与生产环境
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
$scriptFile = $_SERVER['SCRIPT_FILENAME'] ?? '';
$basePath = '';
if ($docroot && $scriptFile && strpos($scriptFile, $docroot) === 0) {
$rel = substr($scriptFile, strlen($docroot)); // 如 /index.php 或 /sub/index.php
$basePath = rtrim(dirname($rel), '/');
}
return rtrim($proto . '://' . $host . $basePath, '/');
}
function site_url(string $path = ''): string
{
return base_url() . '/' . ltrim($path, '/');
}
function asset(string $path = ''): string
{
$url = site_url('assets/' . ltrim($path, '/'));
// V0.9.4:追加文件 mtime 作为版本串,使 theme.css 等静态资源在「重新生成/部署」后自动击穿浏览器长缓存
//nginx 对已改资源设了 expires 1y,无版本串会导致老访客最长 1 年看不到更新)。
$real = rtrim(BASE_PATH, '/') . '/public/assets/' . ltrim($path, '/');
if (is_file($real)) {
$url .= '?v=' . filemtime($real);
}
return $url;
}
function e($v): string
{
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
function slugify(string $s): string
{
$s = preg_replace('~[^\pL\pN]+~u', '-', $s);
$s = trim($s, '-');
return strtolower($s) ?: 'item';
}
function format_date($ts, string $fmt = 'Y-m-d'): string
{
if (!$ts) return '';
$t = is_numeric($ts) ? (int)$ts : strtotime($ts);
return $t ? date($fmt, $t) : '';
}
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="' . csrf_token() . '">';
}
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;
// V0.9.3MySQL 下改用 COUNT + JSON_CONTAINS 聚合,避免每个后台页都 SELECT * 全表拉回再循环计数。
// 语义与旧逻辑一致:统计「当前用户不在 read_by 已读列表」的事件数。
if (\Core\Db::driver() === 'mysql') {
try {
return (int) \Core\Db::query(
"SELECT COUNT(*) FROM psi_events WHERE JSON_CONTAINS(COALESCE(read_by,'[]'), CAST(? AS JSON)) = 0",
[$uid]
)->fetchColumn();
} catch (\Throwable $e) {
// 极端情况(如 read_by 含非法 JSON)回退旧逻辑,保证铃铛数字不报错
}
}
// 文件模式 / MySQL 兜底:无 JSON 函数或异常,保留全表循环(数据量小,可接受)
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 '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>' . e($title) . '</title>'
. '<style>' . $css . '</style></head><body>'
. '<button class="btn-print no-print" onclick="window.print()">打印 / 导出 PDF</button>'
. '<div class="doc">' . $body . '</div>'
. '<script>window.onload=function(){setTimeout(function(){window.print();},300);};</script>'
. '</body></html>';
}
/** 一次性提示消息(跨重定向,取值后清空) */
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 '<div class="' . $cls . '">' . e($f['msg']) . '</div>';
}
/* ---------- 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' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'snowflake' => '<line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/>',
'folders' => '<path d="M8 17h12a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3.9a2 2 0 0 1-1.69-.9l-.81-1.2a2 2 0 0 0-1.67-.9H8a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2Z"/><path d="M2 8v11a2 2 0 0 0 2 2h14"/>',
'newspaper' => '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/>',
'handshake' => '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/>',
'file-text' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
'images' => '<path d="M18 22H4a2 2 0 0 1-2-2V6"/><path d="m22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18"/><circle cx="12" cy="8" r="2"/><rect width="16" height="16" x="6" y="2" rx="2"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'palette' => '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'receipt' => '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
'wallet' => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
'upload' => '<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>',
'database' => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
'package' => '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.27 9 5.15"/>',
'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
'key' => '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'menu' => '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'user' => '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'lightbulb' => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
'phone' => '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
'shopping-bag' => '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
'truck' => '<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
'inbox' => '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'cart' => '<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>',
'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>',
'key' => '<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'bar-chart' => '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/><line x1="3" y1="20" x2="21" y2="20"/>',
'printer' => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8" rx="1"/>',
'check' => '<path d="M20 6 9 17l-5-5"/>',
'clipboard' => '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
];
}
$p = $paths[$name] ?? $paths['file-text'];
return '<svg class="li li-' . e($name) . '" width="' . $size . '" height="' . $size . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $p . '</svg>';
}
/* ---------- 数据库升级:升级包目录与待升级计数 ---------- */
/** 升级包目录(放置 *.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
{
// V0.9.5:请求内缓存(一次请求内多次调用只算一次),避免每后台页重复 glob+md5_file+SELECT
static $cache = null;
if ($cache !== null) return $cache;
try {
if (\Core\Db::driver() !== 'mysql') { $cache = 0; return 0; }
$dir = db_upgrade_dir();
if (!is_dir($dir)) { $cache = 0; return 0; }
$files = glob($dir . '/*.sql') ?: [];
if (!$files) { $cache = 0; 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++;
}
}
$cache = $n;
return $n;
} catch (\Throwable $e) {
$cache = 0;
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();
// 通用安全响应头从 PHP 兜底补齐:即便 Nginx 层未下发也不会缺失(防配置漂移)。
// 与审计整改要求一致:补充 X-Content-Type-Options / Referrer-Policy / Permissions-Policy
// 并将 HSTS 升级为含 includeSubDomains + preload。若 Nginx 也下发 HSTS,重复为无害,
// 浏览器取更严格项(max-age 取最大值并合并指令)。
header("X-Content-Type-Options: nosniff");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()");
header("Strict-Transport-Security: max-age=63072000; includeSubDomains; preload");
// 严格 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 式,文件缓存,越会话更抗爆破) ----------
* 双窗口独立限速(按需求定制):
* · 失败登录:任意 10 分钟内最多 5 次;超出即封锁 30 分钟
* · 成功登录:任意 30 分钟内最多 5 次;超出即限制(封锁至最早成功滑出 30 分钟窗口)
* 数据文件:storage/login_ip.json —— 每个 IP 记失败/成功时间戳列表 + 封锁截止时间
* --------------------------------------------------------------------- */
defined('LOGIN_FAIL_WINDOW') or define('LOGIN_FAIL_WINDOW', 600); // 失败计数窗口:10 分钟
defined('LOGIN_FAIL_LIMIT') or define('LOGIN_FAIL_LIMIT', 5); // 失败次数上限
defined('LOGIN_OK_WINDOW') or define('LOGIN_OK_WINDOW', 1800); // 成功计数窗口:30 分钟
defined('LOGIN_OK_LIMIT') or define('LOGIN_OK_LIMIT', 5); // 成功次数上限
defined('LOGIN_BLOCK_SECS') or define('LOGIN_BLOCK_SECS', 1800); // 超限后封锁时长:30 分钟
function _ip_login_load(): array
{
$file = BASE_PATH . '/storage/login_ip.json';
return is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
}
function _ip_login_save(array $data): void
{
$file = BASE_PATH . '/storage/login_ip.json';
if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
@file_put_contents($file, json_encode($data));
}
/** 裁剪过期时间戳并按规则重算封锁截止时间(就地修改 $st) */
function _ip_login_prune(array &$st, int $now): void
{
$st['fail'] = array_values(array_filter((array)($st['fail'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_FAIL_WINDOW));
$st['ok'] = array_values(array_filter((array)($st['ok'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_OK_WINDOW));
if (!isset($st['block_until']) || !is_numeric($st['block_until'])) $st['block_until'] = 0;
if ($st['block_until'] <= $now) {
if (count($st['fail']) >= LOGIN_FAIL_LIMIT) {
// 失败 5 次 / 10 分钟 → 锁 30 分钟
$st['block_until'] = $now + LOGIN_BLOCK_SECS;
} elseif (count($st['ok']) >= LOGIN_OK_LIMIT) {
// 成功 5 次 / 30 分钟 → 锁到最早一次成功滑出窗口
$oldest = min($st['ok']);
$st['block_until'] = max($now + 60, $oldest + LOGIN_OK_WINDOW);
}
}
}
function ip_login_blocked(string $ip): bool
{
$now = time();
$st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
_ip_login_prune($st, $now);
return ($st['block_until'] ?? 0) > $now;
}
/** 返回剩余封锁秒数(已解封为 0),供 Retry-After 使用 */
function ip_login_remaining(string $ip): int
{
$now = time();
$st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
_ip_login_prune($st, $now);
return max(0, (int)($st['block_until'] ?? 0) - $now);
}
function ip_login_register_fail(string $ip): void
{
$now = time();
$data = _ip_login_load();
$st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
_ip_login_prune($st, $now);
$st['fail'][] = $now;
_ip_login_prune($st, $now); // 追加后重新评估是否触发封锁
$data[$ip] = $st;
_ip_login_save($data);
}
function ip_login_register_success(string $ip): void
{
$now = time();
$data = _ip_login_load();
$st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
_ip_login_prune($st, $now);
$st['fail'] = []; // 成功登录重置失败计数(防爆破计数器归零)
$st['ok'][] = $now; // 记录一次成功,纳入「30 分钟 5 次」上限
_ip_login_prune($st, $now);
$data[$ip] = $st;
_ip_login_save($data);
}
function ip_login_clear(string $ip): void
{
$data = _ip_login_load();
unset($data[$ip]);
_ip_login_save($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));
}
}
if (!function_exists('page_seo')) {
/**
* 取页面 SEO(标题/描述/关键词/OG/规范链接/收录开关)。
* 优先读 page_seo 表;无记录或字段缺失时退回控制器传入的默认值。
* @param string $key page_keyhome/products/news/cases/about/contact...
* @param array $default 默认 SEO 数组(title/description/keywords/og_type
* @return array {title,description,keywords,og_type,og_image,canonical,noindex}
*/
function page_seo(string $key, array $default = []): array
{
$def = array_merge([
'title' => '',
'description' => '',
'keywords' => '',
'og_type' => 'website',
'og_image' => '',
'canonical' => '',
'noindex' => 0,
], $default);
try {
$row = (new \App\Models\PageSeo())->getByKey($key);
} catch (\Throwable $e) {
$row = null;
}
if (!$row) {
return $def;
}
return [
'title' => $row['title'] ?? $def['title'],
'description' => $row['description'] ?? $def['description'],
'keywords' => $row['keywords'] ?? $def['keywords'],
'og_type' => $row['og_type'] ?? $def['og_type'],
'og_image' => $row['og_image'] ?? $def['og_image'],
'canonical' => $row['canonical'] ?? $def['canonical'],
'noindex' => $row['noindex'] ?? $def['noindex'],
];
}
}