码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Controllers/OpenController.php
OpenController.php8.4 KB
<?php
/**
 * 开放 API(/open/v1/*):个人访问令牌(PAT)鉴权,只读统计白名单。
 *
 * 鉴权:Authorization: Bearer <64位token>(api_tokens 表,见 Support\PAT)。
 * 授权:令牌属主对该站点具备任意角色(owner/editor/viewer 均可只读)。
 * 限额:暂不启用硬性限流;last_used_at 节流刷新用于审计。
 */
declare(strict_types=1);

namespace Wstat\Controllers;

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

class OpenController
{
    /** 与 StatsController::URL_PATH_EXPR 同款路径表达式(跨控制器私有,故此处独立维护) */
    private const URL_PATH_EXPR = "IF(LOCATE('//', url) > 0, IF(LOCATE('/', url, LOCATE('//', url) + 2) > 0, SUBSTRING(url, LOCATE('/', url, LOCATE('//', url) + 2)), '/'), url)";

    /**
     * GET /open/v1/overview?site_id=&start=&end=
     * 概览:totals(pv/uv/ip/visits/bounce_rate/avg_duration/new_users)+ daily 逐日序列。
     * 口径与看板概览一致:events 按 day(站点时区本地日)、sessions 按站点本地日聚合。
     */
    public function overview(Request $req): void
    {
        [$site, $start, $end] = $this->site($req);
        [$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
        $sid = (int) $site['id'];
        $offset = Util::tzOffsetSec((string) $site['timezone']);

        $daily = [];
        foreach (Util::dailySeries($startTs, $endTs, (string) $site['timezone'], fn () => null) as $d => $_) {
            $daily[(string) $d] = ['day' => (string) $d, 'pv' => 0, 'uv' => 0, 'ip' => 0,
                'visits' => 0, 'bounce_rate' => 0.0, 'avg_duration' => 0.0, 'new_users' => 0];
        }

        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' AND ip<>'' THEN ip END) ipc
                 FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",
                [$sid, $start, $end]
            ) as $r
        ) {
            $d = (string) $r['day'];
            if (isset($daily[$d])) {
                $daily[$d]['pv'] = (int) $r['pv'];
                $daily[$d]['uv'] = (int) $r['uv'];
                $daily[$d]['ip'] = (int) $r['ipc'];
            }
        }
        foreach (
            Db::select(
                "SELECT DATE(FROM_UNIXTIME(start_ts+?)) d, COUNT(*) c,
                        COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du, COALESCE(SUM(is_new),0) nw
                 FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
                [$offset, $sid, $startTs, $endTs]
            ) as $r
        ) {
            $d = (string) $r['d'];
            if (isset($daily[$d])) {
                $c = (int) $r['c'];
                $daily[$d]['visits'] = $c;
                $daily[$d]['bounce_rate'] = $c > 0 ? round((int) $r['b'] / $c * 100, 1) : 0.0;
                $daily[$d]['avg_duration'] = $c > 0 ? round((int) $r['du'] / $c) : 0.0;
                $daily[$d]['new_users'] = (int) $r['nw'];
            }
        }

        $rows = array_values($daily);
        $t = ['pv' => 0, 'uv' => 0, 'ip' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];
        foreach ($rows as $r) {
            $t['pv'] += $r['pv'];
            $t['visits'] += $r['visits'];
            $t['bounce'] += round($r['bounce_rate'] * $r['visits'] / 100);
            $t['duration'] += round($r['avg_duration'] * $r['visits']);
            $t['new_users'] += $r['new_users'];
        }
        // 区间 UV / 独立 IP:跨日必须去重(逐日相加会把同一访客 / IP 在多天里重复计数)。
        // 与看板概览(StatsController::exactUniq)同一条 SQL,保证开放接口与面板数字一致。
        $uniq = Db::first(
            "SELECT COUNT(DISTINCT visitor_id) uv, COUNT(DISTINCT CASE WHEN ip<>'' THEN ip END) ip
             FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
            [$sid, $start, $end]
        ) ?: [];
        $t['uv'] = (int) ($uniq['uv'] ?? 0);
        $t['ip'] = (int) ($uniq['ip'] ?? 0);
        $totals = [
            'pv' => $t['pv'], 'uv' => $t['uv'], 'ip' => $t['ip'],
            'visits' => $t['visits'],
            'bounce_rate' => $t['visits'] > 0 ? round($t['bounce'] / $t['visits'] * 100, 1) : 0.0,
            'avg_duration' => $t['visits'] > 0 ? round($t['duration'] / $t['visits']) : 0.0,
            'new_users' => $t['new_users'],
        ];

