码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Controllers/ExportController.php
ExportController.php16.4 KB
<?php
/**
 * 数据导出控制器(CSV)
 *
 * 统一入口:GET /api/export/{kind}?site_id=&start=&end=[&筛选参数]
 *   kind = daily      按日 PV / UV / 独立IP / 访问次数 / 跳出率 / 平均访问时长
 *        | channels   来源渠道构成
 *        | referrers  外链来源(referrer 主机名)
 *        | pages      页面路径(按完整 URL 聚合)
 *        | entries    入口页
 *        | exits      退出页
 *        | sessions   会话明细(支持 q/browser/os/device/source 筛选,最多 5万行)
 *        | geo        国家 / 省份 / 城市
 *        | devices / browsers / oses / langs   环境维度
 *
 * 设计要点:
 *   1) 一律直接聚合 events / sessions(不依赖 site_daily 预聚合),保证导出数字与
 *      「会话/明细」口径一致,不会因 rollup 未跑而缺行;
 *   2) 输出带 UTF-8 BOM,Excel 双击不乱码;文件名同时给 ASCII 与 filename*(中文名);
 *   3) 只读权限即可导出(复用 SiteAccess,viewer 也能拿到自己可见站点的数据);
 *   4) 每类导出都设行数上限,避免超大站点一次性拉爆内存。
 */
declare(strict_types=1);

namespace Wstat\Controllers;

use Wstat\Http\Request;
use Wstat\Support\Db;
use Wstat\Support\SiteAccess;
use Wstat\Support\Util;

class ExportController
{
    /** 单次导出行数上限 */
    private const MAX_ROWS = 50000;

    /** 日期区间上限(与 StatsController 保持一致) */
    private const MAX_RANGE_DAYS = 366;

    /** 分组维度的行数上限(榜单类) */
    private const MAX_GROUP = 5000;

    /**
     * GET /api/export/{kind}
     * 路由统一指向本方法,kind 由路由参数给出。
     */
    public function handle(Request $req): void
    {
        $kind = (string) $req->param('kind', 'daily');
        $site = SiteAccess::site($req, (int) $req->input('site_id', 0));
        [$start, $end] = $this->range($req);
        [$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
        $sid = (int) $site['id'];
        $tz = (string) $site['timezone'];

        $stamp = $start === $end ? $start : ($start . '_' . $end);
        $base = preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $site['domain']);

        switch ($kind) {
            case 'daily':
                [$header, $rows] = $this->daily($sid, $start, $end, $tz);
                $this->csv("wstat-daily-$base-$stamp.csv", "流量趋势(PV-UV)_$stamp.csv", $header, $rows);
                break;
            case 'channels':
                [$header, $rows] = $this->groupByEvents('source', "AND source<>''", $sid, $start, $end, ['来源渠道', 'PV', '占比(%)']);
                $this->csv("wstat-channels-$base-$stamp.csv", "来源渠道_$stamp.csv", $header, $rows);
                break;
            case 'referrers':
                [$header, $rows] = $this->groupByEvents('ref_host', "AND ref_host<>''", $sid, $start, $end, ['外链来源', 'PV', '占比(%)']);
                $this->csv("wstat-referrers-$base-$stamp.csv", "外链来源_$stamp.csv", $header, $rows);
                break;
            case 'pages':
                [$header, $rows] = $this->groupByEvents('url', '', $sid, $start, $end, ['页面URL', 'PV', '占比(%)']);
                $this->csv("wstat-pages-$base-$stamp.csv", "页面分析_$stamp.csv", $header, $rows);
                break;
            case 'entries':
                [$header, $rows] = $this->groupBySessions('entry_url', $sid, $startTs, $endTs, ['入口页', 'PV', '占比(%)']);
                $this->csv("wstat-entries-$base-$stamp.csv", "入口页_$stamp.csv", $header, $rows);
                break;
            case 'exits':
                [$header, $rows] = $this->groupBySessions('exit_url', $sid, $startTs, $endTs, ['退出页', 'PV', '占比(%)']);
                $this->csv("wstat-exits-$base-$stamp.csv", "退出页_$stamp.csv", $header, $rows);
                break;
            case 'sessions':
                [$header, $rows] = $this->sessions($req, $sid, $startTs, $endTs);
                $this->csv("wstat-sessions-$base-$stamp.csv", "会话明细_$stamp.csv", $header, $rows);
                break;
            case 'geo':
                [$header, $rows] = $this->geo($sid, $start, $end);
                $this->csv("wstat-geo-$base-$stamp.csv", "地域分布_$stamp.csv", $header, $rows);
                break;
            case 'devices':
                [$header, $rows] = $this->groupByEvents('device', "AND device<>''", $sid, $start, $end, ['设备', 'PV', '占比(%)']);
                $this->csv("wstat-devices-$base-$stamp.csv", "设备分布_$stamp.csv", $header, $rows);
                break;
            case 'browsers':
                [$header, $rows] = $this->groupByEvents('browser', "AND browser<>''", $sid, $start, $end, ['浏览器', 'PV', '占比(%)']);
                $this->csv("wstat-browsers-$base-$stamp.csv", "浏览器分布_$stamp.csv", $header, $rows);
                break;
            case 'oses':
                [$header, $rows] = $this->groupByEvents('os', "AND os<>''", $sid, $start, $end, ['操作系统', 'PV', '占比(%)']);
                $this->csv("wstat-oses-$base-$stamp.csv", "操作系统分布_$stamp.csv", $header, $rows);
                break;
            case 'langs':
                [$header, $rows] = $this->groupByEvents('lang', "AND lang<>''", $sid, $start, $end, ['语言', 'PV', '占比(%)']);
                $this->csv("wstat-langs-$base-$stamp.csv", "语言分布_$stamp.csv", $header, $rows);
                break;
            default:
                wstat_err('不支持的导出类型: ' . $kind, 422);
        }
    }

