Files
freeshop/includes/functions.php
T
2026-08-14 10:55:06 +08:00

649 lines
18 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
// 公共函数:转义、认证、模板助手、CSRF、主题等。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
/**
* HTML 转义,防止 XSS。
*/
function h($str)
{
return htmlspecialchars((string) $str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* 格式化人民币价格。
*/
function money($n)
{
return '¥' . number_format((float) $n, 2);
}
/**
* 跳转。
*/
function redirect($url)
{
// 防开放重定向 / 协议相对 URL 被当作 host(如 //evil.com 或 http://x
if (is_string($url)
&& (strpos($url, '//') === 0
|| preg_match('#^[a-z][a-z0-9+.\-]*://#i', $url))) {
$url = 'index.php';
}
header('Location: ' . $url);
exit;
}
/**
* 获取访问者真实 IP(兼容 CDN / 反向代理)。
* 优先级:X-Forwarded-For(取第一个)→ X-Real-IP → Client-IP → REMOTE_ADDR。
*/
function getClientIp()
{
$candidates = [];
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$candidates = array_merge($candidates, explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
}
foreach (['HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'] as $k) {
if (!empty($_SERVER[$k])) {
$candidates[] = $_SERVER[$k];
}
}
foreach ($candidates as $ip) {
$ip = trim($ip);
// 过滤私有/保留地址,避免 CDN 内部地址误导
if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
return trim((string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'));
}
/**
* 某 IP 今日已注册账号数(用于「每日每 IP 最多 2 个」限制)。
*/
function regCountToday($ip)
{
try {
$stmt = db()->prepare('SELECT COUNT(*) FROM ' . tn('users') . ' WHERE reg_ip = ? AND DATE(created_at) = CURDATE()');
$stmt->execute([$ip]);
return (int) $stmt->fetchColumn();
} catch (Throwable $e) {
return 0;
}
}
/**
* 站点基础 URL(用于拼接验证链接)。自动适配是否部署在子目录。
*/
function siteBaseUrl()
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? '') == '443' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = dirname($_SERVER['SCRIPT_NAME'] ?? '/index.php');
$dir = ($dir === '/' || $dir === '\\') ? '' : rtrim($dir, '/');
return $scheme . '://' . $host . $dir;
}
/**
* 取昵称/用户名的首字(用于头像圆圈)。
*/
function avatarText($name)
{
$name = trim((string) $name);
if ($name === '') {
return '?';
}
return mb_strtoupper(mb_substr($name, 0, 1, 'UTF-8'), 'UTF-8');
}
/**
* 发送邮箱验证邮件:生成/更新 email_token 并投递。
* 返回 ['ok'=>bool, 'error'=>string, 'log'=>array]
*/
function sendVerifyMail($userId)
{
$stmt = db()->prepare('SELECT id, username, nickname, email FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || $user['email'] === '') {
return ['ok' => false, 'error' => '该账号没有可用邮箱', 'log' => []];
}
$token = bin2hex(random_bytes(24));
db()->prepare('UPDATE ' . tn('users') . ' SET email_token = ? WHERE id = ?')
->execute([$token, $user['id']]);
$url = siteBaseUrl() . '/verify.php?token=' . $token;
$name = $user['nickname'] ?: $user['username'];
$subject = '[' . SITE_NAME . '] 请验证你的邮箱';
$body = '<div style="font-family:Segoe UI,Helvetica,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;'
. 'border:1px solid #e3e8ef;border-radius:16px;">'
. '<h2 style="color:#1a73e8;margin:0 0 12px;">验证你的邮箱</h2>'
. '<p style="color:#1f2430;line-height:1.7;">你好 ' . h($name) . ',感谢注册 ' . h(SITE_NAME) . '</p>'
. '<p style="color:#1f2430;line-height:1.7;">请点击下方按钮完成邮箱验证:</p>'
. '<p style="margin:18px 0;"><a href="' . h($url) . '" style="background:#1a73e8;color:#fff;padding:11px 22px;'
. 'border-radius:999px;text-decoration:none;display:inline-block;font-weight:600;">验证邮箱</a></p>'
. '<p style="color:#5f6b7a;font-size:.85rem;line-height:1.6;">如按钮无法点击,请复制以下链接到浏览器打开:<br>' . h($url) . '</p>'
. '<hr style="border:none;border-top:1px solid #eef1f6;margin:18px 0;">'
. '<p style="color:#9aa7b8;font-size:.8rem;margin:0;">若非本人操作,请忽略此邮件。</p>'
. '</div>';
return fnwSendMail($user['email'], $subject, $body);
}
/**
* 当前登录用户(普通用户),未登录返回 null。
*/
function currentUser($forceRefresh = false)
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $cache = null;
if ($cache !== null && !$forceRefresh) {
return $cache;
}
$cache = null; // 强制刷新时先清掉旧缓存
$pdo = db();
$stmt = $pdo->prepare('SELECT id, username, email, nickname, verified, is_admin, status, points, invite_code, invited_by, avatar FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$row = $stmt->fetch();
// 账号被封禁则视为未登录,并清除会话
if (!$row || (int) $row['status'] !== 1) {
unset($_SESSION['user_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 清除当前登录用户的请求级缓存(后台修改用户数据后调用,使下次 currentUser() 重新查库)。
*/
function refreshCurrentUser()
{
currentUser(true);
}
function isLoggedIn()
{
return currentUser() !== null;
}
/**
* 当前登录的管理员,未登录返回 null。
*/
function currentAdmin()
{
if (empty($_SESSION['admin_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$pdo = db();
$stmt = $pdo->prepare('SELECT id, username, email, status FROM ' . tn('users') . ' WHERE id = ? AND is_admin = 1');
$stmt->execute([$_SESSION['admin_id']]);
$row = $stmt->fetch();
if (!$row || (int) $row['status'] !== 1) {
unset($_SESSION['admin_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 要求登录,否则跳转到登录页并带回跳地址。
*/
function requireLogin($next = '')
{
if (!isLoggedIn()) {
$q = $next ? '?next=' . urlencode($next) : '';
redirect('login.php' . $q);
}
}
/**
* 要求管理员(is_admin=1)登录(后台用)。
* 注意:仅校验会话标志不够——客服(role=cs)同样持有 admin_id 会话,
* 因此必须额外确认 is_admin=1,否则客服可通过直接访问 URL 进入管理员专属页面。
*/
function requireAdmin()
{
if (empty($_SESSION['admin_id']) || !currentAdmin()) {
redirect('login.php');
}
}
/**
* 当前登录的「员工」账号(管理员或客服),未登录/无权限返回 null。
* 与 currentAdmin() 的区别:currentAdmin() 仅限 is_admin=1;本函数额外允许 role='cs' 的客服。
*/
function currentStaff()
{
if (empty($_SESSION['admin_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$pdo = db();
$stmt = $pdo->prepare(
'SELECT id, username, email, status, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND status = 1 AND role IN (\'admin\', \'cs\')'
);
$stmt->execute([$_SESSION['admin_id']]);
$row = $stmt->fetch();
if (!$row) {
unset($_SESSION['admin_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 要求员工(管理员或客服)登录,否则跳转到后台登录页。
*/
function requireStaff()
{
if (empty($_SESSION['admin_id']) || !currentStaff()) {
redirect('login.php');
}
}
/**
* 当前员工是否为管理员(拥有全部后台权限)。
*/
function isAdminStaff()
{
$s = currentStaff();
return $s !== null && (int) $s['is_admin'] === 1;
}
/**
* 生成/获取 CSRF 令牌(存入 session)。
*/
function csrfToken()
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
/**
* 输出 CSRF 隐藏字段。
*/
function csrfField()
{
return '<input type="hidden" name="csrf" value="' . csrfToken() . '">';
}
/**
* 校验 CSRF(仅 POST)。失败直接 403。
*/
function verifyCsrf()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($_POST['csrf']) || !hash_equals((string) $_SESSION['csrf'], (string) $_POST['csrf'])) {
http_response_code(403);
exit('CSRF 校验失败');
}
}
}
/**
* 校验 CSRF token 字符串(用于 AJAX/API 场景)。
* @param string $token 待校验的 token
* @return bool
*/
function verifyCsrfToken($token)
{
return !empty($token) && !empty($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], (string)$token);
}
/**
* 当前主题:浅色 / 深色。跟随 cookie,默认跟随系统(用 CSS 媒体查询兜底)。
*/
function currentTheme()
{
return (!empty($_COOKIE['fnw_theme']) && $_COOKIE['fnw_theme'] === 'dark') ? 'dark' : 'light';
}
/**
* 订单状态中文映射。
*/
function orderStatusLabel($status)
{
$map = [
'pending' => '待处理',
'paid' => '已受理',
'shipped' => '已发货',
'completed' => '已完成',
'cancelled' => '已取消',
];
return $map[$status] ?? $status;
}
/**
* 读取单个系统设置(settings 表)。
*/
function getSetting($k, $default = '')
{
try {
$stmt = db()->prepare('SELECT v FROM ' . tn('settings') . ' WHERE k = ?');
$stmt->execute([$k]);
$v = $stmt->fetchColumn();
return $v === false ? $default : $v;
} catch (Throwable $e) {
return $default;
}
}
/**
* 保存单个系统设置(不存在则插入,存在则更新)。
*/
function saveSetting($k, $v)
{
db()->prepare(
'INSERT INTO ' . tn('settings') . ' (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?'
)->execute([$k, $v, $v]);
}
/**
* 读取 SMTP 配置(带缓存)。
*/
function smtpConfig()
{
static $c = null;
if ($c !== null) {
return $c;
}
$keys = ['smtp_host', 'smtp_port', 'smtp_enc', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_fromname', 'notify_emails'];
$map = [];
try {
$pdo = db();
$ph = implode(',', array_fill(0, count($keys), '?'));
$stmt = $pdo->prepare('SELECT k, v FROM ' . tn('settings') . ' WHERE k IN (' . $ph . ')');
$stmt->execute($keys);
foreach ($stmt->fetchAll() as $row) {
$map[$row['k']] = $row['v'];
}
} catch (Throwable $e) {
// 配置表尚不可用时返回空配置
}
$c = [];
foreach ($keys as $k) {
$c[$k] = $map[$k] ?? '';
}
return $c;
}
/**
* 通过 SMTP 发送邮件(依赖 includes/mailer.php)。
* 返回 ['ok'=>true,'log'=>[...]] 或 ['ok'=>false,'error'=>string,'log'=>[...]]
*/
function fnwSendMail($to, $subject, $body)
{
$cfg = smtpConfig();
if (empty($cfg['smtp_host'])) {
return ['ok' => false, 'error' => 'SMTP 未配置'];
}
require_once __DIR__ . '/mailer.php';
$mailer = new SmtpMailer($cfg['smtp_host'], $cfg['smtp_port'], $cfg['smtp_enc'], $cfg['smtp_user'], $cfg['smtp_pass']);
$from = $cfg['smtp_from'] ?: $cfg['smtp_user'];
$ok = $mailer->send($to, $subject, $body, $from, $cfg['smtp_fromname'] ?: SITE_NAME);
if ($ok) {
return ['ok' => true, 'log' => $mailer->log];
}
return ['ok' => false, 'error' => $mailer->lastError, 'log' => $mailer->log];
}
/**
* 工单状态中文映射。
*/
function ticketStatusLabel($status)
{
$map = [
'open' => '待处理',
'pending' => '处理中',
'resolved' => '已解决',
'closed' => '已关闭',
];
return $map[$status] ?? $status;
}
/**
* 工单优先级中文映射。
*/
function ticketPriorityLabel($p)
{
$map = [1 => '低', 2 => '普通', 3 => '高'];
return $map[(int) $p] ?? '普通';
}
/**
* 周期天数 → 中文标签。0 表示一次性/实物。
*/
function periodLabel($days)
{
$days = (int) $days;
if ($days <= 0) {
return '一次性';
}
if ($days % 365 === 0) {
return ($days / 365) . ' 年';
}
if ($days % 30 === 0) {
return ($days / 30) . ' 个月';
}
return $days . ' 天';
}
/**
* 工单类型中文映射。
*/
function ticketTypeLabel($t)
{
$map = [
'general' => '普通',
'renewal' => '续期申请',
];
return $map[$t] ?? '普通';
}
/**
* 到期时间 → 展示文案(null 表示无到期/永久)。
*/
function expiryText($dt)
{
if ($dt === null || $dt === '') {
return '—';
}
return date('Y-m-d H:i', strtotime($dt));
}
/**
* 是否即将/已经到期(用于前台续期入口判断,由调用方按需使用)。
*/
function isExpired($dt)
{
if ($dt === null || $dt === '') {
return false;
}
return strtotime($dt) < time();
}
/**
* 到期状态分析:返回是否含到期时间、展示文案、剩余天数、等级(ok/warn/danger/expired/none)。
*/
function expiryState($dt)
{
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none'];
}
$ts = strtotime($dt);
if ($ts === false) {
return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none'];
}
$now = time();
$days = ($ts - $now) / 86400;
$daysLeft = (int) ceil($days);
if ($ts < $now) {
return ['has' => true, 'text' => '已到期(' . date('Y-m-d H:i', $ts) . '', 'days' => $daysLeft, 'level' => 'expired'];
}
$level = $daysLeft <= 3 ? 'danger' : ($daysLeft <= 7 ? 'warn' : 'ok');
return ['has' => true, 'text' => date('Y-m-d H:i', $ts) . '(剩 ' . $daysLeft . ' 天)', 'days' => $daysLeft, 'level' => $level];
}
/**
* 到期时间徽标(HTML),用于前台订单列表/详情与后台。
*/
function expiryBadge($dt)
{
$s = expiryState($dt);
if (!$s['has']) {
return '<span class="exp-badge exp-none">无到期时间</span>';
}
$icon = [
'ok' => 'fa-calendar-check',
'warn' => 'fa-clock',
'danger' => 'fa-triangle-exclamation',
'expired' => 'fa-circle-xmark',
][$s['level']] ?? 'fa-calendar';
return '<span class="exp-badge exp-' . $s['level'] . '"><i class="fas ' . $icon . '"></i> ' . h($s['text']) . '</span>';
}
/**
* 支付方式中文映射。
*/
function payTypeLabel($t)
{
$map = [
'cash' => '现金(历史)',
'points' => '积分支付',
'direct' => '直接下单',
];
return $map[$t] ?? '直接下单';
}
/**
* 读取用户当前积分余额。
*/
function getUserPoints($uid)
{
try {
$stmt = db()->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([(int) $uid]);
$v = $stmt->fetchColumn();
return $v === false ? 0 : (int) $v;
} catch (Throwable $e) {
return 0;
}
}
/**
* 增减用户积分并写入流水(points_log)。
* $amount:正数=增加,负数=扣减(扣减后余额最低为 0)。
* 返回 ['ok'=>bool, 'balance'=>int, 'error'=>string]。
*/
function addPoints($uid, $type, $amount, $remark = '')
{
$uid = (int) $uid;
$amount = (int) $amount;
try {
$pdo = db();
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ? FOR UPDATE');
$stmt->execute([$uid]);
$cur = (int) $stmt->fetchColumn();
$balance = max(0, $cur + $amount);
$pdo->prepare('UPDATE ' . tn('users') . ' SET points = ? WHERE id = ?')
->execute([$balance, $uid]);
$pdo->prepare(
'INSERT INTO ' . tn('points_log') . ' (user_id, type, amount, balance, remark, created_at) VALUES (?,?,?,?,?,NOW())'
)->execute([$uid, $type, $amount, $balance, $remark]);
$pdo->commit();
return ['ok' => true, 'balance' => $balance, 'error' => ''];
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'balance' => 0, 'error' => $e->getMessage()];
}
}
/**
* 生成唯一邀请码(大写字母+数字,8 位)。
*/
function genInviteCode()
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
for ($i = 0; $i < 20; $i++) {
$code = '';
for ($j = 0; $j < 8; $j++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
$stmt = db()->prepare('SELECT 1 FROM ' . tn('users') . ' WHERE invite_code = ?');
$stmt->execute([$code]);
if (!$stmt->fetch()) {
return $code;
}
}
return strtoupper(substr(md5(uniqid('', true)), 0, 8));
}
/**
* 根据邀请码查邀请人 ID(不存在返回 0)。
*/
function getInviterIdByCode($code)
{
$code = trim((string) $code);
if ($code === '') {
return 0;
}
$stmt = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE invite_code = ?');
$stmt->execute([$code]);
$id = $stmt->fetchColumn();
return $id === false ? 0 : (int) $id;
}
/**
* 今天是否已签到(防重复)。
*/
function hasSignedToday($uid)
{
try {
$stmt = db()->prepare('SELECT 1 FROM ' . tn('sign_logs') . ' WHERE user_id = ? AND sign_date = CURDATE()');
$stmt->execute([(int) $uid]);
return (bool) $stmt->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
/**
* 获取站点名称(优先从数据库读取)。
*/
function getSiteName() {
global $_site_name;
if ($_site_name !== null) {
return $_site_name;
}
try {
$db = db();
$stmt = $db->query("SELECT v FROM " . tn('settings') . " WHERE k = 'site_name'");
$val = $stmt->fetchColumn();
$_site_name = ($val !== false && $val !== null && $val !== '') ? $val : '自由云商城';
} catch (Exception $e) {
$_site_name = '自由云商城';
}
return $_site_name;
}