PHP项目怎么实现后台日志查看?

wen PHP项目 23

本文目录导读:

PHP项目怎么实现后台日志查看?

  1. 方案一:简单文件日志查看(适合小项目)
  2. 方案二:数据库日志(适合中型项目)
  3. 方案三:使用成熟的日志库(推荐)
  4. 方案四:使用开源日志查看器(推荐)
  5. 安全注意事项
  6. 生产环境最佳实践

实现PHP后台日志查看,常见有几种方案,从简单到专业,你可以根据项目规模和需求选择。

简单文件日志查看(适合小项目)

记录日志到文件

// log.php - 日志记录函数
function writeLog($message, $type = 'INFO') {
    $logFile = __DIR__ . '/logs/app_' . date('Y-m-d') . '.log';
    $logDir = dirname($logFile);
    // 确保目录存在
    if (!is_dir($logDir)) {
        mkdir($logDir, 0755, true);
    }
    $logMessage = sprintf(
        "[%s] [%s] %s - %s\n",
        date('Y-m-d H:i:s'),
        $type,
        $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
        $message
    );
    file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX);
}

后台日志查看页面

// admin/logs.php - 日志查看页面
session_start();
// 检查管理员登录
if (!isset($_SESSION['admin_logged_in'])) {
    header('Location: login.php');
    exit;
}
// 获取日志文件列表
$logDir = __DIR__ . '/../logs/';
$logFiles = glob($logDir . '*.log');
rsort($logFiles); // 最新的文件在前面
// 读取指定日志文件
$currentFile = $_GET['file'] ?? ($logFiles[0] ?? '');
$logContent = '';
$lines = [];
if (file_exists($currentFile)) {
    $content = file_get_contents($currentFile);
    $lines = array_reverse(explode("\n", $content)); // 最新的日志在前面
}
// 搜索过滤
$search = $_GET['search'] ?? '';
if ($search && $lines) {
    $lines = array_filter($lines, function($line) use ($search) {
        return stripos($line, $search) !== false;
    });
}
// 分页
$page = $_GET['page'] ?? 1;
$perPage = 50;
$totalLines = count($lines);
$totalPages = ceil($totalLines / $perPage);
$currentLines = array_slice($lines, ($page - 1) * $perPage, $perPage);
?>
<!DOCTYPE html>
<html>
<head>系统日志</title>
    <style>
        .log-container { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 20px; }
        .log-line { padding: 2px 0; border-bottom: 1px solid #333; }
        .log-line:hover { background: #2d2d2d; }
        .INFO { color: #6a9955; }
        .ERROR { color: #f44747; }
        .WARNING { color: #dcdcaa; }
        .search-form { margin-bottom: 20px; }
        .pagination { margin-top: 20px; }
        .file-list { margin-bottom: 20px; }
    </style>
</head>
<body>
    <div class="log-container">
        <h1>系统日志</h1>
        <!-- 文件选择 -->
        <div class="file-list">
            <select onchange="location.href='?file='+this.value">
                <?php foreach ($logFiles as $file): ?>
                    <option value="<?= htmlspecialchars($file) ?>" 
                            <?= $file === $currentFile ? 'selected' : '' ?>>
                        <?= basename($file) ?>
                    </option>
                <?php endforeach; ?>
            </select>
        </div>
        <!-- 搜索 -->
        <form class="search-form" method="GET">
            <input type="hidden" name="file" value="<?= htmlspecialchars($currentFile) ?>">
            <input type="text" name="search" placeholder="搜索日志..." 
                   value="<?= htmlspecialchars($search) ?>">
            <button type="submit">搜索</button>
        </form>
        <!-- 日志内容 -->
        <div id="log-content">
            <?php foreach ($currentLines as $line): ?>
                <?php if (empty($line)) continue; ?>
                <div class="log-line">
                    <?php 
                    // 解析日志类型并添加颜色
                    if (preg_match('/\[(INFO|ERROR|WARNING)\]/', $line, $matches)) {
                        echo '<span class="' . $matches[1] . '">' . 
                             htmlspecialchars($line) . '</span>';
                    } else {
                        echo htmlspecialchars($line);
                    }
                    ?>
                </div>
            <?php endforeach; ?>
        </div>
        <!-- 分页 -->
        <div class="pagination">
            第 <?= $page ?> / <?= $totalPages ?> 页
            <?php if ($page > 1): ?>
                <a href="?file=<?= urlencode($currentFile) ?>&page=<?= $page-1 ?>&search=<?= urlencode($search) ?>">上一页</a>
            <?php endif; ?>
            <?php if ($page < $totalPages): ?>
                <a href="?file=<?= urlencode($currentFile) ?>&page=<?= $page+1 ?>&search=<?= urlencode($search) ?>">下一页</a>
            <?php endif; ?>
        </div>
    </div>
</body>
</html>

数据库日志(适合中型项目)

数据库表结构

CREATE TABLE `system_logs` (
    `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `level` ENUM('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') DEFAULT 'INFO',
    `message` TEXT NOT NULL,
    `context` JSON DEFAULT NULL,
    `ip_address` VARCHAR(45) DEFAULT NULL,
    `user_agent` VARCHAR(500) DEFAULT NULL,
    `request_uri` VARCHAR(500) DEFAULT NULL,
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX `idx_level` (`level`),
    INDEX `idx_created_at` (`created_at`),
    INDEX `idx_level_created` (`level`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

日志记录类

// Logger.php
class Logger {
    private $db;
    private static $instance = null;
    private function __construct() {
        $this->db = Database::getInstance(); // 你的数据库连接类
    }
    public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    public function log($level, $message, array $context = []) {
        $stmt = $this->db->prepare("
            INSERT INTO system_logs (level, message, context, ip_address, user_agent, request_uri)
            VALUES (?, ?, ?, ?, ?, ?)
        ");
        $stmt->execute([
            strtoupper($level),
            $message,
            json_encode($context),
            $_SERVER['REMOTE_ADDR'] ?? null,
            $_SERVER['HTTP_USER_AGENT'] ?? null,
            $_SERVER['REQUEST_URI'] ?? null
        ]);
        return $this->db->lastInsertId();
    }
    public function info($message, array $context = []) {
        return $this->log('INFO', $message, $context);
    }
    public function error($message, array $context = []) {
        return $this->log('ERROR', $message, $context);
    }
    public function warning($message, array $context = []) {
        return $this->log('WARNING', $message, $context);
    }
}

高级日志查看器

// admin/log-viewer.php
class LogViewer {
    private $db;
    private $perPage = 20;
    public function __construct() {
        $this->db = Database::getInstance();
    }
    public function getLogs($filters = []) {
        $where = ['1=1'];
        $params = [];
        // 级别过滤
        if (!empty($filters['level'])) {
            $where[] = 'level = ?';
            $params[] = strtoupper($filters['level']);
        }
        // 日期范围过滤
        if (!empty($filters['date_from'])) {
            $where[] = 'created_at >= ?';
            $params[] = $filters['date_from'];
        }
        if (!empty($filters['date_to'])) {
            $where[] = 'created_at <= ?';
            $params[] = $filters['date_to'] . ' 23:59:59';
        }
        // 搜索关键词
        if (!empty($filters['search'])) {
            $where[] = '(message LIKE ? OR context LIKE ?)';
            $searchTerm = '%' . $filters['search'] . '%';
            $params[] = $searchTerm;
            $params[] = $searchTerm;
        }
        $whereClause = implode(' AND ', $where);
        // 计算总数
        $countStmt = $this->db->prepare("SELECT COUNT(*) FROM system_logs WHERE $whereClause");
        $countStmt->execute($params);
        $total = $countStmt->fetchColumn();
        // 分页查询
        $page = max(1, $filters['page'] ?? 1);
        $offset = ($page - 1) * $this->perPage;
        $stmt = $this->db->prepare("
            SELECT * FROM system_logs 
            WHERE $whereClause 
            ORDER BY created_at DESC 
            LIMIT ? OFFSET ?
        ");
        $params[] = $this->perPage;
        $params[] = $offset;
        $stmt->execute($params);
        $logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
        return [
            'logs' => $logs,
            'total' => $total,
            'page' => $page,
            'perPage' => $this->perPage,
            'totalPages' => ceil($total / $this->perPage)
        ];
    }
    public function getLogStats() {
        $stmt = $this->db->query("
            SELECT 
                level,
                COUNT(*) as count,
                DATE(created_at) as date
            FROM system_logs
            WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
            GROUP BY level, DATE(created_at)
            ORDER BY date DESC, level
        ");
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}
// 在页面中使用
$viewer = new LogViewer();
$filters = [
    'level' => $_GET['level'] ?? '',
    'search' => $_GET['search'] ?? '',
    'date_from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-7 days')),
    'date_to' => $_GET['date_to'] ?? date('Y-m-d'),
    'page' => $_GET['page'] ?? 1
];
$result = $viewer->getLogs($filters);
$stats = $viewer->getLogStats();

使用成熟的日志库(推荐)

使用 Monolog + 前端日志查看器

安装 Composer 包

composer require monolog/monolog

配置日志系统

// config/logging.php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Formatter\LineFormatter;
class Logging {
    private static $logger = null;
    public static function getLogger() {
        if (self::$logger === null) {
            $logger = new Logger('app');
            // 按天轮转的日志文件
            $handler = new RotatingFileHandler(
                __DIR__ . '/../logs/app.log', 
                30, // 保留30天
                Logger::DEBUG
            );
            // 格式化
            $formatter = new LineFormatter(
                "[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n",
                "Y-m-d H:i:s"
            );
            $handler->setFormatter($formatter);
            $logger->pushHandler($handler);
            // 可以根据环境添加不同的处理器
            if (getenv('APP_ENV') === 'production') {
                // 生产环境可以添加邮件通知等
            }
            self::$logger = $logger;
        }
        return self::$logger;
    }
}
// 使用示例
$log = Logging::getLogger();
$log->info('用户登录', ['user_id' => 123]);
$log->error('数据库连接失败', ['error' => $e->getMessage()]);

前端日志查看器(推荐 LNAV 或 Log Viewer)

<!-- admin/logs-advanced.html -->
<!DOCTYPE html>
<html>
<head>日志查看器</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css">
    <style>
        .log-container {
            background: #1e1e1e;
            color: #d4d4d4;
            font-family: 'Consolas', 'Courier New', monospace;
            padding: 20px;
            border-radius: 5px;
            max-height: 80vh;
            overflow-y: auto;
        }
        .log-entry {
            padding: 5px;
            margin: 2px 0;
            cursor: pointer;
            transition: background 0.2s;
        }
        .log-entry:hover {
            background: #2d2d2d;
        }
        .level-INFO { color: #6a9955; }
        .level-WARNING { color: #dcdcaa; }
        .level-ERROR { color: #f44747; }
        .level-CRITICAL { color: #ff0000; font-weight: bold; }
        .stats-card {
            text-align: center;
            padding: 20px;
            margin: 10px 0;
        }
        .log-detail {
            background: #252526;
            padding: 15px;
            border-radius: 5px;
            margin-top: 10px;
        }
        .auto-refresh {
            margin: 10px 0;
        }
    </style>
</head>
<body>
    <div class="container-fluid mt-4">
        <div class="row">
            <!-- 统计信息 -->
            <div class="col-md-3">
                <div class="card stats-card">
                    <div class="card-body">
                        <h5 class="card-title">今日日志</h5>
                        <p class="card-text display-4" id="todayCount">0</p>
                    </div>
                </div>
                <div class="card stats-card">
                    <div class="card-body">
                        <h5 class="card-title">错误数量</h5>
                        <p class="card-text display-4 text-danger" id="errorCount">0</p>
                    </div>
                </div>
            </div>
            <!-- 日志列表 -->
            <div class="col-md-9">
                <!-- 过滤器 -->
                <div class="card mb-3">
                    <div class="card-body">
                        <form id="filterForm" class="row g-3">
                            <div class="col-md-3">
                                <select class="form-select" name="level">
                                    <option value="">所有级别</option>
                                    <option value="INFO">INFO</option>
                                    <option value="WARNING">WARNING</option>
                                    <option value="ERROR">ERROR</option>
                                    <option value="CRITICAL">CRITICAL</option>
                                </select>
                            </div>
                            <div class="col-md-3">
                                <input type="text" class="form-control" name="search" placeholder="搜索日志...">
                            </div>
                            <div class="col-md-2">
                                <input type="date" class="form-control" name="date_from">
                            </div>
                            <div class="col-md-2">
                                <input type="date" class="form-control" name="date_to">
                            </div>
                            <div class="col-md-2">
                                <button type="submit" class="btn btn-primary w-100">筛选</button>
                            </div>
                        </form>
                        <div class="auto-refresh">
                            <label>
                                <input type="checkbox" id="autoRefresh"> 自动刷新 (每10秒)
                            </label>
                            <span class="badge bg-secondary" id="lastUpdate"></span>
                        </div>
                    </div>
                </div>
                <!-- 日志内容 -->
                <div class="log-container" id="logContainer">
                    <div class="text-center text-muted py-5">
                        加载中...
                    </div>
                </div>
                <!-- 分页 -->
                <nav aria-label="日志分页">
                    <ul class="pagination justify-content-center" id="pagination">
                    </ul>
                </nav>
            </div>
        </div>
    </div>
    <!-- 日志详情模态框 -->
    <div class="modal fade" id="logDetailModal" tabindex="-1">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">日志详情</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body" id="logDetailContent">
                </div>
            </div>
        </div>
    </div>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    <script>
        // AJAX 加载日志
        function loadLogs(page = 1) {
            const filters = {
                page: page,
                level: $('[name="level"]').val(),
                search: $('[name="search"]').val(),
                date_from: $('[name="date_from"]').val(),
                date_to: $('[name="date_to"]').val()
            };
            $('#logContainer').html('<div class="text-center py-5"><div class="spinner-border"></div></div>');
            $.get('/api/logs', filters, function(response) {
                renderLogs(response);
                $('#lastUpdate').text('最后更新: ' + new Date().toLocaleTimeString());
            });
        }
        function renderLogs(response) {
            const container = $('#logContainer');
            container.empty();
            if (response.logs.length === 0) {
                container.html('<div class="text-center text-muted py-5">暂无日志</div>');
                return;
            }
            response.logs.forEach(function(log) {
                const levelClass = 'level-' + log.level;
                const time = new Date(log.created_at).toLocaleString();
                const detail = JSON.stringify(log.context, null, 2);
                const html = `
                    <div class="log-entry" data-detail='${escapeHtml(detail)}'>
                        <span class="text-muted">[${time}]</span>
                        <span class="${levelClass}">[${log.level}]</span>
                        <span>${escapeHtml(log.message)}</span>
                        ${log.context ? '<span class="badge bg-info">有详情</span>' : ''}
                    </div>
                `;
                container.append(html);
            });
            // 更新统计
            $('#todayCount').text(response.todayCount || 0);
            $('#errorCount').text(response.errorCount || 0);
            // 更新分页
            renderPagination(response);
            // 绑定点击事件
            $('.log-entry').click(function() {
                const detail = $(this).data('detail');
                $('#logDetailContent').html('<pre>' + detail + '</pre>');
                $('#logDetailModal').modal('show');
            });
        }
        function renderPagination(response) {
            const pagination = $('#pagination');
            pagination.empty();
            if (response.totalPages <= 1) return;
            // 上一页
            pagination.append(`
                <li class="page-item ${response.page <= 1 ? 'disabled' : ''}">
                    <a class="page-link" href="#" data-page="${response.page - 1}">上一页</a>
                </li>
            `);
            // 页码
            for (let i = 1; i <= response.totalPages; i++) {
                pagination.append(`
                    <li class="page-item ${i === response.page ? 'active' : ''}">
                        <a class="page-link" href="#" data-page="${i}">${i}</a>
                    </li>
                `);
            }
            // 下一页
            pagination.append(`
                <li class="page-item ${response.page >= response.totalPages ? 'disabled' : ''}">
                    <a class="page-link" href="#" data-page="${response.page + 1}">下一页</a>
                </li>
            `);
            // 绑定点击事件
            $('.page-link').click(function(e) {
                e.preventDefault();
                const page = $(this).data('page');
                if (page) loadLogs(page);
            });
        }
        function escapeHtml(text) {
            if (!text) return '';
            return $('<div>').text(text).html();
        }
        // 自动刷新
        let refreshInterval;
        $('#autoRefresh').change(function() {
            if (this.checked) {
                refreshInterval = setInterval(loadLogs, 10000);
            } else {
                clearInterval(refreshInterval);
            }
        });
        // 表单提交
        $('#filterForm').submit(function(e) {
            e.preventDefault();
            loadLogs();
        });
        // 初始加载
        $(document).ready(function() {
            loadLogs();
        });
    </script>
</body>
</html>

使用开源日志查看器(推荐)

使用 LogViewer(Composer 包)

composer require rap2hpoutre/laravel-log-viewer

或使用更通用的:

composer require arcanedev/log-viewer

安全注意事项

  1. 权限控制:确保只有管理员可以访问日志
  2. 路径遍历防护:严格验证文件路径
  3. XSS防护:始终使用 htmlspecialchars()escapeHtml()
  4. 日志轮转:防止日志文件过大
  5. 敏感信息过滤:不要记录密码等敏感信息

生产环境最佳实践

// 生产环境日志配置
class ProductionLogger {
    public static function init() {
        // 1. 使用日志聚合服务(如ELK Stack)
        // 2. 设置日志级别(生产环境通常 INFO 及以上)
        // 3. 实现日志告警(错误数超过阈值时发送通知)
        // 4. 定期清理旧日志
        // 5. 日志文件权限设置(644)
        // 6. 使用独立的日志服务器
    }
}

选择哪种方案取决于你的项目需求:

  • 小型项目:方案一(文件日志)
  • 中型项目:方案二(数据库日志)
  • 大型项目:方案三或四(专业日志库 + 聚合服务)

抱歉,评论功能暂时关闭!