    /* ==================== 各导出数据集 ==================== */

    /** 按日趋势(零填充完整日期轴) */
    private function daily(int $sid, string $start, string $end, string $tz): array
    {
        [$startTs, $endTs] = Util::dateRangeToTs($start, $end, $tz);
        $offset = Util::tzOffsetSec($tz);

        $ev = [];
        foreach (
            Db::select(
                "SELECT `day`,
                        SUM(type='pageview') pv,
                        COUNT(DISTINCT CASE WHEN type='pageview' THEN visitor_id END) uv,
                        COUNT(DISTINCT CASE WHEN type='pageview' THEN ip END) ipc
                 FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",
                [$sid, $start, $end]
            ) as $r
        ) {
            $ev[(string) $r['day']] = [
                'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'ipc' => (int) $r['ipc'],
            ];
        }

        $ss = [];
        foreach (
            Db::select(
                "SELECT DATE(FROM_UNIXTIME(start_ts+?)) d,
                        COUNT(*) c, COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du
                 FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
                [$offset, $sid, $startTs, $endTs]
            ) as $r
        ) {
            $ss[(string) $r['d']] = ['visits' => (int) $r['c'], 'bounce' => (int) $r['b'], 'dur' => (int) $r['du']];
        }

        $header = ['日期', '浏览量(PV)', '访客数(UV)', '独立IP', '访问次数', '跳出率(%)', '平均访问时长(秒)'];
        $rows = [];
        // 完整日期轴(用 UTC 日推进,避免 DST 误算;本站时区已折算到 day 字段)
        $cur = strtotime($start . ' 00:00:00 UTC');
        $last = strtotime($end . ' 00:00:00 UTC');
        for (; $cur <= $last; $cur += 86400) {
            $d = gmdate('Y-m-d', $cur);
            $e = $ev[$d] ?? ['pv' => 0, 'uv' => 0, 'ipc' => 0];
            $s = $ss[$d] ?? ['visits' => 0, 'bounce' => 0, 'dur' => 0];
            $visits = (int) $s['visits'];
            $rows[] = [
                $d,
                (int) $e['pv'],
                (int) $e['uv'],
                (int) $e['ipc'],
                $visits,
                $visits > 0 ? round((int) $s['bounce'] / $visits * 100, 2) : 0,
                $visits > 0 ? round((int) $s['dur'] / $visits, 1) : 0,
            ];
        }
        return [$header, $rows];
    }