        wstat_json([
            'site'   => ['id' => $sid, 'name' => $site['name'], 'domain' => $site['domain']],
            'range'  => ['start' => $start, 'end' => $end],
            'totals' => $totals,
            'daily'  => $rows,
        ]);
    }

    /**
     * GET /open/v1/pages?site_id=&start=&end=&limit=50
     * 热门页面 TOP N(按 PV 降序,附 UV)。
     */
    public function pages(Request $req): void
    {
        [$site, $start, $end] = $this->site($req);
        $limit = min(200, max(1, (int) $req->input('limit', 50)));
        $items = [];
        foreach (
            Db::select(
                'SELECT ' . self::URL_PATH_EXPR . ' AS path,
                        SUM(type=\'pageview\') pv,
                        COUNT(DISTINCT CASE WHEN type=\'pageview\' THEN visitor_id END) uv
                 FROM events
                 WHERE site_id=? AND `day` BETWEEN ? AND ? AND type=\'pageview\'
                 GROUP BY path ORDER BY pv DESC LIMIT ' . $limit,
                [(int) $site['id'], $start, $end]
            ) as $r
        ) {
            $items[] = ['path' => (string) $r['path'], 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv']];
        }
        wstat_json([
            'site'  => ['id' => (int) $site['id'], 'name' => $site['name'], 'domain' => $site['domain']],
            'range' => ['start' => $start, 'end' => $end],
            'items' => $items,
        ]);
    }

    /**
     * GET /open/v1/online?site_id=
     * 当前在线访客数(5 分钟窗口,Redis zset)。
     */
    public function online(Request $req): void
    {
        [$site] = $this->site($req);
        $sid = (int) $site['id'];
        $now = time();
        if (!\Wstat\Support\Rds::enabled()) {
            // 无 Redis 模式:sessions 近 5 分钟活跃
            $count = (int) Db::value(
                'SELECT COUNT(*) FROM sessions WHERE site_id=? AND end_ts>=? AND start_ts<=?',
                [$sid, $now - 300, $now]
            );
        } else {
            $count = (int) Rds::safe(function ($r) use ($sid, $now) {
                return $r->zcount("on:$sid", (string) ($now - 300), (string) ($now + 1));
            });
        }
        wstat_json([
            'site'  => ['id' => $sid, 'name' => $site['name'], 'domain' => $site['domain']],
            'online' => $count,
            'time'  => $now,
        ]);
    }

    /** PAT 鉴权 + 站点访问校验,返回 [站点行, start, end] */
    private function site(Request $req): array
    {
        $u = PAT::user($req);
        if ($u === null) {
            http_response_code(401);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['error' => 'invalid_token', 'message' => '无效或过期的访问令牌'], JSON_UNESCAPED_UNICODE);
            exit(0);
        }
        $sid = (int) $req->input('site_id', 0);
        $site = Db::first(
            'SELECT id,name,domain,site_key,timezone FROM sites WHERE id=? AND status=1 LIMIT 1',
            [$sid]
        );
        if ($site === null || SiteAccess::role((int) $u['id'], $sid) === null) {
            wstat_err('站点不存在', 404, 404);
        }
        // 日期区间(站点时区本地日,默认近 30 天)
        $offset = Util::tzOffsetSec((string) $site['timezone']);
        $start = (string) $req->input('start', gmdate('Y-m-d', time() + $offset - 29 * 86400));
        $end = (string) $req->input('end', gmdate('Y-m-d', time() + $offset));
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
            wstat_err('日期格式应为 YYYY-MM-DD', 422);
        }
        if ($start > $end) {
            [$start, $end] = [$end, $start];
        }
        return [$site, $start, $end];
    }
}