码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/scripts/cron.php
cron.php7.5 KB
<?php
/**
 * 定时维护脚本(建议 crontab 每1分钟~每天执行,见 sql/install.sql 底部注释)
 *
 * 用法(在项目根目录下执行;开发副本为 php server/scripts/cron.php …):
 *   php scripts/cron.php session     # 回收超时空闲会话 + 清理离线访客
 *   php scripts/cron.php rollup      # 重建近3天 site_daily 汇总(幂等)
 *   php scripts/cron.php partition   # 预建 events 后续月份分区
 *   php scripts/cron.php clean       # 清理过期明细(按保留期删旧分区)
 *   php scripts/cron.php alert       # 评估流量异常告警规则并推送(建议每5分钟)
 *   php scripts/cron.php report      # 到点发送日报/周报(建议每小时)
 *   php scripts/cron.php session rollup   # 可叠加执行
 */
declare(strict_types=1);

require dirname(__DIR__) . '/app/bootstrap.php';

use Wstat\Support\AlertEngine;
use Wstat\Support\Db;
use Wstat\Support\Rds;
use Wstat\Support\Sessionizer;
use Wstat\Support\Settings;

$actions = array_slice($argv, 1);
if (!$actions) {
    $actions = ['session'];
}

foreach ($actions as $act) {
    if (!method_exists(Cron::class, $act)) {
        fwrite(STDERR, "[cron] unknown action: $act\n");
        exit(1);
    }
    $t0 = microtime(true);
    $msg = Cron::$act();
    fwrite(STDOUT, sprintf("[cron] %-9s %s (%.2fs)\n", $act, $msg, microtime(true) - $t0));
}
exit(0);

final class Cron
{
    /** 会话回收:关闭空闲超时的 ssn hash,并清理在线集合中的离线成员 */
    public static function session(): string
    {
        $r = Rds::get();
        if ($r === null) {
            return 'redis unavailable';
        }
        [$closed, $offline] = Sessionizer::reapIdle($r);
        return "closed=$closed offline=$offline";
    }

    /** 重建近 3 天 site_daily(幂等:先删近段再回填) */
    public static function rollup(): string
    {
        // UTC 近 3 天为删除窗口(放宽至4天覆盖 +14 时区站点本地日)
        $cutoff = gmdate('Y-m-d', time() - 4 * 86400);
        Db::execute('DELETE FROM site_daily WHERE `day` >= ?', [$cutoff]);

        // 1) 明细聚合(pv/uv/ipc),单条 SQL 覆盖全部站点
        $n1 = Db::execute(
            'INSERT INTO site_daily (site_id, `day`, pv, uv, ipc, updated_at)
             SELECT site_id, `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,
                    UNIX_TIMESTAMP()
             FROM events WHERE `day` >= ?
             GROUP BY site_id, `day`',
            [$cutoff]
        );

        // 2) 会话聚合(visits/bounce/duration/new_users),按站点时区逐日回填
        $sites = Db::select('SELECT id, timezone FROM sites WHERE status=1');
        foreach ($sites as $s) {
            $tz = (string) $s['timezone'];
            $off = \Wstat\Support\Util::tzOffsetSec($tz);
            $wins = [];
            for ($i = 3; $i >= 0; $i--) {
                $d = gmdate('Y-m-d', time() + $off - $i * 86400);
                $wins[$d] = \Wstat\Support\Util::dateRangeToTs($d, $d, $tz);
            }
            foreach ($wins as $d => [$ts0, $ts1]) {
                $agg = Db::first(
                    'SELECT 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<?',
                    [(int) $s['id'], $ts0, $ts1]
                ) ?: ['c' => 0, 'b' => 0, 'du' => 0, 'nw' => 0];
                if ((int) $agg['c'] <= 0) {
                    continue;
                }
                Db::execute(
                    'INSERT INTO site_daily (site_id,`day`,visits,bounce,duration,new_users,updated_at)
                     VALUES (?,?,?,?,?,?,UNIX_TIMESTAMP())
                     ON DUPLICATE KEY UPDATE visits=visits+?, bounce=bounce+?, duration=duration+?, new_users=new_users+?',
                    [
                        (int) $s['id'], $d, (int) $agg['c'], (int) $agg['b'], (int) $agg['du'], (int) $agg['nw'],
                        (int) $agg['c'], (int) $agg['b'], (int) $agg['du'], (int) $agg['nw'],
                    ]
                );
            }
        }
        return 'sites=' . count($sites) . ' events_rows=' . $n1;
    }

    /** 预建 events 后续月份分区(当月 + 未来2个月) */
    public static function partition(): string
    {
        $row = Db::first(
            "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd
             FROM information_schema.PARTITIONS
             WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='events'
             ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 1"
        );
        if ($row === null || !preg_match('/\'(\d{4}-\d{2}-\d{2})\'/', (string) $row['pd'], $m)) {
            return 'no partition or unparseable';
        }
        $maxDate = new DateTimeImmutable($m[1]);   // 当前最大分区边界
        $target = (new DateTimeImmutable('now'))->modify('+2 months');
        $targetDay = new DateTimeImmutable($target->format('Y-m-01'));
        $added = 0;
        while ($maxDate <= $targetDay) {
            $next = $maxDate->modify('+1 month');
            $name = 'p' . $next->format('Ym');
            $bound = $next->format('Y-m-d');
            Db::execute("ALTER TABLE `events` ADD PARTITION (PARTITION `$name` VALUES LESS THAN (TO_DAYS('$bound')))");
            $added++;
            $maxDate = $next;
        }
        return "added=$added max_bound={$maxDate->format('Y-m-d')}";
    }

    /** 评估流量异常告警规则(命中即推送并留痕) */
    public static function alert(): string
    {
        return AlertEngine::evaluate();
    }

    /** 到点发送日报 / 周报(按订阅的本地小时判断,当天幂等) */
    public static function report(): string
    {
        return AlertEngine::runReports();
    }

    /** 按保留期清理过期明细(直接 DROP 过期分区,最快) */
    public static function clean(): string
    {
        // 保留天数:系统设置 retention_days 优先(管理员界面可改);空/非法时回退 config.php
        $cfgDays = (int) wstat_config('collect.event_retention');
        $s = trim(Settings::get('retention_days'));
        $retentionDays = ($s !== '' && (int) $s > 0) ? (int) $s : max(1, $cfgDays);
        $cutoff = (new DateTimeImmutable('now'))->modify('-' . $retentionDays . ' days');
        $cutoffMonth = $cutoff->format('Y-m-01');     // 早于该月的数据都删
        $rows = Db::select(
            "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd
             FROM information_schema.PARTITIONS
             WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='events'
             ORDER BY PARTITION_ORDINAL_POSITION ASC"
        );
        $dropped = 0;
        foreach ($rows as $r) {
            $pd = (string) $r['pd'];
            if ($pd === '' || !preg_match('/\'(\d{4}-\d{2}-\d{2})\'/', $pd, $m)) {
                continue;
            }
            $high = new DateTimeImmutable($m[1]);
            // 只删“上边界 <= 截止月”的分区,且至少保留2个分区兜底
            if ($high->format('Y-m-d') <= $cutoffMonth && count($rows) - $dropped > 2) {
                $pn = (string) $r['pn'];
                Db::execute("ALTER TABLE `events` DROP PARTITION `$pn`");
                $dropped++;
            }
        }
        return "dropped=$dropped (retention={$retentionDays}d)";
    }
}