    /** events 维度聚合($col 为内部白名单,无注入面) */
    private function groupByEvents(string $col, string $cond, int $sid, string $start, string $end, array $header): array
    {
        $total = (int) Db::value(
            "SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
            [$sid, $start, $end]
        );
        $rows = [];
        foreach (
            Db::select(
                "SELECT `$col` AS name, COUNT(*) AS pv
                 FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $cond
                 GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,
                [$sid, $start, $end]
            ) as $r
        ) {
            $name = (string) $r['name'];
            $pv = (int) $r['pv'];
            $rows[] = [$name === '' ? '(空)' : $name, $pv, $total > 0 ? round($pv / $total * 100, 2) : 0];
        }
        return [$header, $rows];
    }

    /** sessions 维度聚合(入口/退出页) */
    private function groupBySessions(string $col, int $sid, int $startTs, int $endTs, array $header): array
    {
        $rows = Db::select(
            "SELECT `$col` AS name, COALESCE(SUM(pageviews),0) AS pv
             FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? AND `$col`<>''
             GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,
            [$sid, $startTs, $endTs]
        );
        $total = 0;
        foreach ($rows as $r) {
            $total += (int) $r['pv'];
        }
        $out = [];
        foreach ($rows as $r) {
            $pv = (int) $r['pv'];
            $out[] = [(string) $r['name'], $pv, $total > 0 ? round($pv / $total * 100, 2) : 0];
        }
        return [$header, $out];
    }

    /** 会话明细(筛选逻辑与 /api/stats/sessions 保持一致) */
    private function sessions(Request $req, int $sid, int $startTs, int $endTs): array
    {
        $where = 'site_id=? AND start_ts>=? AND start_ts<?';
        $args = [$sid, $startTs, $endTs];

        $q = trim((string) $req->input('q', ''));
        if ($q !== '') {
            $like = '%' . $this->likeEscape($q) . '%';
            $ipLike = isset(Db::tableColumns('sessions')['ip']) ? " OR ip LIKE ? ESCAPE '\\\\'" : '';
            $where .= " AND (visitor_id LIKE ? ESCAPE '\\\\' OR entry_url LIKE ? ESCAPE '\\\\'$ipLike)";
            $args[] = $like;
            $args[] = $like;
            if ($ipLike !== '') {
                $args[] = $like;
            }
        }
        foreach (['browser', 'os', 'device', 'source'] as $col) {
            $val = trim((string) $req->input($col, ''));
            if ($val !== '') {
                $where .= " AND $col LIKE ? ESCAPE '\\\\'";
                $args[] = '%' . $this->likeEscape($val) . '%';
            }
        }

        $ipCol = isset(Db::tableColumns('sessions')['ip']) ? ',ip' : '';
        $rows = Db::select(
            "SELECT session_id,visitor_id,start_ts,end_ts,pageviews,duration,bounce,is_new,
                    entry_url,exit_url,source,medium,campaign,click_id,
                    browser,os,device,screen,lang,country,province,city$ipCol
             FROM sessions WHERE $where ORDER BY start_ts DESC LIMIT " . self::MAX_ROWS,
            $args
        );

        $header = [
            '会话ID', '访客ID', '开始时间', '结束时间', '浏览页数', '时长(秒)', '是否跳出', '是否新访客',
            '入口页', '退出页', '来源', '媒介', '广告系列', 'ClickID',
            '浏览器', '操作系统', '设备', '屏幕', '语言', '国家', '省份', '城市', 'IP',
        ];
        $out = [];
        foreach ($rows as $r) {
            $out[] = [
                (string) $r['session_id'],
                (string) $r['visitor_id'],
                date('Y-m-d H:i:s', (int) $r['start_ts']),
                date('Y-m-d H:i:s', (int) $r['end_ts']),
                (int) $r['pageviews'],
                (int) $r['duration'],
                (int) $r['bounce'] ? '是' : '否',
                (int) $r['is_new'] ? '是' : '否',
                (string) $r['entry_url'],
                (string) $r['exit_url'],
                (string) $r['source'],
                (string) $r['medium'],
                (string) $r['campaign'],
                (string) $r['click_id'],
                (string) $r['browser'],
                (string) $r['os'],
                (string) $r['device'],
                (string) $r['screen'],
                (string) $r['lang'],
                (string) $r['country'],
                (string) $r['province'],
                (string) $r['city'],
                (string) ($r['ip'] ?? ''),
            ];
        }
        return [$header, $out];
    }

