码桶
发现社区成员的开源项目
StatsController.php135.6 KB
<?php
/**
* 统计控制器(概览 / 会话 / 实时在线)
* 数据口径:概览走 site_daily 预聚合快路径(cron rollup 维护,缺失天自动回填预热);
* UV/IP 周期去重优先 Redis HLL 多键合并;会话/明细走 (site_id,start_ts) / (session_id,ts) 索引。
*/
declare(strict_types=1);
namespace Wstat\Controllers;
use Wstat\Http\Request;
use Wstat\Support\Auth;
use Wstat\Support\Db;
use Wstat\Support\IpLocator;
use Wstat\Support\Rds;
use Wstat\Support\Sessionizer;
use Wstat\Support\Settings;
use Wstat\Support\SiteAccess;
use Wstat\Support\Util;
class StatsController
{
private const MAX_RANGE_DAYS = 366;
/** GET /api/stats/overview?site_id=&start=&end= (预聚合快路径,毫秒级) */
public function overview(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$tz = (string) $site['timezone'];
$offset = Util::tzOffsetSec($tz);
// ---- 1) 预聚合快路径:site_daily(uk_site_day 索引) ----
$rows = Db::select(
'SELECT `day`,pv,uv,ipc,visits,bounce,duration,new_users
FROM site_daily WHERE site_id=? AND `day` BETWEEN ? AND ? ORDER BY `day`',
[$sid, $start, $end]
);
$have = [];
foreach ($rows as $r) {
$have[(string) $r['day']] = $r;
}
// ---- 2) 缺失天补齐(rollup 未覆盖/首次部署):批量走 day 索引查明细并回写预热 ----
$days = [];
foreach (Util::dailySeries($startTs, $endTs, $tz, fn () => null) as $d => $_) {
$days[] = (string) $d;
}
$missing = array_values(array_filter($days, fn ($d) => !isset($have[$d])));
if (!empty($missing)) {
$ph = implode(',', array_fill(0, count($missing), '?'));
// 2a) events 按日聚合(idx_site_day_type)
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` IN ($ph) GROUP BY `day`",
[$sid, ...$missing]
) as $r
) {
$d = (string) $r['day'];
$base = $have[$d] ?? self::emptyDaily($d);
$have[$d] = array_merge($base, [
'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'ipc' => (int) $r['ipc'],
]);
}
// 2b) sessions 按站点本地日聚合(idx_site_start 范围扫描)
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 (!in_array($d, $missing, true)) {
continue;
}
$base = $have[$d] ?? self::emptyDaily($d);
$have[$d] = array_merge($base, [
'visits' => (int) $r['c'], 'bounce' => (int) $r['b'],
'duration' => (int) $r['du'], 'new_users' => (int) $r['nw'],
]);
}
// 回写 site_daily 预热,下次请求直接命中快路径(幂等)
foreach ($missing as $d) {
$h = $have[$d] ?? null;
if ($h === null) {
continue;
}
Db::execute(
'INSERT INTO site_daily (site_id,`day`,pv,uv,ipc,visits,bounce,duration,new_users,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,UNIX_TIMESTAMP())
ON DUPLICATE KEY UPDATE pv=VALUES(pv), uv=VALUES(uv), ipc=VALUES(ipc),
visits=VALUES(visits), bounce=VALUES(bounce), duration=VALUES(duration), new_users=VALUES(new_users)',
[$sid, $d, (int) $h['pv'], (int) $h['uv'], (int) $h['ipc'], (int) $h['visits'],
(int) $h['bounce'], (int) $h['duration'], (int) $h['new_users']]
);
}
}
// ---- 2c) 兜底校正:0 值汇总行 / 中间态(仅 PV 回写而 visits 未回写)会挡住面板指标。
// 区间内 PV 或 visits 任一合计为 0,而明细确有 pageview(访问真实发生)→ 强制按明细重算并回写。
$pvSum2c = 0;
$visitSum2c = 0;
foreach ($days as $d) {
$h = $have[$d] ?? null;
$pvSum2c += (int) ($h['pv'] ?? 0);
$visitSum2c += (int) ($h['visits'] ?? 0);
}
$needCorr = ($pvSum2c === 0 || $visitSum2c === 0);
if ($needCorr) {
$hasPv = (int) Db::value(
'SELECT 1 FROM events WHERE site_id=? AND type="pageview" AND `day` BETWEEN ? AND ? LIMIT 1',
[$sid, $start, $end]
);
if ($hasPv === 1) {
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'];
$base = $have[$d] ?? self::emptyDaily($d);
$have[$d] = array_merge($base, [
'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'ipc' => (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 (!in_array($d, $days, true)) {
continue;
}
$base = $have[$d] ?? self::emptyDaily($d);
$have[$d] = array_merge($base, [
'visits' => (int) $r['c'], 'bounce' => (int) $r['b'],
'duration' => (int) $r['du'], 'new_users' => (int) $r['nw'],
]);
}
foreach ($days as $d) {
$h = $have[$d] ?? null;
if ($h === null) {
continue;
}
Db::execute(
'INSERT INTO site_daily (site_id,`day`,pv,uv,ipc,visits,bounce,duration,new_users,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,UNIX_TIMESTAMP())
ON DUPLICATE KEY UPDATE pv=VALUES(pv), uv=VALUES(uv), ipc=VALUES(ipc),
visits=VALUES(visits), bounce=VALUES(bounce), duration=VALUES(duration), new_users=VALUES(new_users)',
[$sid, $d, (int) $h['pv'], (int) $h['uv'], (int) $h['ipc'], (int) $h['visits'],
(int) $h['bounce'], (int) $h['duration'], (int) $h['new_users']]
);
}
}
}
// ---- 3) 趋势序列 + 区间求和 ----
$sum = ['pv' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];
$trend = [];
foreach ($days as $d) {
$h = $have[$d] ?? null;
$trend[] = [
'day' => $d,
'pv' => (int) ($h['pv'] ?? 0),
'uv' => (int) ($h['uv'] ?? 0),
'ipc' => (int) ($h['ipc'] ?? 0),
];
$sum['pv'] += (int) ($h['pv'] ?? 0);
$sum['visits'] += (int) ($h['visits'] ?? 0);
$sum['bounce'] += (int) ($h['bounce'] ?? 0);
$sum['duration'] += (int) ($h['duration'] ?? 0);
$sum['new_users'] += (int) ($h['new_users'] ?? 0);
}
// ---- 4) UV/IP 周期去重(精确):events 明细 DISTINCT,一次扫描同时取 UV 与独立 IP ----
// 2026-09-15 变更:此前 Redis 可用时走 HyperLogLog(PFCOUNT)估算,而估算在极小基数下会多估,
// 出现「概览 IP 数 4 / IP 地域页 3」这类不一致。现在无论是否部署 Redis,UV/IP 一律走同一条
// 精确 SQL,与 IP 地域页、大屏、会话明细完全同口径(口径一致优先于省内存)。
$uniq = $this->exactUniq($sid, $start, $end);
$uvTotal = $uniq['uv'];
$ipcTotal = $uniq['ipc'];
// Redis 句柄:此后仅用于「进行中会话」合并,不再参与 UV/IP 统计
$redis = null;
try {
$redis = Rds::get();
} catch (\Throwable $e) {
$redis = null;
}
// ---- 5) 今日实时修正:rollup 有分钟级延迟,故今日 PV/UV/IP 一律用 events 实况覆盖。
// 这里不再是「Redis 分支 / 无 Redis 分支」两套数字:PV/UV/IP 走同一个 todayExact(),
// 保证同一站点同一时刻,无论是否部署 Redis,概览数字完全相同。会话侧才按模式分流:
// · Redis 模式:并入仍驻留 Redis 的「进行中会话」(尚未回收写入 sessions 表);
// · 无 Redis 模式:sessions 表即时写入,用「实况 - 已汇总」差值修正。
$todayLocal = gmdate('Y-m-d', time() + $offset);
if (in_array($todayLocal, $days, true)) {
try {
$t = $this->todayExact($sid, $todayLocal);
$rolledPv = (int) ($have[$todayLocal]['pv'] ?? 0);
foreach ($trend as &$tr) {
if ($tr['day'] === $todayLocal) {
$tr['pv'] = $t['pv'];
$tr['uv'] = $t['uv'];
$tr['ipc'] = $t['ipc'];
}
}
unset($tr);
$sum['pv'] += max(0, $t['pv'] - $rolledPv); // 总计补上 rollup 未落库的今日增量
[$ts0, $ts1] = Util::dateRangeToTs($todayLocal, $todayLocal, $tz);
if ($redis !== null) {
$liveRows = Sessionizer::liveRows($redis, $sid, $ts0, $ts1);
if ($liveRows) {
$liveVisits = count($liveRows);
$liveBounce = 0;
$liveDur = 0;
foreach ($liveRows as $lv) {
$liveBounce += (int) $lv['bounce'];
$liveDur += (int) $lv['duration'];
}
$sum['visits'] += $liveVisits;
$sum['bounce'] += $liveBounce;
$sum['duration'] += $liveDur;
foreach ($trend as &$tr) {
if ($tr['day'] === $todayLocal) {
$tr['visits'] = ($tr['visits'] ?? 0) + $liveVisits;
}
}
unset($tr);
}
} else {
$srow = Db::first(
'SELECT COUNT(*) c, COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du
FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<?',
[$sid, $ts0, $ts1]
);
if ($srow) {
$lv = (int) $srow['c'];
$lb = (int) $srow['b'];
$ld = (int) $srow['du'];
$sum['visits'] += max(0, $lv - (int) ($have[$todayLocal]['visits'] ?? 0));
$sum['bounce'] += max(0, $lb - (int) ($have[$todayLocal]['bounce'] ?? 0));
$sum['duration'] += max(0, $ld - (int) ($have[$todayLocal]['duration'] ?? 0));
foreach ($trend as &$tr) {
if ($tr['day'] === $todayLocal) {
$tr['visits'] = $lv;
}
}
unset($tr);
}
}
} catch (\Throwable $e) { /* 忽略实时修正失败 */ }
}
$visits = $sum['visits'];
$totals = [
'pv' => $sum['pv'], 'uv' => $uvTotal, 'ipc' => $ipcTotal, 'visits' => $visits,
'bounce' => (int) $sum['bounce'],
'bounce_rate' => $visits > 0 ? round($sum['bounce'] / $visits * 100, 1) : 0,
'avg_duration' => $visits > 0 ? (int) round($sum['duration'] / $visits) : 0,
'new_users' => (int) $sum['new_users'],
];
$totals['old_users'] = max(0, (int) $uvTotal - (int) $totals['new_users']);
// ---- 5b) 单日区间:小时级分时序列(趋势卡分时视图) ----
$hourly = [];
if ($start === $end) {
$hmap = [];
foreach (
Db::select(
"SELECT LPAD(HOUR(FROM_UNIXTIME(ts+?)),2,'0') h,
SUM(type='pageview') pv,
COUNT(DISTINCT CASE WHEN type='pageview' THEN visitor_id END) uv
FROM events WHERE site_id=? AND `day`=? GROUP BY h",
[$offset, $sid, $start]
) as $r
) {
$hmap[(string) $r['h']] = ['pv' => (int) $r['pv'], 'uv' => (int) $r['uv']];
}
for ($i = 0; $i < 24; $i++) {
$h = sprintf('%02d', $i);
$hourly[] = ['hour' => $h . ':00', 'pv' => $hmap[$h]['pv'] ?? 0, 'uv' => $hmap[$h]['uv'] ?? 0];
}
}
// ---- 5c) 环比:上一等长区间汇总(site_daily 快路径,覆盖不全退回明细聚合) ----
// 注意:strtotime/date 均按默认时区解析与格式化,二者配对自洽;不可混用 gmdate
$len = count($days);
$pEnd = date('Y-m-d', strtotime($start . ' 00:00:00') - 86400);
$pStart = date('Y-m-d', strtotime($pEnd . ' 00:00:00') - ($len - 1) * 86400);
$prev = $this->periodTotals($sid, $pStart, $pEnd, $tz);
$deltaKeys = ['pv', 'uv', 'ipc', 'visits', 'bounce_rate', 'avg_duration', 'new_users', 'old_users'];
$deltas = [];
foreach ($deltaKeys as $k) {
$c = (float) ($totals[$k] ?? 0);
$p = (float) ($prev[$k] ?? 0);
if ($p > 0) {
$deltas[$k] = round(($c - $p) / $p * 100, 1);
} else {
$deltas[$k] = $c > 0 ? 100.0 : null;
}
}
// ---- 实时(今日/分钟/在线) ----
$live = $this->live($sid, 0, $offset);
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'totals' => $totals,
'deltas' => $deltas,
'trend' => $trend,
'hourly' => $hourly,
'live' => $live,
]);
}
/** 等长区间汇总(环比用):site_daily 优先,覆盖不全时退回 events/sessions 明细聚合 */
private function periodTotals(int $sid, string $start, string $end, string $tz): array
{
$rows = Db::select(
'SELECT pv,visits,bounce,duration,new_users FROM site_daily
WHERE site_id=? AND `day` BETWEEN ? AND ?',
[$sid, $start, $end]
);
$pv = 0; $visits = 0; $bounce = 0; $dur = 0; $new = 0;
foreach ($rows as $r) {
$pv += (int) $r['pv'];
$visits += (int) $r['visits'];
$bounce += (int) $r['bounce'];
$dur += (int) $r['duration'];
$new += (int) $r['new_users'];
}
$expected = (int) ((strtotime($end) - strtotime($start)) / 86400) + 1;
$partial = count($rows) < $expected;
// UV/IP 精确去重:与 overview 共用同一条 SQL,环比数字必须与主指标同口径
$uniq = $this->exactUniq($sid, $start, $end);
$uv = $uniq['uv'];
$ipc = $uniq['ipc'];
if ($partial) {
$pv2 = (int) Db::value(
"SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
[$sid, $start, $end]
);
if ($pv2 > $pv) {
$pv = $pv2;
}
}
if ($partial) {
[$ts0, $ts1] = Util::dateRangeToTs($start, $end, $tz);
$s = 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<?',
[$sid, $ts0, $ts1]
);
if ($s) {
$visits = max($visits, (int) $s['c']);
$bounce = max($bounce, (int) $s['b']);
$dur = max($dur, (int) $s['du']);
$new = max($new, (int) $s['nw']);
}
}
return [
'pv' => $pv, 'uv' => $uv, 'ipc' => $ipc, 'visits' => $visits,
'bounce_rate' => $visits > 0 ? round($bounce / $visits * 100, 1) : 0,
'avg_duration' => $visits > 0 ? (int) round($dur / $visits) : 0,
'new_users' => $new,
'old_users' => max(0, (int) $uv - $new),
];
}
/**
* 区间「唯一值」精确去重:UV 与独立 IP 一次扫描同时得到。
*
* 这是全站 UV / 独立 IP 的唯一口径来源 —— 概览、环比、大屏、IP 地域页都必须经由它,
* 保证同一站点同一区间在任何页面看到的数字完全一致(是否部署 Redis 都不影响结果)。
* 独立 IP 排除空串(取不到真实 IP 的事件不计入),与 IP 地域页的 `ip<>''` 口径一致。
*/
private function exactUniq(int $sid, string $start, string $end): array
{
$r = Db::first(
"SELECT COUNT(DISTINCT visitor_id) uv,
COUNT(DISTINCT CASE WHEN ip<>'' THEN ip END) ipc
FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
[$sid, $start, $end]
) ?: [];
return ['uv' => (int) ($r['uv'] ?? 0), 'ipc' => (int) ($r['ipc'] ?? 0)];
}
/**
* 今日实况(精确):PV / UV / 独立 IP 一次扫描。
* 用于覆盖 rollup 滞后导致的今日数据陈旧;Redis 模式与无 Redis 模式共用本方法,
* 因此两种部署形态的今日数字完全相同。
*/
private function todayExact(int $sid, string $day): array
{
$r = Db::first(
"SELECT COUNT(*) pv,
COUNT(DISTINCT visitor_id) uv,
COUNT(DISTINCT CASE WHEN ip<>'' THEN ip END) ipc
FROM events WHERE site_id=? AND type='pageview' AND `day`=?",
[$sid, $day]
) ?: [];
return [
'pv' => (int) ($r['pv'] ?? 0),
'uv' => (int) ($r['uv'] ?? 0),
'ipc' => (int) ($r['ipc'] ?? 0),
];
}
/** GET /api/stats/sources?site_id=&start=&end= 来源分析 */
public function sources(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$tz = (string) $site['timezone'];
$w = 'site_id=? AND type="pageview" AND `day` BETWEEN ? AND ?';
$args = [$sid, $start, $end];
$types = ['direct', 'search', 'social', 'link', 'paid', 'utm', 'internal'];
// 预检:来源分析查询依赖归因扩展列;库未跑增量迁移时给出明确提示而非 SQLSTATE 500
$missing = [];
$cols = Db::tableColumns('events');
foreach (['utm_source', 'utm_medium', 'click_source', 'ref_host'] as $need) {
if (!isset($cols[$need])) {
$missing[] = $need;
}
}
if ($missing) {
$alter = "ALTER TABLE `events`\n ADD COLUMN `utm_source` VARCHAR(128) NOT NULL DEFAULT '' AFTER `medium`,\n ADD COLUMN `utm_medium` VARCHAR(128) NOT NULL DEFAULT '' AFTER `utm_source`,\n ADD COLUMN `click_source` VARCHAR(64) NOT NULL DEFAULT '' AFTER `click_id`,\n ADD COLUMN `ref_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `click_source`;";
wstat_err(
'events 表缺少来源分析列:' . implode(', ', $missing) . "。请在数据库执行升级:\n\n" . $alter
. "\n\n或直接运行:mysql -uroot -p < sql/upgrade-2026-09-09-sources.sql(路径以部署目录为准)。"
. '执行后刷新本页即可;若 worker/collect 为常驻进程,请重启以刷新列缓存。',
200, 1
);
return;
}
// 渠道 PV(source 列 = 渠道类型)
$ch = [];
foreach ($types as $tp) {
$ch[$tp] = 0;
}
foreach (
Db::select("SELECT source k, COUNT(*) pv FROM events WHERE $w AND source IN ('" . implode("','", $types) . "') GROUP BY source", $args) as $r
) {
$ch[(string) $r['k']] = (int) $r['pv'];
}
$totalPv = array_sum($ch);
// 渠道×日趋势(堆叠图数据)
$days = [];
foreach (Util::dailySeries($startTs, $endTs, $tz, fn () => null) as $d => $_) {
$days[] = (string) $d;
}
$trendData = [];
foreach ($types as $tp) {
$trendData[$tp] = array_fill(0, count($days), 0);
}
$dayIdx = array_flip($days);
foreach (
Db::select("SELECT `day` d, source k, COUNT(*) pv FROM events WHERE $w AND source IN ('" . implode("','", $types) . "') GROUP BY `day`, source ORDER BY d", $args) as $r
) {
$i = $dayIdx[(string) $r['d']] ?? -1;
$k = (string) $r['k'];
if ($i >= 0 && isset($trendData[$k])) {
$trendData[$k][$i] = (int) $r['pv'];
}
}
// 外链主机 TOP(ref_host)
$referrers = Db::select(
"SELECT ref_host host, COUNT(*) pv FROM events WHERE $w AND ref_host<>'' GROUP BY ref_host ORDER BY pv DESC LIMIT 20",
$args
);
// 说明:UTM 投放系列与广告平台(ClickID)已统一归口「广告追踪」页(/api/stats/ads),
// 本接口不再返回 campaigns / ads,避免同一批数据两处展示、口径分裂。
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'total_pv' => $totalPv,
'channels' => array_map(fn ($tp) => ['type' => $tp, 'pv' => $ch[$tp]], $types),
'trend' => ['days' => $days, 'data' => $trendData],
'referrers' => $referrers,
]);
}
/** GET /api/stats/sessions?site_id=&page=&size=&start=&end=&q= */
public function sessions(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$page = max(1, (int) $req->input('page', 1));
$size = min(100, max(1, (int) $req->input('size', 15)));
$q = trim((string) $req->input('q', ''));
$where = 'site_id=? AND start_ts>=? AND start_ts<?';
$args = [(int) $site['id'], $startTs, $endTs];
if ($q !== '') {
$like = '%' . $this->likeEscape($q) . '%';
// 支持按 访客/URL/IP/终端用户标识 检索(ip/end_user 为迁移项:仅当列存在时加入匹配)
$scols = Db::tableColumns('sessions');
$ipLike = isset($scols['ip']) ? " OR ip LIKE ? ESCAPE '\\\\'" : '';
$euLike = isset($scols['end_user']) ? " OR end_user LIKE ? ESCAPE '\\\\'" : '';
$where .= " AND (visitor_id LIKE ? ESCAPE '\\\\' OR entry_url LIKE ? ESCAPE '\\\\'$ipLike$euLike)";
$args[] = $like;
$args[] = $like;
if ($ipLike !== '') {
$args[] = $like;
}
if ($euLike !== '') {
$args[] = $like;
}
}
// 高级筛选(模糊匹配):浏览器 / 操作系统 / 设备 / 来源渠道
foreach (['browser', 'os', 'device', 'source'] as $fcol) {
$fval = trim((string) $req->input($fcol, ''));
if ($fval !== '') {
$where .= " AND $fcol LIKE ? ESCAPE '\\\\'";
$args[] = '%' . $this->likeEscape($fval) . '%';
}
}
$hasFilter = $q !== '' || (bool) array_filter(array_map(fn ($c) => trim((string) $req->input($c, '')), ['browser', 'os', 'device', 'source']));
$total = (int) Db::value("SELECT COUNT(*) FROM sessions WHERE $where", $args);
$ipCol = isset(Db::tableColumns('sessions')['ip']) ? ',ip' : '';
$euCol = isset(Db::tableColumns('sessions')['end_user']) ? ',end_user' : '';
$rows = Db::select(
"SELECT id,session_id,visitor_id$euCol,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 " . (($page - 1) * $size) . ",$size",
$args
);
// ---- 进行中会话(Redis 实时)合并:仅首页/无搜索/区间含今日时前置展示 ----
$todayLocal = gmdate('Y-m-d', time() + Util::tzOffsetSec((string) $site['timezone']));
$redis = Rds::get();
if ($redis !== null && $page === 1 && !$hasFilter && $start <= $todayLocal && $todayLocal <= $end) {
$pre = [];
foreach (Sessionizer::liveRows($redis, (int) $site['id'], $startTs, $endTs) as $i => $row) {
$pre[] = [
'id' => -(100000 + $i), // 伪 id(负数,不与 MySQL 行冲突,rowKey 唯一)
'session_id' => (string) ($row['session_id'] ?? ''),
'visitor_id' => (string) ($row['visitor_id'] ?? ''),
'end_user' => (string) ($row['end_user'] ?? ''),
'start_ts' => (int) ($row['start_ts'] ?? 0),
'end_ts' => (int) ($row['end_ts'] ?? 0),
'pageviews' => (int) ($row['pageviews'] ?? 1),
'duration' => (int) ($row['duration'] ?? 0),
'bounce' => (int) ($row['bounce'] ?? 0),
'is_new' => (int) ($row['is_new'] ?? 0),
'entry_url' => (string) ($row['entry_url'] ?? ''),
'exit_url' => (string) ($row['exit_url'] ?? ''),
'source' => (string) ($row['source'] ?? 'direct'),
'medium' => (string) ($row['medium'] ?? ''),
'campaign' => (string) ($row['campaign'] ?? ''),
'click_id' => (string) ($row['click_id'] ?? ''),
'browser' => (string) ($row['browser'] ?? ''),
'os' => (string) ($row['os'] ?? ''),
'device' => (string) ($row['device'] ?? ''),
'screen' => (string) ($row['screen'] ?? ''),
'lang' => (string) ($row['lang'] ?? ''),
'ip' => (string) ($row['ip'] ?? ''),
'country' => (string) ($row['country'] ?? ''),
'province' => (string) ($row['province'] ?? ''),
'city' => (string) ($row['city'] ?? ''),
'live' => true,
];
}
if ($pre) {
$rows = array_merge($pre, $rows);
$total += count($pre);
}
}
wstat_json(['items' => $rows, 'total' => $total, 'page' => $page, 'size' => $size]);
}
/** GET /api/stats/sessions/{id}?site_id= 单个会话 + 完整行为日志 */
public function sessionDetail(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
$ipCol = isset(Db::tableColumns('sessions')['ip']) ? ',ip' : '';
$sess = Db::first(
'SELECT id,session_id,visitor_id,start_ts,end_ts,pageviews,duration,bounce,is_new,
entry_url,exit_url,source,medium,campaign,content,term,click_id,
browser,os,device,screen,lang,country,province,city' . $ipCol . '
FROM sessions WHERE id=? AND site_id=? LIMIT 1',
[(int) $req->param('id'), (int) $site['id']]
);
if ($sess === null) {
// 进行中会话(尚未落库):按 session_id 从 Redis 构建
$sidQ = trim((string) $req->input('sid', ''));
if ($sidQ !== '') {
$r = Rds::get();
if ($r !== null) {
$h = $r->hgetall(Sessionizer::HASH_KEY . $sidQ);
$row = Sessionizer::buildRow($h);
if ($row !== null && (int) $row['site_id'] === (int) $site['id']) {
$row['id'] = 0;
$row['live'] = true;
$sess = $row;
}
}
}
if ($sess === null) {
wstat_err('会话不存在', 404, 404);
}
}
// 行为日志:该会话下的全部事件(session_id 精确命中,含类型细分)
$events = Db::select(
'SELECT ts,type,url,title,ref,source,payload
FROM events WHERE session_id=? AND ts>=? AND ts<=?
ORDER BY ts ASC LIMIT 1000',
[(string) $sess['session_id'], (int) $sess['start_ts'] - 30, (int) $sess['end_ts'] + 30]
);
foreach ($events as &$e) {
$e['payload'] = $e['payload'] !== null && $e['payload'] !== '' ? json_decode($e['payload'], true) : null;
}
unset($e);
// 访客级统计:同一 visitor_id 的访问次数 / 总浏览量 / 平均访问时长 / 首末出现
$vid = (string) ($sess['visitor_id'] ?? '');
$live = !empty($sess['live']);
$vs = [
'visits' => 1,
'views' => (int) ($sess['pageviews'] ?? 0),
'avg_dur' => (int) ($sess['duration'] ?? 0),
'first_ts' => (int) ($sess['start_ts'] ?? 0),
'last_ts' => (int) ($sess['end_ts'] ?? 0),
];
if ($vid !== '') {
$agg = Db::first(
'SELECT COUNT(*) c, COALESCE(SUM(pageviews),0) pv, COALESCE(ROUND(AVG(duration)),0) dur,
MIN(start_ts) f, MAX(end_ts) l
FROM sessions WHERE site_id=? AND visitor_id=?',
[(int) $site['id'], $vid]
);
if ($agg) {
$c = (int) $agg['c'];
$den = $c + ($live ? 1 : 0);
$vs['visits'] = $den;
$vs['views'] = (int) $agg['pv'] + ($live ? (int) $sess['pageviews'] : 0);
$sumDur = ((int) $agg['dur']) * $c + ($live ? (int) $sess['duration'] : 0);
$vs['avg_dur'] = $den > 0 ? (int) round($sumDur / $den) : 0;
$vs['first_ts'] = min((int) $agg['f'] ?: PHP_INT_MAX, (int) $sess['start_ts']);
$vs['last_ts'] = max((int) $agg['l'], (int) $sess['end_ts']);
}
}
wstat_json(['session' => $sess, 'events' => $events, 'visitor' => $vs, 'now' => time()]);
}
/** GET /api/stats/online?site_id= 当前在线访客(近5分钟活跃) */
public function online(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
$sid = (int) $site['id'];
$now = time();
$items = [];
if (!Rds::enabled()) {
// 无 Redis 模式:在线 = sessions 近 5 分钟活跃(end_ts 随 pageview 刷新)
$count = self::onlineCountDb($sid, $now);
$rows = Db::select(
'SELECT session_id, end_ts, pageviews, exit_url url, entry_url,
browser, os, device, country, province, city
FROM sessions WHERE site_id=? AND end_ts>=? AND start_ts<=?
ORDER BY end_ts DESC LIMIT 50',
[$sid, $now - 300, $now]
);
foreach ($rows as $r) {
$items[] = [
'session_id' => (string) $r['session_id'],
'last_seen' => (int) $r['end_ts'],
'pageviews' => (int) $r['pageviews'],
'url' => (string) ($r['url'] ?: $r['entry_url']),
'title' => '',
'browser' => (string) $r['browser'],
'os' => (string) $r['os'],
'device' => (string) $r['device'],
'country' => (string) $r['country'],
'province' => (string) $r['province'],
'city' => (string) $r['city'],
];
}
wstat_json(['online' => $count, 'items' => $items]);
return;
}
$count = (int) Rds::safe(function ($r) use ($sid, $now, &$items) {
$min = (string) ($now - 300);
$cnt = $r->zcount("on:$sid", $min, (string) ($now + 1));
$tops = $r->zrangebyscore("on:$sid", $min, (string) ($now + 1), true, 50);
foreach ($tops as $sessId => $score) {
$ssn = $r->hgetall("ssn:$sessId");
if (!$ssn) {
continue;
}
$items[] = [
'session_id' => $sessId,
'last_seen' => (int) $score,
'pageviews' => (int) ($ssn['pv'] ?? 0),
'url' => $ssn['url'] ?? '',
'title' => $ssn['title'] ?? '',
'browser' => $ssn['browser'] ?? '',
'os' => $ssn['os'] ?? '',
'device' => $ssn['device'] ?? '',
'country' => $ssn['country'] ?? '',
'province' => $ssn['province'] ?? '',
'city' => $ssn['city'] ?? '',
];
}
return $cnt;
}, 0);
wstat_json(['online' => $count, 'items' => $items]);
}
/**
* GET /api/stats/all-sites 全部站点今日/昨日数据对比(跨站点看板)。
* 口径与单站点概览一致:pv/uv/ip 走 events(events.day 已是站点本地日),
* visits/bounce/duration/new_users 走 sessions 按站点本地日聚合;在线数走 Redis zset(5 分钟窗口)。
* 返回 {items:[{site_id,name,domain,role,verified,today,yesterday,online}], total:{today,yesterday}, generated_at}
*/
public function allSites(Request $req): void
{
$u = Auth::requireUser($req);
$sites = SiteAccess::sitesFor((int) $u['id']);
$now = time();
$redis = null;
try {
$redis = Rds::get();
} catch (\Throwable $e) {
$redis = null;
}
$empty = static fn (): array => ['pv' => 0, 'uv' => 0, 'ipc' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];
$fmt = static function (array $r): array {
$v = (int) $r['visits'];
return [
'pv' => (int) $r['pv'],
'uv' => (int) $r['uv'],
'ipc' => (int) $r['ipc'],
'visits' => $v,
'bounce_rate' => $v > 0 ? round((int) $r['bounce'] / $v * 100, 1) : 0,
'avg_duration' => $v > 0 ? (int) round((int) $r['duration'] / $v) : 0,
'new_users' => (int) $r['new_users'],
];
};
$items = [];
$sumT = $empty();
$sumY = $empty();
foreach ($sites as $s) {
$sid = (int) $s['id'];
$tz = (string) $s['timezone'];
$offset = Util::tzOffsetSec($tz);
$today = gmdate('Y-m-d', $now + $offset);
$yest = gmdate('Y-m-d', $now + $offset - 86400);
[$ts0, $ts1] = Util::dateRangeToTs($yest, $today, $tz);
// events 按 day 聚合(idx_site_day_type),day 已是站点本地日,两天的量一次查完
$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' AND ip<>'' THEN ip END) ipc
FROM events WHERE site_id=? AND `day` IN (?,?) GROUP BY `day`",
[$sid, $yest, $today]
) as $r
) {
$ev[(string) $r['day']] = $r;
}
// sessions 按站点本地日聚合(idx_site_start 范围扫描,昨天 0 点 ~ 明天 0 点)
$se = [];
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, $ts0, $ts1]
) as $r
) {
$se[(string) $r['d']] = $r;
}
$mk = static function (string $d) use ($ev, $se): array {
$e = $ev[$d] ?? null;
$s = $se[$d] ?? null;
return [
'pv' => (int) ($e['pv'] ?? 0),
'uv' => (int) ($e['uv'] ?? 0),
'ipc' => (int) ($e['ipc'] ?? 0),
'visits' => (int) ($s['c'] ?? 0),
'bounce' => (int) ($s['b'] ?? 0),
'duration' => (int) ($s['du'] ?? 0),
'new_users' => (int) ($s['nw'] ?? 0),
];
};
$t = $mk($today);
$y = $mk($yest);
// 实时在线(5 分钟窗口):Redis 模式走 zset;无 Redis 模式走 sessions 近 5 分钟活跃
if ($redis !== null) {
$online = (int) Rds::safe(static function ($r) use ($sid, $now) {
return $r->zcount("on:$sid", (string) ($now - 300), (string) ($now + 1));
}, 0);
} else {
$online = self::onlineCountDb($sid, $now);
}
$items[] = [
'site_id' => $sid,
'name' => (string) $s['name'],
'domain' => (string) $s['domain'],
'role' => (string) ($s['role'] ?? ''),
'verified' => (int) ($s['verified_at'] ?? 0) > 0,
'today' => $fmt($t),
'yesterday' => $fmt($y),
'online' => $online,
];
foreach (['pv', 'uv', 'ipc', 'visits', 'bounce', 'duration', 'new_users'] as $k) {
$sumT[$k] += (int) $t[$k];
$sumY[$k] += (int) $y[$k];
}
}
// 默认按今日 PV 降序(前端可再排序)
usort($items, static fn ($a, $b) => $b['today']['pv'] <=> $a['today']['pv']);
wstat_json([
'items' => $items,
'total' => ['today' => $fmt($sumT), 'yesterday' => $fmt($sumY)],
'generated_at' => $now,
]);
}
/**
* GET /api/stats/auto-events?site_id=&start=&end= 自动事件 TOP(出链/下载/站内搜索)。
* 目标信息在 payload JSON($.to / $.file / $.term),按其聚合;无数据返回空数组。
*/
public function autoEvents(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
$sid = (int) $site['id'];
$agg = static function (string $type, string $field) use ($sid, $start, $end): array {
$rows = Db::select(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload, ?)) u,
COUNT(*) pv, COUNT(DISTINCT visitor_id) uv
FROM events
WHERE site_id=? AND type=? AND `day` BETWEEN ? AND ? AND payload IS NOT NULL
GROUP BY u HAVING u IS NOT NULL AND u <> ''
ORDER BY pv DESC LIMIT 50",
["\$.{$field}", $sid, $type, $start, $end]
);
foreach ($rows as &$r) {
$r['pv'] = (int) $r['pv'];
$r['uv'] = (int) $r['uv'];
}
unset($r);
return $rows;
};
wstat_json([
'outlinks' => $agg('outlink', 'to'),
'downloads' => $agg('download', 'file'),
'searches' => $agg('search', 'term'),
]);
}
/* ================= 内部 ================= */
/** Web Vitals 指标清单(payload JSON 字段,前端同一顺序渲染) */
private const VITALS = ['ttfb', 'fcp', 'lcp', 'inp', 'cls'];
/** GET /api/stats/performance?site_id=&start=&end= 页面性能(perf 事件 payload 聚合) */
public function performance(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
$sid = (int) $site['id'];
$w = 'site_id=? AND type="perf" AND `day` BETWEEN ? AND ?';
$args = [$sid, $start, $end];
$avgExpr = function (string $m): string {
return "ROUND(AVG(CAST(JSON_UNQUOTE(JSON_EXTRACT(payload,'\$." . $m . "')) AS DECIMAL(12,3))),3) AS `" . $m . '`';
};
$avgs = implode(', ', array_map($avgExpr, self::VITALS));
// 1) 区间汇总(不含分组的全体平均)
$row = Db::first(
"SELECT COUNT(*) AS samples, $avgs FROM events
WHERE $w AND payload IS NOT NULL AND JSON_VALID(payload)",
$args
);
$totals = ['samples' => (int) ($row['samples'] ?? 0)];
foreach (self::VITALS as $m) {
$totals[$m] = ($row[$m] ?? null) === null ? null : round((float) $row[$m], 3);
}
// 2) 页面 TOP(按样本数倒序;仅含至少一次有效 LCP 的页面更贴近 LCP 排行榜)
$pages = Db::select(
"SELECT url, COUNT(*) AS samples, $avgs FROM events
WHERE $w AND payload IS NOT NULL AND JSON_VALID(payload)
GROUP BY url ORDER BY samples DESC LIMIT 40",
$args
);
foreach ($pages as &$p) {
foreach (self::VITALS as $m) {
$p[$m] = ($p[$m] ?? null) === null ? null : round((float) $p[$m], 3);
}
}
unset($p);
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'totals' => $totals,
'pages' => $pages,
]);
}
/** GET /api/stats/realtime?site_id= 实时访客(进行中会话 + 在线 + 今日实时) */
public function realtime(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
$sid = (int) $site['id'];
$tz = (string) $site['timezone'];
$offset = Util::tzOffsetSec($tz);
$now = time();
$out = [
'now' => $now,
'today' => ['pv' => 0, 'uv' => 0, 'ipc' => 0, 'series' => []],
'online' => 0,
'list' => ['items' => [], 'total' => 0, 'page' => 1, 'page_size' => 10, 'day' => gmdate('Y-m-d', $now + $offset)],
];
$redis = Rds::get();
if ($redis === null) {
// 无 Redis 模式:今日实时 + 在线 + 60 分钟折线全部走数据库;
// 访客列表(下方 DB 查询)照常工作,仅无「进行中会话」合并
$live = $this->liveFromDb($sid, $offset);
$out['online'] = $live['online'];
$out['today']['pv'] = $live['pv'];
$out['today']['uv'] = $live['uv'];
$out['today']['ipc'] = $live['ipc'];
$out['today']['series'] = $live['series'];
}
// Redis 直读段只在该分支里做:无 Redis 时 $redis 为 null,绝不能在 null 上调方法 ——
// 此前这里的异常会被下方大 try 的 catch 吞掉,连带把「访客列表」整段 DB 查询一起跳过,
// 表现为无 Redis 模式下实时访客页永远空列表(2026-09-15 修复)。
if ($redis !== null) {
try {
$ymd = gmdate('Ymd', $now + $offset);
$out['online'] = $redis->zcount("on:$sid", (string) ($now - 300), (string) ($now + 1));
$out['today']['pv'] = (int) $redis->hget("today:$sid", $ymd . ':pv');
// UV / 独立 IP 一律精确(events 去重),不再用 HyperLogLog 估算:
// 与概览、IP 地域页、大屏同一口径,Redis 与无 Redis 两种部署形态数字完全相同
$t = $this->todayExact($sid, gmdate('Y-m-d', $now + $offset));
$out['today']['uv'] = $t['uv'];
$out['today']['ipc'] = $t['ipc'];
// 近 60 分钟 PV 折线
$fields = [];
for ($i = 59; $i >= 0; $i--) {
$fields[] = gmdate('YmdHi', $now - $i * 60 + $offset);
}
$vals = $redis->hmget("rt:min:$sid", $fields);
if (is_array($vals)) {
foreach ($fields as $i => $f) {
$out['today']['series'][] = [
'min' => substr($f, -4, 2) . ':' . substr($f, -2),
'pv' => (int) ($vals[$i] ?? 0),
];
}
}
} catch (\Throwable $e) {
// Redis 瞬时异常:退回数据库口径(直写模式下 events/sessions 本就是唯一数据源)
try {
$live = $this->liveFromDb($sid, $offset);
$out['online'] = $live['online'];
$out['today'] = ['pv' => $live['pv'], 'uv' => $live['uv'], 'ipc' => $live['ipc'], 'series' => $live['series']];
} catch (\Throwable $e2) {
/* 保持初始空值 */
}
}
}
try {
// ---- 访客列表:所选日期的会话(DB 已落库) + Redis 进行中会话合并 ----
$day = $this->ymd((string) $req->input('day', gmdate('Y-m-d', $now + $offset)));
if ($day === '') {
wstat_err('日期格式应为 YYYY-MM-DD', 422);
}
$scope = (string) $req->input('scope', 'all'); // all | paid(推广流量)
$ipF = trim((string) $req->input('ip', ''));
$srcF = trim((string) $req->input('source', ''));
$devF = trim((string) $req->input('device', ''));
$brF = trim((string) $req->input('browser', ''));
$page = max(1, (int) $req->input('page', 1));
$pageSize = min(50, max(5, (int) $req->input('page_size', 10)));
[$ts0, $ts1] = Util::dateRangeToTs($day, $day, $tz);
$w = 'site_id=? AND start_ts>=? AND start_ts<?';
$bind = [$sid, $ts0, $ts1];
if ($scope === 'paid') {
$w .= " AND (click_id<>'' OR source IN ('paid','utm') OR medium IN ('cpc','ppc','paid'))";
}
if ($ipF !== '') {
$w .= ' AND ip LIKE ?';
$bind[] = $this->likeEscape($ipF) . '%';
}
if ($srcF !== '') {
$w .= ' AND source=?';
$bind[] = $srcF;
}
if ($devF !== '') {
$w .= ' AND device=?';
$bind[] = $devF;
}
if ($brF !== '') {
$w .= ' AND browser LIKE ?';
$bind[] = $this->likeEscape($brF) . '%';
}
$rows = Db::select(
"SELECT id,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,ip,country,province,city
FROM sessions WHERE $w ORDER BY start_ts DESC LIMIT 5000",
$bind
);
// Redis 进行中会话(未落库/未关闭):仅所选日期当天,且按 session_id 去重(以 Redis 为准)
$isToday = ($day === gmdate('Y-m-d', $now + $offset));
$liveMap = [];
if ($isToday && $redis !== null) {
foreach (Sessionizer::liveRows($redis, $sid, max($ts0, $now - 2 * 86400), $now + 1) as $lr) {
if ((int) ($lr['start_ts'] ?? 0) < $ts0) {
continue;
}
$liveMap[(string) ($lr['session_id'] ?? '')] = $lr;
}
}
$merged = [];
$seen = [];
foreach ($liveMap as $lsid => $lr) {
$seen[$lsid] = true;
$lr['duration'] = max(0, $now - (int) ($lr['start_ts'] ?? $now));
$merged[] = $lr;
}
foreach ($rows as $r) {
if (isset($seen[(string) $r['session_id']])) {
continue;
}
$merged[] = $r;
}
usort($merged, fn ($a, $b) => (int) ($b['start_ts'] ?? 0) - (int) ($a['start_ts'] ?? 0));
// 应用端过滤(Redis 行无法走 SQL):scope/ip/source/device/browser
$merged = array_values(array_filter($merged, function ($r) use ($scope, $ipF, $srcF, $devF, $brF) {
if ($scope === 'paid'
&& (string) ($r['click_id'] ?? '') === ''
&& !in_array((string) ($r['source'] ?? ''), ['paid', 'utm'], true)
&& !in_array((string) ($r['medium'] ?? ''), ['cpc', 'ppc', 'paid'], true)) {
return false;
}
if ($ipF !== '' && !str_starts_with((string) ($r['ip'] ?? ''), $ipF)) {
return false;
}
if ($srcF !== '' && (string) ($r['source'] ?? '') !== $srcF) {
return false;
}
if ($devF !== '' && (string) ($r['device'] ?? '') !== $devF) {
return false;
}
if ($brF !== '' && stripos((string) ($r['browser'] ?? ''), $brF) !== 0) {
return false;
}
return true;
}));
$total = count($merged);
$slice = array_slice($merged, ($page - 1) * $pageSize, $pageSize);
// 补充来源域名(events.ref_host,仅当前页行,一次批量查询)
$refMap = [];
$sids = array_values(array_filter(array_map(fn ($r) => (string) ($r['session_id'] ?? ''), $slice)));
if (!empty($sids)) {
$ph = implode(',', array_fill(0, count($sids), '?'));
foreach (
Db::select(
"SELECT session_id, MIN(ref_host) ref_host FROM events
WHERE site_id=? AND session_id IN ($ph) AND ref_host<>'' GROUP BY session_id",
array_merge([$sid], $sids)
) as $er
) {
$refMap[(string) $er['session_id']] = (string) $er['ref_host'];
}
}
foreach ($slice as $i => $row) {
$start = (int) ($row['start_ts'] ?? 0);
$last = (int) ($row['end_ts'] ?? 0);
$isLive = isset($liveMap[(string) ($row['session_id'] ?? '')]);
$out['list']['items'][] = [
'id' => $isLive ? -(200000 + $i) : (int) ($row['id'] ?? 0),
'session_id' => (string) ($row['session_id'] ?? ''),
'visitor_id' => (string) ($row['visitor_id'] ?? ''),
'start_ts' => $start,
'last_ts' => $last,
'duration' => $isLive ? (int) $row['duration'] : (int) ($row['duration'] ?? 0),
'pageviews' => (int) ($row['pageviews'] ?? 1),
'entry_url' => (string) ($row['entry_url'] ?? ''),
'exit_url' => (string) ($row['exit_url'] ?? ''),
'source' => (string) ($row['source'] ?? 'direct'),
'medium' => (string) ($row['medium'] ?? ''),
'campaign' => (string) ($row['campaign'] ?? ''),
'click_id' => (string) ($row['click_id'] ?? ''),
'ref_host' => $refMap[(string) ($row['session_id'] ?? '')] ?? '',
'browser' => (string) ($row['browser'] ?? ''),
'os' => (string) ($row['os'] ?? ''),
'device' => (string) ($row['device'] ?? ''),
'screen' => (string) ($row['screen'] ?? ''),
'lang' => (string) ($row['lang'] ?? ''),
'ip' => (string) ($row['ip'] ?? ''),
'country' => (string) ($row['country'] ?? ''),
'province' => (string) ($row['province'] ?? ''),
'city' => (string) ($row['city'] ?? ''),
'online' => $isToday && $last > 0 && ($now - $last) <= 300,
'live' => $isLive,
];
}
$out['list']['total'] = $total;
$out['list']['page'] = $page;
$out['list']['page_size'] = $pageSize;
$out['list']['day'] = $day;
} catch (\Throwable $e) {
/* Redis/DB 瞬时异常按空数据返回 */
}
wstat_json($out);
}
/** GET /api/stats/geo?site_id=&start=&end= IP 地理分布(会话表聚合:国家 / 省份) */
public function geo(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$w = 'site_id=? AND start_ts>=? AND start_ts<?';
$args = [$sid, $startTs, $endTs];
$total = (int) Db::value("SELECT COUNT(*) FROM sessions WHERE $w", $args);
$geoVisits = (int) Db::value("SELECT COUNT(*) FROM sessions WHERE $w AND country<>''", $args);
// 国家 TOP(省份维度独立下钻,避免混合分组)
$countries = Db::select(
"SELECT country, COUNT(*) AS visits, COUNT(DISTINCT visitor_id) AS uv,
COALESCE(SUM(pageviews),0) AS pv, COALESCE(SUM(bounce),0) AS bounce
FROM sessions WHERE $w AND country<>''
GROUP BY country ORDER BY visits DESC LIMIT 40",
$args
);
// 中国省份 TOP
$provinces = Db::select(
"SELECT province, COUNT(*) AS visits, COUNT(DISTINCT visitor_id) AS uv,
COALESCE(SUM(pageviews),0) AS pv
FROM sessions WHERE $w AND country IN ('中国','China') AND province<>''
GROUP BY province ORDER BY visits DESC LIMIT 40",
$args
);
// IP TOP(按 PV 降序,供「IP 统计」页签)
// 2026-09-15 变更:改由 events(pageview)按天聚合 —— 与概览「IP 数」同源、同区间、同口径,
// 概览 IP 数与这里的 IP 条数因此严格一致(旧实现取 sessions,会话尚未结算或跨零点会让两者对不上)。
// pv = 该 IP 的页面浏览数;uv = 去重访客数;visits = 去重会话数
$ips = Db::select(
"SELECT ip, COUNT(DISTINCT session_id) AS visits, COUNT(DISTINCT visitor_id) AS uv,
COUNT(*) AS pv
FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? AND ip<>''
GROUP BY ip ORDER BY pv DESC, visits DESC LIMIT 100",
[$sid, $start, $end]
);
// 区间独立 IP 总数(精确,与概览 ipc 完全一致;IP 列表受 LIMIT 100 截断时以本值为准)
$ipTotal = $this->exactUniq($sid, $start, $end)['ipc'];
// 诊断:驱动/离线库状态 + 区间内地理字段覆盖率(前端据此给出「为什么没有地域数据」的具体原因)
$st = IpLocator::status();
$evTotal = (int) Db::value(
'SELECT COUNT(*) FROM events WHERE site_id=? AND `day` BETWEEN ? AND ?',
[$sid, $start, $end]
);
$evGeo = (int) Db::value(
"SELECT COUNT(*) FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? AND country<>''",
[$sid, $start, $end]
);
// IPv6 访客单独计数:地域库分 v4/v6 两份,只有分开看才能定位「是 v6 库缺了」
// 还是「整体都没解析」(ip 里有冒号即为 IPv6,含 ::ffff: 映射形式)
$evIpv6 = (int) Db::value(
"SELECT COUNT(*) FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? AND ip LIKE '%:%'",
[$sid, $start, $end]
);
$evIpv6Geo = (int) Db::value(
"SELECT COUNT(*) FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? AND ip LIKE '%:%' AND country<>''",
[$sid, $start, $end]
);
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'total_visits' => $total,
'geo_visits' => $geoVisits,
'ip_total' => $ipTotal,
'countries' => $countries,
'provinces' => $provinces,
'ips' => $ips,
'geo_status' => [
'driver' => $st['effective'],
'configured' => $st['configured'],
'driver_v4' => $st['effective_v4'],
'driver_v6' => $st['effective_v6'],
'xdb_exists' => $st['xdb_exists'],
'xdb_size' => $st['xdb_size'],
'xdb_path' => $st['xdb_path'],
'xdb6_exists' => $st['xdb6_exists'],
'xdb6_size' => $st['xdb6_size'],
'xdb6_path' => $st['xdb6_path'],
'xdb_candidates' => array_map(fn ($c) => $c['file'], $st['xdb_candidates']),
'events_total' => $evTotal,
'events_geo' => $evGeo,
'events_ipv6' => $evIpv6,
'events_ipv6_geo' => $evIpv6Geo,
'sessions_total' => $total,
'sessions_geo' => $geoVisits,
],
]);
}
/**
* GET /api/stats/analysis?site_id=&start=&end=
* 页面分析:路径 / 入口URL / 退出URL / 来源域名 / 渠道 / 环境 / 位置(TOP50,含 PV 与百分比)
* 口径:页面级维度走 events pageview(day 索引);入口/退出走 sessions(SUM(pageviews))。
*/
public function analysis(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$totalPv = (int) Db::value(
"SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
[$sid, $start, $end]
);
$pct = static fn (int $pv): float => $totalPv > 0 ? round($pv / $totalPv * 100, 2) : 0.0;
// events 维度聚合(pageview 事件,idx_site_day_type 索引;$col 为内部白名单调用,无注入面)
$ev = function (string $col, string $cond = '') use ($sid, $start, $end, $pct): array {
$rows = 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 50",
[$sid, $start, $end]
);
$out = [];
foreach ($rows as $r) {
$name = (string) $r['name'];
if ($name === '') {
$name = '-';
}
$pv = (int) $r['pv'];
$out[] = ['name' => $name, 'pv' => $pv, 'pct' => $pct($pv)];
}
return $out;
};
// sessions 维度(入口/退出 URL:PV=SUM(pageviews),idx_site_start 索引)
$sess = function (string $col) use ($sid, $startTs, $endTs, $pct): 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 50",
[$sid, $startTs, $endTs]
);
$out = [];
foreach ($rows as $r) {
$pv = (int) $r['pv'];
$out[] = ['name' => (string) $r['name'], 'pv' => $pv, 'pct' => $pct($pv)];
}
return $out;
};
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'total_pv' => $totalPv,
'pages' => $ev('url'),
'entries' => $sess('entry_url'),
'exits' => $sess('exit_url'),
'referrers' => $ev('ref_host', "AND ref_host<>''"),
'channels' => $ev('source'),
'browsers' => $ev('browser', "AND browser<>''"),
'oses' => $ev('os', "AND os<>''"),
'devices' => $ev('device', "AND device<>''"),
'countries' => $ev('country', "AND country<>''"),
'provinces' => $ev('province', "AND province<>'' AND country IN ('中国','China')"),
'cities' => $ev('city', "AND city<>''"),
]);
}
/* ==================== 单页面(URL)下钻详情 ==================== */
/** 流向分析(上一页/下一页)允许扫描的页面 PV 上限,超过则标记 partial */
private const PAGE_FLOW_MAX = 50000;
/** 参与会话指标聚合的最大会话数(derived table LIMIT) */
private const PAGE_SESS_MAX = 20000;
/**
* GET /api/stats/page?site_id=&url=&match=exact|prefix&start=&end=
*
* 单页面深度分析:汇总指标 / 按天与按小时趋势 / 来源构成 / 环境与地域 /
* 上一页与下一页流向 / 页面自定义事件与点击 / 最近会话。
*
* 匹配口径:默认 exact 精确匹配完整 URL;prefix 为前缀匹配。
* 两者都同时对「完整 url」与「URL 路径」生效,兼容 SDK 上报完整 URL 与只存路径两种情形。
* 大量数据下流向分析与会话指标为主采样(partial=true),其余指标为精确值。
*/
public function pageDetail(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$tz = (string) $site['timezone'];
$offset = Util::tzOffsetSec($tz);
$url = trim((string) $req->input('url', ''));
if ($url === '') {
wstat_err('缺少页面地址参数 url', 422);
}
$match = (string) $req->input('match', 'exact');
if (!in_array($match, ['exact', 'prefix'], true)) {
$match = 'exact';
}
// 匹配条件(对完整 URL 与路径表达式双匹配);SQL 片段与绑定值成对出现
$pathExpr = self::URL_PATH_EXPR;
if ($match === 'prefix') {
$pat = $this->likeEscape($url) . '%';
$condSql = "AND (url LIKE ? ESCAPE '\\\\' OR $pathExpr LIKE ? ESCAPE '\\\\')";
$condArgs = [$pat, $pat];
} else {
$condSql = "AND (url = ? OR $pathExpr = ?)";
$condArgs = [$url, $url];
}
$base = [$sid, $start, $end, ...$condArgs];
// ---- 1) 汇总 ----
$sum = Db::first(
"SELECT COUNT(*) pv,
COUNT(DISTINCT visitor_id) uv,
COUNT(DISTINCT ip) ipc,
COUNT(DISTINCT session_id) sess,
MIN(ts) first_ts, MAX(ts) last_ts
FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql",
$base
) ?: [];
$pv = (int) ($sum['pv'] ?? 0);
// 页面标题(访问量最高的那个)
$title = (string) (Db::value(
"SELECT title FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql AND title<>''
GROUP BY title ORDER BY COUNT(*) DESC LIMIT 1",
$base
) ?? '');
// ---- 2) 会话侧指标(停留时长 / 跳出率)----
$sessAgg = Db::first(
"SELECT COUNT(*) c, COALESCE(SUM(s.bounce),0) b, COALESCE(SUM(s.duration),0) du
FROM sessions s
JOIN (SELECT DISTINCT session_id, site_id FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql
LIMIT " . self::PAGE_SESS_MAX . ") x
ON x.session_id=s.session_id AND x.site_id=s.site_id
WHERE s.site_id=? AND s.start_ts>=? AND s.start_ts<?",
[...$base, $sid, $startTs, $endTs]
) ?: ['c' => 0, 'b' => 0, 'du' => 0];
$sessCnt = (int) $sessAgg['c'];
$avgDur = $sessCnt > 0 ? round((int) $sessAgg['du'] / $sessCnt, 1) : 0;
$bounceRate = $sessCnt > 0 ? round((int) $sessAgg['b'] / $sessCnt * 100, 2) : 0;
// 作为入口页 / 退出页的次数(会话聚合表,精确)
$entryCond = $match === 'prefix'
? 'entry_url LIKE ? ESCAPE \'\\\\\''
: 'entry_url = ?';
$exitCond = $match === 'prefix'
? 'exit_url LIKE ? ESCAPE \'\\\\\''
: 'exit_url = ?';
$entryVal = $match === 'prefix' ? $this->likeEscape($url) . '%' : $url;
$entryCnt = (int) Db::value(
"SELECT COUNT(*) FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? AND $entryCond",
[$sid, $startTs, $endTs, $entryVal]
);
$exitCnt = (int) Db::value(
"SELECT COUNT(*) FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? AND $exitCond",
[$sid, $startTs, $endTs, $entryVal]
);
// ---- 3) 趋势:按天 / 按小时 ----
$trend = [];
foreach (
Db::select(
"SELECT `day`, COUNT(*) pv, COUNT(DISTINCT visitor_id) uv
FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql
GROUP BY `day` ORDER BY `day`",
$base
) as $r
) {
$trend[] = ['day' => (string) $r['day'], 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv']];
}
$hourly = [];
foreach (
Db::select(
"SELECT HOUR(FROM_UNIXTIME(ts+?)) h, COUNT(*) pv
FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql
GROUP BY h ORDER BY h",
[$offset, ...$base]
) as $r
) {
$hourly[] = ['hour' => (int) $r['h'], 'pv' => (int) $r['pv']];
}
// ---- 4) 维度分布(复用统一聚合) ----
$dim = function (string $col, string $extra = '') use ($base, $condSql): array {
$out = [];
foreach (
Db::select(
"SELECT `$col` AS name, COUNT(*) pv FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql $extra
GROUP BY `$col` ORDER BY pv DESC LIMIT 30",
$base
) as $r
) {
$out[] = ['name' => (string) $r['name'], 'pv' => (int) $r['pv']];
}
return $out;
};
$referrers = $dim('ref_host', "AND ref_host<>''");
$channels = $dim('source', "AND source<>''");
$devices = $dim('device', "AND device<>''");
$browsers = $dim('browser', "AND browser<>''");
$oses = $dim('os', "AND os<>''");
$countries = $dim('country', "AND country<>''");
$provinces = $dim('province', "AND province<>'' AND country IN ('中国','China')");
$screens = $dim('screen', "AND screen<>''");
$langs = $dim('lang', "AND lang<>''");
$utmCampaigns = $dim('campaign', "AND campaign<>''");
// ---- 5) 页面事件与点击 ----
$events = [];
foreach (
Db::select(
"SELECT COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.n')),'(未命名)') AS name,
COUNT(*) c, COUNT(DISTINCT visitor_id) uv
FROM events
WHERE site_id=? AND type='event' AND `day` BETWEEN ? AND ? $condSql
GROUP BY name ORDER BY c DESC LIMIT 30",
$base
) as $r
) {
$events[] = ['name' => (string) $r['name'], 'count' => (int) $r['c'], 'uv' => (int) $r['uv']];
}
$clicks = (int) Db::value(
"SELECT COUNT(*) FROM events
WHERE site_id=? AND type='click' AND `day` BETWEEN ? AND ? $condSql",
$base
);
$scrollDepth = Db::first(
"SELECT ROUND(AVG(CAST(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.d')) AS DECIMAL(6,2))),1) d
FROM events
WHERE site_id=? AND type='scroll' AND `day` BETWEEN ? AND ? $condSql",
$base
);
$avgScroll = isset($scrollDepth['d']) && $scrollDepth['d'] !== null ? (float) $scrollDepth['d'] : null;
// ---- 6) 流向(上一页 / 下一页):以会话为单位,锚定本页首次出现时间 ----
$flow = function (string $dir) use ($sid, $start, $end, $condSql, $condArgs): array {
$cmp = $dir === 'next' ? '>' : '<';
$sql = "SELECT e2.url AS url, COUNT(*) c
FROM (SELECT e1.session_id, " . ($dir === 'next' ? 'MIN' : 'MAX') . "(e1.ts) t0
FROM events e1
WHERE e1.site_id=? AND e1.type='pageview' AND e1.day BETWEEN ? AND ? $condSql
GROUP BY e1.session_id LIMIT " . self::PAGE_SESS_MAX . ") x
JOIN events e2
ON e2.session_id=x.session_id AND e2.site_id=? AND e2.type='pageview'
AND e2.ts $cmp x.t0 AND e2.url<>''
GROUP BY e2.url ORDER BY c DESC LIMIT 12";
$rows = [];
foreach (Db::select($sql, [$sid, $start, $end, ...$condArgs, $sid]) as $r) {
$rows[] = ['url' => (string) $r['url'], 'count' => (int) $r['c']];
}
return $rows;
};
$partial = $pv > self::PAGE_FLOW_MAX;
$nextPages = $flow('next');
$prevPages = $flow('prev');
// ---- 7) 最近会话(含该页的会话明细)----
$ipCol = isset(Db::tableColumns('sessions')['ip']) ? ',s.ip' : '';
$recentSessions = Db::select(
"SELECT s.id,s.session_id,s.visitor_id,s.start_ts,s.end_ts,s.pageviews,s.duration,
s.bounce,s.is_new,s.entry_url,s.exit_url,s.source,s.browser,s.os,s.device,
s.country,s.province,s.city$ipCol
FROM sessions s
JOIN (SELECT DISTINCT session_id, site_id FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $condSql
ORDER BY ts DESC LIMIT 200) x
ON x.session_id=s.session_id AND x.site_id=s.site_id
ORDER BY s.start_ts DESC LIMIT 20",
[...$base]
);
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'url' => $url,
'match' => $match,
'title' => $title,
'partial' => $partial,
'summary' => [
'pv' => $pv,
'uv' => (int) ($sum['uv'] ?? 0),
'ipc' => (int) ($sum['ipc'] ?? 0),
'sessions' => (int) ($sum['sess'] ?? 0),
'entries' => $entryCnt,
'exits' => $exitCnt,
'avg_duration' => $avgDur,
'bounce_rate' => $bounceRate,
'pv_per_visit' => $sessCnt > 0 ? round($pv / $sessCnt, 2) : 0,
'clicks' => $clicks,
'avg_scroll' => $avgScroll,
'first_ts' => (int) ($sum['first_ts'] ?? 0),
'last_ts' => (int) ($sum['last_ts'] ?? 0),
],
'trend' => $trend,
'hourly' => $hourly,
'referrers' => $referrers,
'channels' => $channels,
'devices' => $devices,
'browsers' => $browsers,
'oses' => $oses,
'countries' => $countries,
'provinces' => $provinces,
'screens' => $screens,
'langs' => $langs,
'campaigns' => $utmCampaigns,
'events' => $events,
'next_pages' => $nextPages,
'prev_pages' => $prevPages,
'recent_sessions' => $recentSessions,
]);
}
/**
* GET /api/stats/audience?site_id=&start=&end=
* 访问分析(访问时长/深度分布、新老访问、跳出、平均时长/深度)
* + 访客分析(新/回访访客、访问频次分布、人均访问次数/人均PV)
* 数据源:sessions(idx_site_start 索引),全部按区间聚合。
*/
public function audience(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$w = 'site_id=? AND start_ts>=? AND start_ts<?';
$args = [$sid, $startTs, $endTs];
// ---- 访问总量指标 ----
$tot = Db::first(
"SELECT COUNT(*) AS visits, COALESCE(SUM(is_new),0) AS new_visits,
COALESCE(SUM(bounce),0) AS bounce, COALESCE(AVG(duration),0) AS avg_dur,
COALESCE(AVG(pageviews),0) AS avg_pv, COALESCE(SUM(pageviews),0) AS pv,
COUNT(DISTINCT visitor_id) AS uv
FROM sessions WHERE $w",
$args
) ?: ['visits' => 0, 'new_visits' => 0, 'bounce' => 0, 'avg_dur' => 0, 'avg_pv' => 0, 'pv' => 0, 'uv' => 0];
$bucketize = function (array $rows): array {
$map = [];
foreach ($rows as $r) {
$map[(string) $r['k']] = (int) $r['c'];
}
return $map;
};
// ---- 访问时长分布 ----
$durMap = $bucketize(Db::select(
"SELECT CASE
WHEN duration <= 10 THEN 'd0_10'
WHEN duration <= 30 THEN 'd11_30'
WHEN duration <= 60 THEN 'd31_60'
WHEN duration <= 180 THEN 'd1_3'
WHEN duration <= 600 THEN 'd3_10'
ELSE 'd10m' END AS k, COUNT(*) AS c
FROM sessions WHERE $w GROUP BY k",
$args
));
// ---- 访问深度分布(每次访问 PV 数) ----
$depthMap = $bucketize(Db::select(
"SELECT CASE
WHEN pageviews <= 1 THEN 'p1'
WHEN pageviews <= 3 THEN 'p2_3'
WHEN pageviews <= 6 THEN 'p4_6'
WHEN pageviews <= 10 THEN 'p7_10'
ELSE 'p10m' END AS k, COUNT(*) AS c
FROM sessions WHERE $w GROUP BY k",
$args
));
// ---- 访客维度:访问频次分布(区间内每访客访问次数) ----
$freqRaw = Db::select(
"SELECT cnt, COUNT(*) AS c FROM (
SELECT visitor_id, COUNT(*) AS cnt
FROM sessions WHERE $w GROUP BY visitor_id
) t GROUP BY cnt",
$args
);
$freqMap = ['f1' => 0, 'f2' => 0, 'f3_5' => 0, 'f6_10' => 0, 'f10m' => 0];
foreach ($freqRaw as $r) {
$n = (int) $r['cnt'];
$k = $n <= 1 ? 'f1' : ($n === 2 ? 'f2' : ($n <= 5 ? 'f3_5' : ($n <= 10 ? 'f6_10' : 'f10m')));
$freqMap[$k] += (int) $r['c'];
}
// ---- 访问维度:入口/出口页排名 TOP10(访问次数 + PV) ----
$pageRank = function (string $col) use ($w, $args): array {
$rows = Db::select(
"SELECT `$col` AS name, COUNT(*) AS visits, COALESCE(SUM(pageviews),0) AS pv
FROM sessions WHERE $w AND `$col`<>''
GROUP BY `$col` ORDER BY visits DESC LIMIT 10",
$args
);
$out = [];
foreach ($rows as $r) {
$out[] = ['name' => (string) $r['name'], 'visits' => (int) $r['visits'], 'pv' => (int) $r['pv']];
}
return $out;
};
// ---- 访客维度:地域/浏览器/OS/设备/屏幕/语言 TOP10(按去重访客数) ----
$dim = function (string $col) use ($w, $args): array {
$rows = Db::select(
"SELECT `$col` AS name, COUNT(DISTINCT visitor_id) AS c
FROM sessions WHERE $w AND `$col`<>''
GROUP BY `$col` ORDER BY c DESC LIMIT 10",
$args
);
$out = [];
foreach ($rows as $r) {
$out[] = ['name' => (string) $r['name'], 'c' => (int) $r['c']];
}
return $out;
};
// 地域:国家+省份组合(country · province),仅统计已归属数据
$geoRows = Db::select(
"SELECT CONCAT_WS(' · ', NULLIF(country,''), NULLIF(province,'')) AS name,
COUNT(DISTINCT visitor_id) AS c
FROM sessions WHERE $w AND country<>''
GROUP BY name ORDER BY c DESC LIMIT 10",
$args
);
$geo = [];
foreach ($geoRows as $r) {
$geo[] = ['name' => (string) $r['name'], 'c' => (int) $r['c']];
}
$uv = (int) $tot['uv'];
$visits = (int) $tot['visits'];
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'totals' => [
'visits' => $visits,
'new_visits' => (int) $tot['new_visits'],
'return_visits' => max(0, $visits - (int) $tot['new_visits']),
'bounce_rate' => $visits > 0 ? round((int) $tot['bounce'] / $visits * 100, 2) : 0.0,
'avg_duration' => (int) round((float) $tot['avg_dur']),
'avg_pv' => round((float) $tot['avg_pv'], 2),
'pv' => (int) $tot['pv'],
'uv' => $uv,
'uv_new' => (int) Db::value(
"SELECT COUNT(DISTINCT CASE WHEN is_new=1 THEN visitor_id END) FROM sessions WHERE $w",
$args
),
'visits_per_uv' => $uv > 0 ? round($visits / $uv, 2) : 0.0,
'pv_per_uv' => $uv > 0 ? round((int) $tot['pv'] / $uv, 2) : 0.0,
],
'duration_dist' => $durMap, // {d0_10, d11_30, d31_60, d1_3, d3_10, d10m}
'depth_dist' => $depthMap, // {p1, p2_3, p4_6, p7_10, p10m}
'freq_dist' => $freqMap, // {f1, f2, f3_5, f6_10, f10m} 单位=访客数
'entries' => $pageRank('entry_url'), // [{name, visits, pv}]
'exits' => $pageRank('exit_url'),
'geo' => $geo, // [{name, c}] 以下均按去重访客数
'browsers' => $dim('browser'),
'oses' => $dim('os'),
'devices' => $dim('device'),
'screens' => $dim('screen'),
'langs' => $dim('lang'),
]);
}
/* ================= 内部 ================= */
/** 无 Redis(DB 直写)模式:在线访客数(sessions.end_ts 近 5 分钟活跃;end_ts 随 pageview 刷新) */
public static function onlineCountDb(int $sid, ?int $now = null): int
{
$now = $now ?? time();
return (int) Db::value(
'SELECT COUNT(*) FROM sessions WHERE site_id=? AND end_ts>=? AND start_ts<=?',
[$sid, $now - 300, $now]
);
}
/**
* 无 Redis(DB 直写)模式:今日实时指标(今日 pv/uv/ip + 近 60 分钟 PV 折线 + 在线数)。
* 与 Redis 实时口径对齐:pv/uv/ip 走 events(day=站点本地今日),折线按分钟桶聚合 pageview。
*/
private function liveFromDb(int $sid, int $offset): array
{
$now = time();
$today = gmdate('Y-m-d', $now + $offset);
$todayKey = gmdate('Ymd', $now + $offset);
// 与 Redis 模式共用 todayExact():同一站点同一时刻,两种部署形态的今日 PV/UV/IP 一定相同
$t = $this->todayExact($sid, $today);
$pv = $t['pv'];
$uv = $t['uv'];
$ipc = $t['ipc'];
$online = self::onlineCountDb($sid, $now);
// 近 60 分钟分钟桶(站点本地时):一次范围聚合,桶序号 floor((ts+offset)/60)
$bmap = [];
foreach (
Db::select(
"SELECT (ts + ?) DIV 60 m, COUNT(*) pv FROM events
WHERE site_id=? AND type='pageview' AND ts>=? AND ts<?
GROUP BY m",
[$offset, $sid, $now - 59 * 60, $now + 60]
) as $r
) {
$bmap[(int) $r['m']] = (int) $r['pv'];
}
$series = [];
for ($i = 59; $i >= 0; $i--) {
$t = $now - $i * 60;
$m = intdiv($t + $offset, 60);
$series[] = ['min' => gmdate('H:i', $m * 60), 'pv' => $bmap[$m] ?? 0];
}
return [
'date' => $today, 'todayKey' => $todayKey,
'pv' => $pv, 'uv' => $uv, 'ipc' => $ipc,
'online' => $online, 'online10' => $online,
'series' => $series,
];
}
/** 实时数据(Redis 缺失时返回 0 占位) */
private function live(int $sid, int $tzJunk, int $offset): array
{
$now = time();
$today = gmdate('Y-m-d', $now + $offset);
$todayKey = gmdate('Ymd', $now + $offset);
$redis = Rds::get();
if ($redis === null) {
// 无 Redis 模式(或 Redis 故障):今日实时直接从数据库计算
return $this->liveFromDb($sid, $offset);
}
try {
$pv = (int) $redis->hget("today:$sid", $todayKey . ':pv');
// UV / 独立 IP 精确取自 events(与概览、IP 地域页、大屏同一口径),不使用 HLL 估算
$t = $this->todayExact($sid, $today);
$uv = $t['uv'];
$ipc = $t['ipc'];
$online = $redis->zcount("on:$sid", (string) ($now - 300), (string) ($now + 1));
$online10 = $redis->zcount("on:$sid", (string) ($now - 600), (string) ($now + 1));
// 最近60分钟 PV 折线
$fields = [];
for ($i = 59; $i >= 0; $i--) {
$fields[] = gmdate('YmdHi', $now - $i * 60 + $offset);
}
$vals = $redis->hmget("rt:min:$sid", $fields);
$series = [];
if (is_array($vals)) {
foreach ($fields as $idx => $f) {
$series[] = [
'min' => substr($f, -4, 2) . ':' . substr($f, -2),
'pv' => (int) ($vals[$idx] ?? 0),
];
}
}
return ['date' => $today, 'pv' => $pv, 'uv' => $uv, 'ipc' => $ipc, 'online' => $online, 'online10' => $online10, 'series' => $series];
} catch (\Throwable $e) {
// Redis 瞬时异常:退回数据库口径(直写模式下 events/sessions 本就是唯一数据源)
try {
return $this->liveFromDb($sid, $offset);
} catch (\Throwable $e2) {
return ['date' => $today, 'pv' => 0, 'uv' => 0, 'ipc' => 0, 'online' => 0, 'online10' => 0, 'series' => []];
}
}
}
/**
* 站点归属 + 基本信息(多用户:改为 SiteAccess 校验,只读成员亦可查看报表)。
* 返回的站点数组附带 role 字段;$min 用于需要更高权限的调用点。
*/
private function own(Request $req, int $id, string $min = SiteAccess::VIEWER): array
{
return SiteAccess::site($req, $id, $min);
}
private function siteShape(array $site): array
{
return [
'id' => (int) $site['id'],
'name' => $site['name'],
'domain' => $site['domain'],
'site_key' => $site['site_key'],
'timezone' => $site['timezone'],
'verified' => (int) $site['verified_at'] > 0,
'status' => (int) $site['status'],
];
}
/** 空日行(缺失天补齐用) */
private static function emptyDaily(string $d): array
{
return ['day' => $d, 'pv' => 0, 'uv' => 0, 'ipc' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];
}
/** 校验/默认日期区间 */
private function range(Request $req, string $tz): array
{
$offset = Util::tzOffsetSec($tz);
$today = gmdate('Y-m-d', time() + $offset);
$start = (string) $req->input('start', gmdate('Y-m-d', time() + $offset - 29 * 86400));
$end = (string) $req->input('end', $today);
// 容错归一:仅接受 YYYY-MM-DD;对 ISO 串 / Date.toString() 等可解析格式自动转换
$start = $this->ymd($start);
$end = $this->ymd($end);
if ($start === '' || $end === '') {
wstat_err('日期格式应为 YYYY-MM-DD', 422);
}
if ($start > $end) {
[$start, $end] = [$end, $start];
}
$d1 = strtotime($start);
$d2 = strtotime($end);
if ($d2 - $d1 > (self::MAX_RANGE_DAYS - 1) * 86400) {
wstat_err('查询区间最长 366 天', 422);
}
return [$start, $end];
}
/** LIKE 转义(防通配符注入) */
private function likeEscape(string $s): string
{
return addcslashes($s, '%_\\');
}
/**
* 日期参数归一为 YYYY-MM-DD:
* - 已是标准格式直接返回;
* - 兼容 ISO8601(2026-09-02T00:00:00.000Z)与 JS Date.toString()(Wed Sep 02 2026 00:00:00 GMT+0800);
* - 无法解析返回 ''(由调用方回 422)。
*/
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); // ISO 串按字面日期取,避免时区偏移
}
// JS Date.toString() 形如 "Wed Sep 02 2026 00:00:00 GMT+0800 (GMT+08:00)":
// 尾部括号说明会让 strtotime 解析失败,先剥离再解析
$plain = (string) preg_replace('/\s*\([^)]*\)\s*$/', '', $v);
$ts = strtotime($plain);
if ($ts === false) {
$ts = strtotime($v);
}
return $ts === false ? '' : date('Y-m-d', $ts);
}
/* ================= 用户旅程 ================= */
private const JOURNEY_MAX_ROWS = 200000; // 单次聚合的 pageview 上限(防内存失控)
private const JOURNEY_PATH_STEP = 6; // 路径排名最多展开步数
private const JOURNEY_FLOW_STEP = 4; // 桑基图步数
private const JOURNEY_FLOW_TOP = 8; // 桑基图每步展示节点数
/**
* GET /api/stats/journey?site_id=&start=&end=
* 用户旅程:路径排名(访问次数)、跳转 TOP(from→to PV)、桑基流(入口→…→出口)。
* 数据源:events pageview 按 (session_id, ts) 时序聚合(idx_site_day_type + (session_id,ts) 索引)。
*/
public function journey(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
$sid = (int) $site['id'];
$rows = Db::select(
"SELECT session_id, url FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?
ORDER BY session_id, ts, id LIMIT " . self::JOURNEY_MAX_ROWS,
[$sid, $start, $end]
);
$paths = []; // 路径串 => 访问次数
$trans = []; // "from\x1fto" => PV
$stepNodes = []; // [step][url] => PV
$stepEdges = []; // [step]["from\x1fto"] => PV
$sessPv = []; // session_id => pv(算桑基节点占比)
$curSid = null;
$seq = [];
$flush = function () use (&$seq, &$curSid, &$paths, &$trans, &$stepNodes, &$stepEdges, &$sessPv) {
if ($curSid === null) {
return;
}
$sessPv[$curSid] = count($seq);
// 连续重复页折叠(刷新不重复计)
$fold = [];
foreach ($seq as $u) {
$n = count($fold);
if ($n === 0 || $fold[$n - 1] !== $u) {
$fold[] = $u;
}
}
$n = count($fold);
if ($n === 0) {
return;
}
// 路径排名(超长截断标记 …)
$cut = array_slice($fold, 0, self::JOURNEY_PATH_STEP);
$key = implode(' → ', $cut) . ($n > self::JOURNEY_PATH_STEP ? ' → …' : '');
$paths[$key] = ($paths[$key] ?? 0) + 1;
// 跳转对 + 桑基节点/边
for ($i = 0; $i < $n; $i++) {
$stepNodes[$i][$fold[$i]] = ($stepNodes[$i][$fold[$i]] ?? 0) + 1;
if ($i + 1 < $n) {
$pair = $fold[$i] . "\x1f" . $fold[$i + 1];
$trans[$pair] = ($trans[$pair] ?? 0) + 1;
if ($i < self::JOURNEY_FLOW_STEP - 1) {
$stepEdges[$i][$pair] = ($stepEdges[$i][$pair] ?? 0) + 1;
}
}
}
$seq = [];
$curSid = null;
};
foreach ($rows as $r) {
$s = (string) $r['session_id'];
if ($s !== $curSid) {
$flush();
$curSid = $s;
}
$seq[] = (string) $r['url'];
}
$flush();
arsort($paths);
$totalVisits = count($sessPv);
$pathItems = [];
$i = 0;
foreach ($paths as $k => $c) {
if ($i++ >= 30) {
break;
}
$pathItems[] = ['path' => $k, 'visits' => $c, 'pct' => $totalVisits > 0 ? round($c / $totalVisits * 100, 2) : 0.0];
}
arsort($trans);
$transItems = [];
$i = 0;
foreach ($trans as $k => $c) {
if ($i++ >= 30) {
break;
}
[$a, $b] = explode("\x1f", $k);
$transItems[] = ['from' => $a, 'to' => $b, 'pv' => $c];
}
// ---- 桑基流:每步取 TOP N 节点,其余归并为「其他」;同节点边丢弃 ----
$flowNodes = []; // 最终节点名集合
$nodeTop = []; // [step] => [url => true](TOP 集合)
foreach ($stepNodes as $step => $urls) {
arsort($urls);
$top = array_slice(array_keys($urls), 0, self::JOURNEY_FLOW_TOP, true);
foreach ($top as $u) {
$nodeTop[$step][$u] = true;
}
}
$mapNode = static function (int $step, string $url) use ($nodeTop): string {
// 节点名带层级前缀:ECharts sankey 按名字合并节点,同一 URL 跨层复用
// 会形成 A→B→A 环(sankey 要求 DAG,有环直接抛错)。加前缀后边
// 严格从 step i 指向 i+1,天然无环;前端展示时剥掉前缀。
return $step . '|' . (isset($nodeTop[$step][$url]) ? $url : '其他');
};
$flowLinks = [];
foreach ($stepEdges as $step => $pairs) {
$agg = [];
foreach ($pairs as $pair => $c) {
[$a, $b] = explode("\x1f", $pair);
$sa = $mapNode($step, $a);
$sb = $mapNode($step + 1, $b);
if ($sa === $sb
|| (str_ends_with($sa, '|其他') && str_ends_with($sb, '|其他'))) {
continue; // 自环与「其他→其他」长尾边不画
}
$k = $sa . "\x1f" . $sb;
$agg[$k] = ($agg[$k] ?? 0) + $c;
}
arsort($agg);
$j = 0;
foreach ($agg as $k => $c) {
if ($j++ >= 60) {
break;
}
[$a, $b] = explode("\x1f", $k);
$flowLinks[] = ['source' => $a, 'target' => $b, 'value' => $c];
$flowNodes[$a] = true;
$flowNodes[$b] = true;
}
}
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'totals' => ['sessions' => $totalVisits, 'pv' => array_sum($sessPv), 'capped' => count($rows) >= self::JOURNEY_MAX_ROWS],
'paths' => $pathItems,
'transitions' => $transItems,
'flow' => ['nodes' => array_keys($flowNodes), 'links' => $flowLinks],
]);
}
/* ================= 转化漏斗 ================= */
private const FUNNEL_MAX_STEPS = 8; // 单条漏斗最多步数
/** 校验并归一漏斗入参:[name, steps[], match_type] */
private function funnelInput(Request $req): array
{
$name = trim((string) $req->input('name', ''));
if ($name === '' || mb_strlen($name) > 100) {
wstat_err('漏斗名称必填且不超过 100 字', 422);
}
$steps = $req->input('steps');
if (!is_array($steps)) {
wstat_err('步骤格式错误', 422);
}
$steps = array_values(array_filter(
array_map(static fn ($s) => trim((string) $s), $steps),
static fn ($s) => $s !== ''
));
if (count($steps) < 2) {
wstat_err('漏斗至少需要 2 个步骤', 422);
}
if (count($steps) > self::FUNNEL_MAX_STEPS) {
wstat_err('漏斗最多 ' . self::FUNNEL_MAX_STEPS . ' 步', 422);
}
foreach ($steps as $s) {
if (strlen($s) > 255) {
wstat_err('步骤 URL 过长(≤255 字符)', 422);
}
}
$match = (string) $req->input('match_type', 'prefix');
if (!in_array($match, ['prefix', 'contains', 'exact'], true)) {
$match = 'prefix';
}
return [$name, $steps, $match];
}
/**
* 取当前用户可访问站点下的漏斗;默认要求可编辑(写入场景)。
* 无权访问 → 404(不泄露漏斗是否存在);权限不足 → 403。
*/
private function funnelOwn(Request $req, int $id, string $min = SiteAccess::EDITOR): array
{
$f = Db::first('SELECT id,site_id,name,steps,match_type,is_active,created_at FROM funnels WHERE id=? LIMIT 1', [$id]);
if ($f === null) {
wstat_err('漏斗不存在', 404, 404);
}
SiteAccess::require($req, (int) $f['site_id'], $min);
return $f;
}
/** events.url 的路径表达式:完整 URL 取 path 部分,纯路径原样返回 */
private const URL_PATH_EXPR = "IF(LOCATE('//', url) > 0, IF(LOCATE('/', url, LOCATE('//', url) + 2) > 0, SUBSTRING(url, LOCATE('/', url, LOCATE('//', url) + 2)), '/'), url)";
/** 步骤匹配片段(返回 SQL 片段与绑定参数;一律按路径匹配) */
private function funnelLike(string $step, string $match): array
{
$s = $this->likeEscape($step);
if ($s !== '' && $s[0] !== '/') {
$s = '/' . $s; // 归一为路径形态
}
$expr = self::URL_PATH_EXPR;
if ($match === 'exact') {
return [$expr . ' = ?', $s];
}
$pat = $match === 'contains' ? '%' . $s . '%' : $s . '%';
return [$expr . ' LIKE ?', $pat];
}
/** GET /api/stats/funnels?site_id= 漏斗列表 */
public function funnels(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
$rows = Db::select(
'SELECT id,name,steps,match_type,is_active,created_at FROM funnels
WHERE site_id=? ORDER BY id DESC',
[(int) $site['id']]
);
foreach ($rows as &$r) {
$r['id'] = (int) $r['id'];
$r['steps'] = json_decode((string) $r['steps'], true) ?: [];
$r['is_active'] = (int) $r['is_active'];
$r['created_at'] = (int) $r['created_at'];
}
unset($r);
wstat_json(['items' => $rows]);
}
/** POST /api/stats/funnels 新建漏斗(name, steps[], match_type) */
public function funnelSave(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0), SiteAccess::EDITOR);
[$name, $steps, $match] = $this->funnelInput($req);
$id = Db::insert('funnels', [
'site_id' => (int) $site['id'],
'name' => $name,
'steps' => json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'match_type' => $match,
'is_active' => 1,
'created_at' => time(),
'updated_at' => time(),
]);
wstat_json(['id' => $id]);
}
/** PATCH /api/stats/funnels/{id} 更新漏斗 */
public function funnelUpdate(Request $req): void
{
$f = $this->funnelOwn($req, (int) $req->param('id'));
[$name, $steps, $match] = $this->funnelInput($req);
Db::execute(
'UPDATE funnels SET name=?, steps=?, match_type=?, updated_at=? WHERE id=?',
[
$name,
json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$match,
time(),
(int) $f['id'],
]
);
wstat_json(['ok' => true]);
}
/** DELETE /api/stats/funnels/{id} 删除漏斗——需 editor 及以上 */
public function funnelDelete(Request $req): void
{
$f = $this->funnelOwn($req, (int) $req->param('id'));
SiteAccess::require($req, (int) $f['site_id'], SiteAccess::EDITOR);
Db::execute('DELETE FROM funnels WHERE id=?', [(int) $f['id']]);
wstat_json(['ok' => true]);
}
/**
* GET /api/stats/funnels/{id}/data?start=&end=&ordered=1
* 转化统计:逐级累计「命中前 N 步」的去重会话数(events pageview,session_id 去重)。
* ordered=1(默认)要求步骤按时间先后依次发生;ordered=0 为旧口径(顺序无关)
* —— 只要区间内命中过全部步骤即计入,转化率会偏高,仅作对照。
*/
public function funnelData(Request $req): void
{
// 读接口:只读成员即可查看漏斗转化数据
$f = $this->funnelOwn($req, (int) $req->param('id'), SiteAccess::VIEWER);
$site = $this->own($req, (int) $f['site_id']);
[$start, $end] = $this->range($req, (string) $site['timezone']);
$sid = (int) $f['site_id'];
$steps = json_decode((string) $f['steps'], true) ?: [];
$match = (string) $f['match_type'];
$ordered = (string) $req->input('ordered', '1') !== '0';
$orConds = []; // 命中任意一步(缩小扫描范围)
$orArgs = [];
$mins = []; // 有序模式:每个步骤的首次命中时间/事件ID
$minsArgs = [];
$idMins = [];
$idArgs = [];
foreach ($steps as $i => $step) {
[$cond, $pat] = $this->funnelLike((string) $step, $match);
$orConds[] = $cond;
$orArgs[] = $pat;
$mins[] = 'MIN(CASE WHEN ' . $cond . ' THEN `ts` END) AS t' . ($i + 1);
$minsArgs[] = $pat;
$idMins[] = 'MIN(CASE WHEN ' . $cond . ' THEN `id` END) AS e' . ($i + 1);
$idArgs[] = $pat;
}
$where = 'site_id=? AND type=\'pageview\' AND `day` BETWEEN ? AND ?'
. ' AND session_id<>\'\' AND (' . implode(' OR ', $orConds) . ')';
$baseArgs = [$sid, $start, $end, ...$orArgs];
$n = count($steps);
$out = [];
for ($k = 1; $k <= $n; $k++) {
if ($ordered) {
// 有序:步骤 k 要求 t1 ≤ t2 ≤ … ≤ tk(同秒用事件 id 兜底比较),
// 即「先到过第 1 步、再到第 2 步 …」的真实转化路径。
$h = ['t1 IS NOT NULL'];
for ($j = 2; $j <= $k; $j++) {
$p = $j - 1;
$h[] = "(t{$j} > t{$p} OR (t{$j} = t{$p} AND e{$j} > e{$p}))";
}
$sql = 'SELECT COUNT(*) FROM (
SELECT session_id, ' . implode(', ', $mins) . ', ' . implode(', ', $idMins) . '
FROM events
WHERE ' . $where . '
GROUP BY session_id
HAVING ' . implode(' AND ', $h) . '
) t';
// 绑定顺序必须与 SQL 文本一致:SELECT 列表里的 CASE 占位符在前,WHERE 的在后
$args = [...$minsArgs, ...$idArgs, ...$baseArgs];
} else {
// 无序:只要求区间内命中过前 k 步(旧行为,顺序无关)。
$h = [];
for ($j = 1; $j <= $k; $j++) {
$h[] = 'SUM(' . $orConds[$j - 1] . ')>0';
}
$sql = 'SELECT COUNT(*) FROM (
SELECT session_id FROM events
WHERE ' . $where . '
GROUP BY session_id
HAVING ' . implode(' AND ', $h) . '
) t';
// WHERE 用全部 n 个条件,HAVING 复用前 k 个 → 占位符再按前 k 步传一份
$args = [...$baseArgs, ...array_slice($orArgs, 0, $k)];
}
$c = (int) Db::value($sql, $args);
$out[] = [
'step' => $k,
'url' => (string) $steps[$k - 1],
'visitors' => $c,
];
}
$base = $out[0]['visitors'] ?? 0;
$prev = 0;
foreach ($out as &$s) {
$s['rate'] = $base > 0 ? round($s['visitors'] / $base * 100, 1) : 0.0;
$s['dropRate'] = $prev > 0 ? round(max(0, $prev - $s['visitors']) / $prev * 100, 1) : 0.0;
$prev = $s['visitors'];
}
unset($s);
wstat_json([
'funnel' => [
'id' => (int) $f['id'],
'name' => $f['name'],
'steps' => $steps,
'match_type' => $match,
'ordered' => $ordered,
],
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'steps' => $out,
'overall' => ['rate' => $base > 0 ? round(($out[count($out) - 1]['visitors'] ?? 0) / $base * 100, 1) : 0.0],
]);
}
/* ================= 转化目标 Goal ================= */
/** GET /api/stats/goals?site_id= 目标列表 */
public function goals(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
$rows = Db::select(
'SELECT id,name,type,target,match_type,is_active,created_at FROM goals
WHERE site_id=? ORDER BY id DESC',
[(int) $site['id']]
);
foreach ($rows as &$r) {
$r['id'] = (int) $r['id'];
$r['is_active'] = (int) $r['is_active'];
$r['created_at'] = (int) $r['created_at'];
}
unset($r);
wstat_json([
'items' => $rows,
'types' => ['page', 'event'],
'matches' => ['prefix', 'contains', 'exact'],
]);
}
/** POST /api/stats/goals 新建目标(site_id, name, type, target, match_type) */
public function goalSave(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0), SiteAccess::EDITOR);
[$name, $type, $target, $match] = $this->goalInput($req);
$id = Db::insert('goals', [
'site_id' => (int) $site['id'],
'name' => $name,
'type' => $type,
'target' => $target,
'match_type' => $match,
'is_active' => 1,
'created_at' => time(),
]);
wstat_json(['id' => $id]);
}
/** PATCH /api/stats/goals/{id} 更新目标 */
public function goalUpdate(Request $req): void
{
$g = $this->goalOwn($req, (int) $req->param('id'));
[$name, $type, $target, $match] = $this->goalInput($req);
Db::execute(
'UPDATE goals SET name=?, type=?, target=?, match_type=? WHERE id=?',
[$name, $type, $target, $match, (int) $g['id']]
);
wstat_json(['ok' => true]);
}
/** DELETE /api/stats/goals/{id} 删除目标 */
public function goalDelete(Request $req): void
{
$g = $this->goalOwn($req, (int) $req->param('id'));
Db::execute('DELETE FROM goals WHERE id=?', [(int) $g['id']]);
wstat_json(['ok' => true]);
}
/**
* GET /api/stats/goals/{id}/data?start=&end=
* 转化统计:分母 = 区间内会话总数(sessions);分子 = 命中目标的去重会话数。
* page 目标按 URL 路径匹配(prefix/contains/exact);event 目标按 SDK 上报事件名(payload.n)精确匹配。
*/
public function goalData(Request $req): void
{
$g = $this->goalOwn($req, (int) $req->param('id'), SiteAccess::VIEWER);
$site = $this->own($req, (int) $g['site_id']);
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $g['site_id'];
$visits = (int) Db::value(
'SELECT COUNT(*) FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<?',
[$sid, $startTs, $endTs]
);
[$cond, $bind] = $this->goalCond((string) $g['type'], (string) $g['target'], (string) $g['match_type']);
$row = Db::first(
"SELECT COUNT(DISTINCT session_id) conv, COUNT(DISTINCT visitor_id) users
FROM events
WHERE site_id=? AND `day` BETWEEN ? AND ? AND session_id<>'' AND ($cond)",
[$sid, $start, $end, $bind]
) ?: ['conv' => 0, 'users' => 0];
// 按日转化趋势(命中会话数)
$trend = [];
foreach (
Db::select(
"SELECT `day`, COUNT(DISTINCT session_id) c FROM events
WHERE site_id=? AND `day` BETWEEN ? AND ? AND session_id<>'' AND ($cond)
GROUP BY `day` ORDER BY `day`",
[$sid, $start, $end, $bind]
) as $r
) {
$trend[] = ['day' => (string) $r['day'], 'conversions' => (int) $r['c']];
}
$conv = (int) $row['conv'];
wstat_json([
'goal' => [
'id' => (int) $g['id'],
'name' => $g['name'],
'type' => $g['type'],
'target' => $g['target'],
'match_type' => $g['match_type'],
],
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'visits' => $visits,
'conversions' => $conv,
'converted_visitors' => (int) $row['users'],
'rate' => $visits > 0 ? round($conv / $visits * 100, 2) : 0.0,
'trend' => $trend,
]);
}
private function goalOwn(Request $req, int $id, string $min = SiteAccess::EDITOR): array
{
$g = Db::first(
'SELECT id,site_id,name,type,target,match_type,is_active,created_at FROM goals WHERE id=? LIMIT 1',
[$id]
);
if ($g === null) {
wstat_err('目标不存在', 404, 404);
}
SiteAccess::require($req, (int) $g['site_id'], $min);
return $g;
}
/** 目标入参校验,返回 [name, type, target, match_type] */
private function goalInput(Request $req): array
{
$name = trim((string) $req->input('name', ''));
if ($name === '' || mb_strlen($name) > 100) {
wstat_err('目标名称必填且不超过 100 字', 422);
}
$type = (string) $req->input('type', 'page');
if (!in_array($type, ['page', 'event'], true)) {
wstat_err('目标类型应为 page 或 event', 422);
}
$target = trim((string) $req->input('target', ''));
if ($target === '' || strlen($target) > 255) {
wstat_err($type === 'page' ? '目标路径必填且不超过 255 字符' : '事件名必填且不超过 255 字符', 422);
}
$match = (string) $req->input('match_type', 'contains');
if (!in_array($match, ['prefix', 'contains', 'exact'], true)) {
$match = 'contains';
}
if ($type === 'event') {
$match = 'exact'; // 事件名一律精确匹配
}
return [$name, $type, $target, $match];
}
/**
* 目标命中 SQL 片段 + 绑定值。
* page 目标按 URL_PATH_EXPR(路径)匹配;event 目标按 JSON_EXTRACT(payload,'$.n') 精确匹配。
*/
private function goalCond(string $type, string $target, string $match): array
{
if ($type === 'event') {
return ["type='event' AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.n')) = ?", $target];
}
$expr = self::URL_PATH_EXPR;
$s = $this->likeEscape($target);
if ($s !== '' && $s[0] !== '/') {
$s = '/' . $s; // 归一为路径形态(与漏斗口径一致)
}
if ($match === 'exact') {
return ["type='pageview' AND $expr = ?", $s];
}
$pat = $match === 'contains' ? '%' . $s . '%' : $s . '%';
return ["type='pageview' AND $expr LIKE ?", $pat];
}
/* ================= 广告追踪专项 ================= */
/** 付费流量口径(events 与 sessions 通用列:click_id / source / medium) */
private const ADS_PAID_W = "(click_id<>'' OR source IN ('paid','utm') OR medium IN ('cpc','ppc','paid'))";
/** 归因扩展列预检:缺失则提示执行迁移(避免 SQLSTATE 500) */
private function requireSourceColumns(): void
{
$missing = [];
$cols = Db::tableColumns('events');
foreach (['utm_source', 'utm_medium', 'click_source', 'ref_host'] as $need) {
if (!isset($cols[$need])) {
$missing[] = $need;
}
}
if ($missing) {
$alter = "ALTER TABLE `events`\n ADD COLUMN `utm_source` VARCHAR(128) NOT NULL DEFAULT '' AFTER `medium`,\n ADD COLUMN `utm_medium` VARCHAR(128) NOT NULL DEFAULT '' AFTER `utm_source`,\n ADD COLUMN `click_source` VARCHAR(64) NOT NULL DEFAULT '' AFTER `click_id`,\n ADD COLUMN `ref_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `click_source`;";
wstat_err(
'events 表缺少来源分析列:' . implode(', ', $missing) . "。请在数据库执行升级:\n\n" . $alter
. "\n\n或直接运行:mysql -uroot -p <库名> < sql/upgrade-2026-09-09-sources.sql(路径以部署目录为准)。",
200, 1
);
}
}
/**
* GET /api/stats/ads?site_id=&start=&end=
* 广告追踪专项:付费口径汇总、付费/全站趋势、多维度归因明细、付费落地页质量。
*/
public function ads(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
[$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);
$sid = (int) $site['id'];
$tz = (string) $site['timezone'];
$this->requireSourceColumns();
$wAll = 'site_id=? AND type="pageview" AND `day` BETWEEN ? AND ?';
$wPaid = $wAll . ' AND ' . self::ADS_PAID_W;
$args = [$sid, $start, $end];
$wSessPaid = 'site_id=? AND start_ts>=? AND start_ts<? AND ' . self::ADS_PAID_W;
$sessArgs = [$sid, $startTs, $endTs];
// ---- 汇总 ----
$paidPv = (int) Db::value("SELECT COUNT(*) FROM events WHERE $wPaid", $args);
$totalPv = (int) Db::value("SELECT COUNT(*) FROM events WHERE $wAll", $args);
$q = Db::first(
"SELECT COUNT(*) v, COALESCE(SUM(bounce),0) b, COALESCE(ROUND(AVG(duration)),0) d, COALESCE(SUM(is_new),0) n
FROM sessions WHERE $wSessPaid", $sessArgs
) ?: ['v' => 0, 'b' => 0, 'd' => 0, 'n' => 0];
$qAll = Db::first(
'SELECT COUNT(*) v FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<?',
$sessArgs
) ?: ['v' => 0];
$paidVisits = (int) $q['v'];
// ---- 趋势:付费 / 全站 PV 按天 ----
$days = [];
foreach (Util::dailySeries($startTs, $endTs, $tz, fn () => null) as $d => $_) {
$days[] = (string) $d;
}
$idx = array_flip($days);
$trPaid = array_fill(0, count($days), 0);
$trTotal = array_fill(0, count($days), 0);
foreach (
Db::select("SELECT `day` d, COUNT(*) pv FROM events WHERE $wPaid GROUP BY `day` ORDER BY d", $args) as $r
) {
$i = $idx[(string) $r['d']] ?? -1;
if ($i >= 0) {
$trPaid[$i] = (int) $r['pv'];
}
}
foreach (
Db::select("SELECT `day` d, COUNT(*) pv FROM events WHERE $wAll GROUP BY `day` ORDER BY d", $args) as $r
) {
$i = $idx[(string) $r['d']] ?? -1;
if ($i >= 0) {
$trTotal[$i] = (int) $r['pv'];
}
}
// ---- 维度明细(events:PV + 去重访问) ----
$byCampaign = Db::select(
"SELECT COALESCE(NULLIF(campaign,''),'(未命名)') k, COUNT(*) pv, COUNT(DISTINCT session_id) visits
FROM events WHERE $wPaid GROUP BY k ORDER BY pv DESC LIMIT 20", $args
);
$byUtm = Db::select(
"SELECT CONCAT(COALESCE(NULLIF(utm_source,''),'—'),' / ',COALESCE(NULLIF(utm_medium,''),'—')) k,
COUNT(*) pv, COUNT(DISTINCT session_id) visits
FROM events WHERE $wPaid AND (utm_source<>'' OR utm_medium<>'') GROUP BY k ORDER BY pv DESC LIMIT 20", $args
);
$byPlatform = Db::select(
"SELECT COALESCE(NULLIF(click_source,''),'(未知平台)') k, COUNT(*) pv, COUNT(DISTINCT session_id) visits
FROM events WHERE $wPaid AND click_id<>'' GROUP BY k ORDER BY pv DESC LIMIT 20", $args
);
$byTerm = Db::select(
"SELECT COALESCE(NULLIF(term,''),'(无关键词)') k, COUNT(*) pv, COUNT(DISTINCT session_id) visits
FROM events WHERE $wPaid AND term<>'' GROUP BY k ORDER BY pv DESC LIMIT 20", $args
);
// ---- 付费落地页(sessions:访问/跳出/时长) ----
$landings = Db::select(
"SELECT entry_url k, COUNT(*) visits,
COALESCE(SUM(bounce),0) b, COALESCE(ROUND(AVG(duration)),0) d
FROM sessions WHERE $wSessPaid
GROUP BY entry_url ORDER BY visits DESC LIMIT 20", $sessArgs
);
foreach ($landings as &$l) {
$v = (int) $l['visits'];
$l['visits'] = $v;
$l['bounce_rate'] = $v > 0 ? round((int) $l['b'] / $v * 100, 1) : 0.0;
$l['duration'] = (int) $l['d'];
unset($l['b'], $l['d']);
}
unset($l);
wstat_json([
'site' => $this->siteShape($site),
'range' => ['start' => $start, 'end' => $end],
'totals' => [
'paid_pv' => $paidPv,
'total_pv' => $totalPv,
'paid_visits' => $paidVisits,
'total_visits' => (int) $qAll['v'],
'paid_new' => (int) $q['n'],
'bounce_rate' => $paidVisits > 0 ? round((int) $q['b'] / $paidVisits * 100, 1) : 0.0,
'avg_duration' => (int) $q['d'],
],
'trend' => ['days' => $days, 'paid' => $trPaid, 'total' => $trTotal],
'campaigns' => $byCampaign,
'utms' => $byUtm,
'platforms' => $byPlatform,
'terms' => $byTerm,
'landings' => $landings,
]);
}
/* ================= 点击热力图 ================= */
private const HEAT_MAX_ROWS = 100000; // 单页点击聚合扫描上限
/**
* GET /api/stats/heatmap
* ?site_id&start&end → 页面列表(点击量排名)
* ?site_id&start&end&page=/path&device=desktop|mobile|tablet → 该页坐标点
*/
public function heatmap(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
[$start, $end] = $this->range($req, (string) $site['timezone']);
$sid = (int) $site['id'];
$path = trim((string) $req->input('page', ''));
$device = (string) $req->input('device', '');
if ($path === '') {
// ---- 页面列表:click 事件按路径聚合 ----
$expr = self::URL_PATH_EXPR;
$rows = Db::select(
"SELECT {$expr} AS path, COUNT(*) AS clicks, COUNT(DISTINCT session_id) AS visits
FROM events
WHERE site_id=? AND type='click' AND `day` BETWEEN ? AND ?
GROUP BY path ORDER BY clicks DESC LIMIT 50",
[$sid, $start, $end]
);
wstat_json(['pages' => $rows]);
return;
}
// ---- 单页坐标聚合 ----
$cond = "site_id=? AND type='click' AND `day` BETWEEN ? AND ? AND {$this->pathCond()}";
$bind = [$sid, $start, $end, $path, $path];
if (in_array($device, ['desktop', 'mobile', 'tablet'], true)) {
$cond .= ' AND device=?';
$bind[] = $device;
}
$rows = Db::select(
"SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.x')) AS DECIMAL(8,2)) AS x,
CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.y')) AS DECIMAL(8,2)) AS y,
CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.dh')) AS DECIMAL(10,0)) AS dh,
JSON_UNQUOTE(JSON_EXTRACT(payload, '$.tag')) AS tag,
JSON_UNQUOTE(JSON_EXTRACT(payload, '$.txt')) AS txt
FROM events
WHERE {$cond}
ORDER BY id DESC LIMIT " . self::HEAT_MAX_ROWS,
$bind
);
// 1% x 1% 分桶(x: 视口宽百分比;y: 文档高百分比)
$cells = [];
$dhSum = 0;
$dhN = 0;
$tags = [];
foreach ($rows as $r) {
$x = (float) $r['x'];
$y = (float) $r['y'];
if ($x < 0 || $x > 100 || $y < 0 || $y > 100) {
continue; // 异常坐标丢弃
}
$gx = (int) floor($x);
$gy = (int) floor($y);
$k = $gx . '_' . $gy;
if (!isset($cells[$k])) {
$cells[$k] = ['x' => $gx, 'y' => $gy, 'c' => 0];
}
$cells[$k]['c']++;
if ((float) $r['dh'] > 0) {
$dhSum += (float) $r['dh'];
$dhN++;
}
$tag = trim((string) $r['tag']);
if ($tag !== '') {
$tags[$tag] = ($tags[$tag] ?? 0) + 1;
}
}
arsort($tags);
wstat_json([
'page' => $path,
'clicks' => count($rows),
'avg_dh' => $dhN > 0 ? (int) round($dhSum / $dhN) : 0,
'max_cell' => $cells ? max(array_column($cells, 'c')) : 0,
'cells' => array_values($cells),
'tags' => array_slice(array_map(null, array_keys($tags), array_values($tags)), 0, 8),
]);
}
/** 路径精确匹配条件(events.url 可能存完整 URL 或纯路径,两个 ? 共用同一 $path) */
private function pathCond(): string
{
$expr = self::URL_PATH_EXPR;
return "({$expr} = ? OR url = ?)";
}
/* ================= 数据大屏(只读 token,免登录) ================= */
/** 大屏签名密钥:环境变量优先,缺省由 DB 密码派生(部署后轮换 DB 密码即失效) */
private function screenSecret(): string
{
$s = getenv('WSTAT_SCREEN_SECRET');
if ($s && $s !== '') {
return $s;
}
$cfg = wstat_config();
return 'wstat-screen:' . $cfg['db']['pass'];
}
/** GET /api/screen/token?site_id= (需登录)签发大屏只读 token */
public function screenToken(Request $req): void
{
$site = $this->own($req, (int) $req->input('site_id', 0));
$token = hash_hmac('sha256', 'screen:' . $site['id'], $this->screenSecret());
wstat_json(['site' => $this->siteShape($site), 'token' => $token]);
}
/**
* 解析大屏时间范围:range=today|yesterday|7d|30d|custom(兼容旧参 days=N,1..90)。
* 时间基准是站点时区的「今日」,返回 [start(Y-m-d), end(Y-m-d), days](闭区间)。
*/
private function screenRange(Request $req, string $today): array
{
$back = fn (int $n) => date('Y-m-d', strtotime($today) - $n * 86400);
$range = strtolower(trim((string) $req->input('range', '')));
if ($range === 'yesterday') {
return [$back(1), $back(1), 1];
}
if ($range === '7d') {
return [$back(6), $today, 7];
}
if ($range === '30d') {
return [$back(29), $today, 30];
}
if ($range === 'today') {
return [$today, $today, 1];
}
if ($range === 'custom') {
$s = (string) $req->input('start', '');
$e = (string) $req->input('end', $s);
$re = '/^\d{4}-\d{2}-\d{2}$/';
if (!preg_match($re, $s) || !preg_match($re, $e)) {
return [$today, $today, 1];
}
if ($s > $e) {
[$s, $e] = [$e, $s];
}
if ($e > $today) {
$e = $today;
}
if ($s > $e) {
$s = $e;
}
$n = (int) round((strtotime($e) - strtotime($s)) / 86400) + 1;
if ($n > 90) { // 上限 90 天,避免超长区间拖垮查询
$s = date('Y-m-d', strtotime($e) - 89 * 86400);
$n = 90;
}
return [$s, $e, $n];
}
// 未指定 range:兼容旧参 days=N(缺省今日)
$n = max(1, min(90, (int) $req->input('days', 1)));
return [$back($n - 1), $today, $n];
}
/**
* GET /api/screen/data?site_id=&token=&range=today|yesterday|7d|30d|custom&start=&end=
* 大屏聚合数据(HMAC token 校验,只读,无用户态)。
*/
public function screenData(Request $req): void
{
$sid = (int) $req->input('site_id', 0);
$site = Db::first('SELECT id,name,domain,site_key,timezone,verified_at FROM sites WHERE id=? LIMIT 1', [$sid]);
if ($site === null) {
wstat_err('站点不存在', 404, 404);
}
$token = (string) $req->input('token', '');
$expect = hash_hmac('sha256', 'screen:' . $sid, $this->screenSecret());
if ($token === '' || !hash_equals($expect, $token)) {
wstat_err('大屏 token 无效', 403, 403);
}
// 访问密码(系统设置配置;空=不启用)。code=4031 供前端识别弹出密码门。
$needPwd = (string) Settings::get('screen_password');
if ($needPwd !== '') {
$got = (string) $req->input('password', '');
if ($got === '') {
wstat_err('需要访问密码', 403, 4031);
}
if (!hash_equals($needPwd, $got)) {
wstat_err('访问密码错误', 403, 4032);
}
}
$tz = (string) $site['timezone'];
$offset = Util::tzOffsetSec($tz);
$today = gmdate('Y-m-d', time() + $offset);
// 时间筛选:默认今日;range=custom 时用 start/end。
[$start, $end, $days] = $this->screenRange($req, $today);
$startTs = strtotime($start);
$endTs = strtotime($end) + 86400; // 区间右开界
$liveRange = ($end === $today); // 区间覆盖今日时需要实况修正
// 环比对照窗:紧邻的等长前一段(用于指标卡的 环比 百分比)
$prevStartTs = $startTs - $days * 86400;
$prevStart = date('Y-m-d', $prevStartTs);
$prevEndTs = $startTs;
// ---- 区间总量:site_daily 快路径 + 明细兜底(与 overview 同口径的精简版) ----
$rows = Db::select(
'SELECT `day`,pv,uv,ipc,visits,bounce,duration,new_users
FROM site_daily WHERE site_id=? AND `day` BETWEEN ? AND ? ORDER BY `day`',
[$sid, $prevStart, $end]
);
$have = [];
foreach ($rows as $r) {
$have[(string) $r['day']] = $r;
}
$days_ = [];
for ($t = $prevStartTs; $t <= strtotime($end); $t += 86400) {
$days_[] = date('Y-m-d', $t);
}
$missing = array_values(array_filter($days_, fn ($d) => !isset($have[$d])));
// 今日必须是实况:site_daily 的今日行只是某次 rollup / 概览预热的快照(可能停在数小时前),
// 直接沿用会让大屏的 PV / 访客数长时间卡住不动。故区间含今日时,用明细重算覆盖今日。
if ($liveRange && !in_array($today, $missing, true)) {
$missing[] = $today;
}
if (!empty($missing)) {
$ph = implode(',', array_fill(0, count($missing), '?'));
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` IN ($ph) GROUP BY `day`",
[$sid, ...$missing]
) as $r
) {
$have[(string) $r['day']] = array_merge(self::emptyDaily((string) $r['day']), [
'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'ipc' => (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) nu
FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
[$offset, $sid, $prevStartTs, $endTs]
) as $r
) {
$d = (string) $r['d'];
if (isset($have[$d])) {
$have[$d]['visits'] = (int) $r['c'];
$have[$d]['bounce'] = (int) $r['b'];
$have[$d]['duration'] = (int) $r['du'];
$have[$d]['new_users'] = (int) $r['nu'];
}
}
}
// 今日「进行中会话」(仍驻留 Redis、尚未回收落库)并入今日访问次数/跳出/时长/新访客,
// 与 PV 的实况口径保持一致;失败仅忽略,不影响大屏其余数据。
try {
$redisLive = Rds::get();
if ($liveRange && $redisLive !== null) {
[$liveTs0, $liveTs1] = Util::dateRangeToTs($today, $today, $tz);
$liveRows = Sessionizer::liveRows($redisLive, $sid, $liveTs0, $liveTs1);
if ($liveRows) {
$base = $have[$today] ?? self::emptyDaily($today);
$lb = 0;
$ld = 0;
$ln = 0;
foreach ($liveRows as $lv) {
$lb += (int) $lv['bounce'];
$ld += (int) $lv['duration'];
$ln += (int) ($lv['is_new'] ?? 0);
}
$base['visits'] = (int) ($base['visits'] ?? 0) + count($liveRows);
$base['bounce'] = (int) ($base['bounce'] ?? 0) + $lb;
$base['duration'] = (int) ($base['duration'] ?? 0) + $ld;
$base['new_users'] = (int) ($base['new_users'] ?? 0) + $ln;
$have[$today] = $base;
}
}
} catch (\Throwable $e) { /* 忽略实时修正失败 */ }
$sum = ['pv' => 0, 'uv' => 0, 'ipc' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];
$prevSum = ['pv' => 0, 'uv' => 0, 'ipc' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];
$trend = [];
foreach ($days_ as $d) {
$r = $have[$d] ?? self::emptyDaily($d);
if ($d >= $start) {
foreach (array_keys($sum) as $k) {
$sum[$k] += (int) $r[$k];
}
$trend[] = ['day' => $d, 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'visits' => (int) $r['visits']];
} else {
foreach (array_keys($prevSum) as $k) {
$prevSum[$k] += (int) $r[$k];
}
}
}
// 区间 UV / 独立 IP:跨天必须去重 —— 逐日相加会把同一访客 / IP 在多天里重复计数(旧问题)。
// 统一走 exactUniq(),与概览、IP 地域页同一口径;环比窗同理。
$uniq = $this->exactUniq($sid, $start, $end);
$sum['uv'] = $uniq['uv'];
$sum['ipc'] = $uniq['ipc'];
$prevUniq = $this->exactUniq($sid, $prevStart, date('Y-m-d', $startTs - 86400));
$prevSum['uv'] = $prevUniq['uv'];
$prevSum['ipc'] = $prevUniq['ipc'];
$bounceRate = $sum['visits'] > 0 ? round($sum['bounce'] / $sum['visits'] * 100, 1) : 0.0;
$avgDur = $sum['visits'] > 0 ? (int) round($sum['duration'] / $sum['visits']) : 0;
$prevBounceRate = $prevSum['visits'] > 0 ? round($prevSum['bounce'] / $prevSum['visits'] * 100, 1) : 0.0;
$prevAvgDur = $prevSum['visits'] > 0 ? (int) round($prevSum['duration'] / $prevSum['visits']) : 0;
// ---- 区间末日分时 PV(区间为单日时前端用它画折线) ----
$hourly = array_fill(0, 24, 0);
foreach (
Db::select(
"SELECT HOUR(FROM_UNIXTIME(ts+?)) h, COUNT(*) c FROM events
WHERE site_id=? AND type='pageview' AND `day`=? GROUP BY h",
[$offset, $sid, $end]
) as $r
) {
$hourly[(int) $r['h']] = (int) $r['c'];
}
// ---- 区间 TOP 聚合($col 均来自下方字面量调用,无注入面) ----
$agg = function (string $col, int $limit = 10, string $cond = '') use ($sid, $start, $end): array {
$out = [];
foreach (
Db::select(
"SELECT `$col` AS name, COUNT(*) pv FROM events
WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $cond
GROUP BY `$col` ORDER BY pv DESC LIMIT $limit",
[$sid, $start, $end]
) as $r
) {
$out[] = ['name' => (string) $r['name'], 'pv' => (int) $r['pv']];
}
return $out;
};
// 入口/退出页来自会话表(访问次数口径,与区间严格对齐)
$sessTop = function (string $col, int $limit = 10) use ($sid, $startTs, $endTs): array {
$out = [];
foreach (
Db::select(
"SELECT `$col` AS name, COUNT(*) pv FROM sessions
WHERE site_id=? AND start_ts>=? AND start_ts<? AND `$col`<>''
GROUP BY `$col` ORDER BY pv DESC LIMIT $limit",
[$sid, $startTs, $endTs]
) as $r
) {
$out[] = ['name' => (string) $r['name'], 'pv' => (int) $r['pv']];
}
return $out;
};
// TOP IP(PV + 独立访客)
$ips = [];
foreach (
Db::select(
"SELECT ip AS name, COUNT(*) pv, COUNT(DISTINCT visitor_id) uv FROM events
WHERE site_id=? AND type='pageview' AND ip<>'' AND `day` BETWEEN ? AND ?
GROUP BY ip ORDER BY pv DESC LIMIT 10",
[$sid, $start, $end]
) as $r
) {
$ips[] = ['name' => (string) $r['name'], 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv']];
}
// 受访页数(区间内去重 URL)
$pagesCount = (int) Db::value(
"SELECT COUNT(DISTINCT url) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",
[$sid, $start, $end]
);
// ---- 实时在线:Redis 模式走 5 分钟窗口 zset;无 Redis 模式走 sessions 活跃数 ----
if (Rds::enabled()) {
$online = Rds::safe(function ($r) use ($sid) {
return (int) $r->zcount('on:' . $sid, (string) (time() - 300), (string) (time() + 1));
}, 0);
} else {
$online = self::onlineCountDb($sid);
}
wstat_json([
'site' => ['id' => (int) $site['id'], 'name' => $site['name'], 'domain' => $site['domain']],
'now' => time(),
'today' => $today,
'days' => $days,
'range' => [
'start' => $start, 'end' => $end, 'days' => $days,
'single_day' => $start === $end,
'is_today' => $end === $today,
'hourly_day' => $end,
],
'totals' => [
'pv' => $sum['pv'], 'uv' => $sum['uv'], 'ip' => $sum['ipc'],
'visits' => $sum['visits'], 'bounce_rate' => $bounceRate, 'avg_duration' => $avgDur,
'new_users' => $sum['new_users'], 'pages' => $pagesCount, 'online' => $online,
],
'prev' => [
'pv' => $prevSum['pv'], 'uv' => $prevSum['uv'], 'ip' => $prevSum['ipc'],
'visits' => $prevSum['visits'], 'bounce_rate' => $prevBounceRate, 'avg_duration' => $prevAvgDur,
'new_users' => $prevSum['new_users'],
],
'trend' => $trend,
'hourly' => $hourly,
'paths' => [
'pages' => $agg('url'),
'entries' => $sessTop('entry_url'),
'exits' => $sessTop('exit_url'),
],
'sources' => [
'channels' => $agg('source'),
'referrers' => $agg('ref_host', 10, "AND ref_host<>''"),
],
'env' => [
'browsers' => $agg('browser', 10, "AND browser<>''"),
'os' => $agg('os', 10, "AND os<>''"),
'devices' => $agg('device', 10, "AND device<>''"),
],
'geo' => [
'countries' => $agg('country', 40, "AND country<>''"),
'provinces' => $agg('province', 40, "AND province<>''"),
'cities' => $agg('city', 10, "AND city<>''"),
],
'ips' => $ips,
]);
}
}