Laravel + ClickHouse 统计数据推送飞书卡片

用 Laravel 定时任务对接 ClickHouse 日志,每小时/每天推送应用流量报告到飞书群。

核心架构

ClickHouse (request_logs) → Laravel Artisan Command → 飞书 Webhook → 交互式卡片

定时任务两种模式:

  • push:feishu-stats — 每小时统计近 1h / 3h 流量
  • push:feishu-stats daily — 每天 0 点统计近 24h / 3d / 7d

Artisan Command

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Carbon\Carbon;
use App\Services\ClickHouseService;

class PushFeishuStats extends Command
{
    protected $signature = 'push:feishu-stats {type=hourly}';
    protected $description = '推送应用访问统计数据到飞书群';

    protected ClickHouseService $clickHouseService;

    public function __construct(ClickHouseService $clickHouseService)
    {
        parent::__construct();
        $this->clickHouseService = $clickHouseService;
    }

    public function handle()
    {
        $type = $this->argument('type');

        try {
            $type === 'daily'
                ? $this->handleDailyPush()
                : $this->handleHourlyPush();

            return Command::SUCCESS;
        } catch (\Throwable $e) {
            $this->error($e->getMessage());
            return Command::FAILURE;
        }
    }

    protected function handleHourlyPush()
    {
        $now = Carbon::now();
        $oneHourAgo   = $now->copy()->subHour()->toDateTimeString();
        $threeHoursAgo = $now->copy()->subHours(3)->toDateTimeString();

        $sql = "
            SELECT
                JSONExtractString(request_params, 'appid') AS appid,
                COUNTIf(timestamp >= '{$oneHourAgo}') AS count_1h,
                COUNT(1) AS count_3h
            FROM request_logs
            WHERE timestamp >= '{$threeHoursAgo}'
            GROUP BY appid
            HAVING count_3h > 10
            ORDER BY count_3h DESC
            LIMIT 20
        ";

        $rows = $this->query($sql);
        $this->sendToFeishu('📊 每小时应用流量监控', $rows, 'hourly');
    }

    protected function handleDailyPush()
    {
        $now = Carbon::now();
        $oneDayAgo   = $now->copy()->subDay()->toDateTimeString();
        $threeDaysAgo = $now->copy()->subDays(3)->toDateTimeString();
        $sevenDaysAgo = $now->copy()->subDays(7)->toDateTimeString();

        $sql = "
            SELECT
                JSONExtractString(request_params, 'appid') AS appid,
                COUNTIf(timestamp >= '{$oneDayAgo}') AS count_24h,
                COUNTIf(timestamp >= '{$threeDaysAgo}') AS count_3d,
                COUNT(1) AS count_7d
            FROM request_logs
            WHERE timestamp >= '{$sevenDaysAgo}'
            GROUP BY appid
            HAVING count_7d > 50
            ORDER BY count_7d DESC
            LIMIT 20
        ";

        $rows = $this->query($sql);
        $this->sendToFeishu('📅 每日应用流量大盘', $rows, 'daily');
    }

    protected function query(string $sql): array
    {
        return $this->clickHouseService->getClient()->select($sql)->rows();
    }

    protected function sendToFeishu(string $title, array $rows, string $type = 'hourly')
    {
        $webhookUrl = env('FEISHU_WEBHOOK');
        if (!$webhookUrl) {
            $this->error('未配置 FEISHU_WEBHOOK');
            return;
        }

        $elements = [
            ['tag' => 'markdown', 'content' => '**🕒 生成时间:** ' . now()->format('Y-m-d H:i:s')],
            ['tag' => 'markdown', 'content' => '**📈 应用数量:** ' . count($rows)],
            ['tag' => 'hr'],
        ];

        foreach ($rows as $index => $row) {
            $rank  = $index + 1;
            $appid = $row['appid'] ?: 'unknown';

            if ($type === 'hourly') {
                $count1h = (int) $row['count_1h'];
                $count3h = (int) $row['count_3h'];
                $avg     = max(1, $count3h / 3);
                $rate    = round(($count1h / $avg) * 100);

                $trend  = '🟢';
                $status = '正常';
                if ($rate >= 200)      { $trend = '🚨'; $status = '流量暴涨'; }
                elseif ($rate >= 150)  { $trend = '🟡'; $status = '流量升高'; }
                elseif ($rate <= 50)   { $trend = '🔵'; $status = '流量下降'; }

                $content = "### {$trend} TOP {$rank} · {$appid}\n\n> {$status}\n\n"
                    . "- 1小时请求量:**" . number_format($count1h) . "**\n"
                    . "- 3小时请求量:**" . number_format($count3h) . "**\n"
                    . "- 当前热度:**{$rate}%**";
            } else {
                $count24h = (int) $row['count_24h'];
                $count3d  = (int) $row['count_3d'];
                $count7d  = (int) $row['count_7d'];
                $avg      = max(1, $count7d / 7);
                $rate     = round(($count24h / $avg) * 100);
                $trend    = $rate >= 180 ? '🚨' : ($rate >= 130 ? '🟡' : '🟢');

                $content = "### {$trend} TOP {$rank} · {$appid}\n\n"
                    . "- 24小时请求量:**" . number_format($count24h) . "**\n"
                    . "- 近3天:**" . number_format($count3d) . "**\n"
                    . "- 近7天:**" . number_format($count7d) . "**\n"
                    . "- 当前热度:**{$rate}%**";
            }

            $elements[] = ['tag' => 'markdown', 'content' => $content];
            $elements[] = ['tag' => 'hr'];
        }

        $elements[] = ['tag' => 'markdown', 'content' => '⚡ Powered By Laravel + ClickHouse'];

        $payload = [
            'msg_type' => 'interactive',
            'card' => [
                'config'  => ['wide_screen_mode' => true],
                'header'  => [
                    'title'    => ['tag' => 'plain_text', 'content' => $title],
                    'template' => 'turquoise',
                ],
                'elements' => $elements,
            ],
        ];

        $response = Http::timeout(10)->post($webhookUrl, $payload);
        $response->successful()
            ? $this->info('飞书推送成功')
            : $this->error('飞书推送失败: ' . $response->body());
    }
}

注册定时任务

// routes/console.php
Schedule::command('push:feishu-stats')->hourly();
Schedule::command('push:feishu-stats daily')->dailyAt('00:00');

.env 配置

FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx

ClickHouse 性能建议

当前用 JSONExtractString(request_params, 'appid') 实时解析 JSON,数据量大时 CPU 开销高。建议在 request_logs 表冗余一个 appid String 字段,插入时直接写入,便于索引和 GROUP BY 加速。

飞书卡片效果

📊 每小时应用流量监控
🕒 生成时间:2026-05-28 10:00:00
📈 应用数量:15

🚨 TOP 1 · app_main
> 流量暴涨
• 1小时请求量:23,412
• 3小时请求量:41,221
• 当前热度:171%

🟢 TOP 2 · app_test
• 1小时请求量:1,242
• 当前热度:119%

比 Markdown 表格可读性高很多,支持趋势颜色标识。