    /** 地域分布(国家 / 省份 / 城市三层,去重 PV) */
    private function geo(int $sid, string $start, string $end): array
    {
        $header = ['国家/地区', '省份', '城市', 'PV', '占比(%)'];
        $total = (int) Db::value(
            "SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
            [$sid, $start, $end]
        );
        $rows = [];
        foreach (
            Db::select(
                "SELECT country,province,city,COUNT(*) pv
                 FROM events
                 WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?
                   AND (country<>'' OR province<>'' OR city<>'')
                 GROUP BY country,province,city ORDER BY pv DESC LIMIT " . self::MAX_GROUP,
                [$sid, $start, $end]
            ) as $r
        ) {
            $pv = (int) $r['pv'];
            $rows[] = [
                (string) $r['country'], (string) $r['province'], (string) $r['city'],
                $pv, $total > 0 ? round($pv / $total * 100, 2) : 0,
            ];
        }
        return [$header, $rows];
    }

    /* ==================== 输出与工具 ==================== */

    /**
     * 输出 CSV 并结束请求。
     * $asciiName 给老浏览器(Content-Disposition filename),$utf8Name 给现代浏览器(filename*)。
     */
    private function csv(string $asciiName, string $utf8Name, array $header, array $rows): void
    {
        // 丢弃此前可能的杂散输出,避免污染 CSV 正文。
        // 只清空、不销毁最外层缓冲:销毁所有层级会连带破坏调用方(如 CLI 自检
        // 脚本用 ob_start() 回收响应体)的缓冲,使其拿到空响应。
        while (ob_get_level() > 1) {
            ob_end_clean();
        }
        if (ob_get_level() > 0) {
            ob_clean();
        }
        header('Content-Type: text/csv; charset=utf-8');
        header(
            'Content-Disposition: attachment; filename="' . $asciiName . '"; '
            . "filename*=UTF-8''" . rawurlencode($utf8Name)
        );
        header('Cache-Control: no-store');
        header('X-Export-Rows: ' . count($rows));

        $out = fopen('php://output', 'wb');
        if ($out === false) {
            exit(0);
        }
        fwrite($out, "\xEF\xBB\xBF");                    // UTF-8 BOM:Excel 识别编码
        fputcsv($out, $header);
        foreach ($rows as $r) {
            fputcsv($out, $r);
        }
        fclose($out);
        exit(0);
    }

    /** 校验/默认日期区间(与 StatsController::range 同语义) */
    private function range(Request $req): array
    {
        $today = date('Y-m-d');
        $start = $this->ymd((string) $req->input('start', date('Y-m-d', strtotime('-29 days'))));
        $end = $this->ymd((string) $req->input('end', $today));
        if ($start === '' || $end === '') {
            wstat_err('日期格式应为 YYYY-MM-DD', 422);
        }
        if ($start > $end) {
            [$start, $end] = [$end, $start];
        }
        if (strtotime($end) - strtotime($start) > (self::MAX_RANGE_DAYS - 1) * 86400) {
            wstat_err('导出区间最长 366 天', 422);
        }
        return [$start, $end];
    }

    private function ymd(string $v): string
    {
        $v = trim($v);
        if ($v === '') {
            return '';
        }
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
            return $v;
        }
        if (preg_match('/^(\d{4}-\d{2}-\d{2})[T ]/', $v)) {
            return (string) substr($v, 0, 10);
        }
        $ts = strtotime($v);
        return $ts === false ? '' : date('Y-m-d', $ts);
    }

    private function likeEscape(string $s): string
    {
        return addcslashes($s, '%_\\');
    }
}