';
}
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();
// 通用安全响应头从 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_key(home/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'],
];
}
}