feat(analytics): 数据分析页增加访问与受控设备的国家维度分布

参考受控版本分布的饼图实现,新增国家维度分布:
- AnalyticsDailyDim 增加 KIND_VISIT_COUNTRY / KIND_DEVICE_COUNTRY 常量
- AnalyticsReportService::aggregateDay() 按 country 聚合访问 UV 与受控设备数
- present() 汇总并返回 by_visit_country / by_device_country(ISO 代码转中文名展示)
- 视图新增两个国家饼图卡片与两个国家明细标签区

性能与正确性修复:
- ensureCached() 改为一次查询校验所有按日聚合 kind 的缓存完整性,
  避免旧缓存完整时新维度不被聚合的问题
- 新增迁移为 page_visits / devices 建立 (created_at, country) 复合索引,
  对齐 os_version 维度的索引做法,加速按日期范围 + GROUP BY country 的聚合
This commit is contained in:
hashbro
2026-09-26 19:49:25 +00:00
parent 2c974c8263
commit 8d5ec411f1
4 changed files with 177 additions and 5 deletions
+4
View File
@@ -14,6 +14,10 @@ class AnalyticsDailyDim extends Model
public const KIND_DEVICE_VERSION = 'device_version';
public const KIND_VISIT_COUNTRY = 'visit_country';
public const KIND_DEVICE_COUNTRY = 'device_country';
public const KIND_SURVIVAL = 'survival';
public const KIND_SURVIVAL_WALLET = 'survival_wallet';
+97 -5
View File
@@ -7,6 +7,7 @@ use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Support\AgentScope;
use App\Support\CfIpCountry;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
@@ -28,6 +29,8 @@ class AnalyticsReportService
* by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
* by_device_country: list<array{label: string, count: int, pct: float}>,
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
* }
*/
@@ -67,12 +70,30 @@ class AnalyticsReportService
{
$scope = DailyReportService::scopeKey($agentUserId);
$expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
$have = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL, $from, $to);
$haveWallet = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL_WALLET, $from, $to);
if ($have < $expected || $haveWallet < $expected) {
$this->rebuild($from, $to, $agentUserId);
// 一次查询拿到范围内每个 kind 的去重天数,避免逐 kind COUNT。
$have = AnalyticsDailyDim::query()
->where('scope_key', $scope)
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
->selectRaw('kind, COUNT(DISTINCT stat_date) AS days')
->groupBy('kind')
->pluck('days', 'kind')
->all();
// 检查所有按日聚合的 kind 是否都有完整缓存;任一缺失即重建。
$requiredKinds = [
AnalyticsDailyDim::KIND_SURVIVAL,
AnalyticsDailyDim::KIND_SURVIVAL_WALLET,
AnalyticsDailyDim::KIND_VISIT_OS,
AnalyticsDailyDim::KIND_VISIT_VERSION,
AnalyticsDailyDim::KIND_DEVICE_VERSION,
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
];
foreach ($requiredKinds as $kind) {
if ((int) ($have[$kind] ?? 0) < $expected) {
$this->rebuild($from, $to, $agentUserId);
return;
return;
}
}
$today = Carbon::now()->toDateString();
@@ -176,6 +197,34 @@ class AnalyticsReportService
);
}
$countryExpr = $this->countryDimSql('country');
$visitCountryRows = (clone $visits)
->selectRaw("{$countryExpr} as dim, COUNT(DISTINCT client_uid) as uv")
->groupByRaw($countryExpr)
->get();
foreach ($visitCountryRows as $row) {
$out[] = $this->dimRow(
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
$this->clipDim((string) $row->dim),
(int) $row->uv,
);
}
$deviceCountryRows = (clone $devices)
->selectRaw("{$countryExpr} as dim, COUNT(*) as cnt")
->groupByRaw($countryExpr)
->get();
foreach ($deviceCountryRows as $row) {
$out[] = $this->dimRow(
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
$this->clipDim((string) $row->dim),
0,
0,
(int) $row->cnt,
);
}
$secondsExpr = $this->survivalSecondsSql();
$survival = (clone $devices)
->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum")
@@ -215,6 +264,8 @@ class AnalyticsReportService
* by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
* by_device_country: list<array{label: string, count: int, pct: float}>,
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
* }
*/
@@ -230,6 +281,8 @@ class AnalyticsReportService
$visitVersions = [];
$visitEffective = [];
$deviceVersions = [];
$visitCountries = [];
$deviceCountries = [];
$survivalDevices = 0;
$survivalSeconds = 0;
$walletDevices = 0;
@@ -247,6 +300,10 @@ class AnalyticsReportService
$visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv;
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) {
$deviceVersions[$row->dim] = ($deviceVersions[$row->dim] ?? 0) + (int) $row->devices;
} elseif ($row->kind === AnalyticsDailyDim::KIND_VISIT_COUNTRY) {
$visitCountries[$row->dim] = ($visitCountries[$row->dim] ?? 0) + (int) $row->uv;
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_COUNTRY) {
$deviceCountries[$row->dim] = ($deviceCountries[$row->dim] ?? 0) + (int) $row->devices;
} elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL) {
$survivalDevices += (int) $row->devices;
$survivalSeconds += (int) $row->seconds_sum;
@@ -302,6 +359,8 @@ class AnalyticsReportService
],
'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum),
'by_device_ios_version' => $this->deviceVersionRows($deviceVersions, $deviceTotal),
'by_visit_country' => $this->countryRows($visitCountries, array_sum($visitCountries), 'uv'),
'by_device_country' => $this->countryRows($deviceCountries, array_sum($deviceCountries), 'count'),
'control_by_version' => $control,
];
}
@@ -347,6 +406,34 @@ class AnalyticsReportService
return $rows;
}
/**
* 按国家维度汇总,dim 存 ISO 代码,label 转中文名展示。
*
* @param array<string, int> $countries
* @return list<array{label: string, uv: int, pct: float}>|list<array{label: string, count: int, pct: float}>
*/
private function countryRows(array $countries, int $total, string $valueKey): array
{
$labels = array_keys($countries);
usort($labels, function (string $a, string $b) use ($countries): int {
return ($countries[$b] ?? 0) <=> ($countries[$a] ?? 0) ?: strcmp($a, $b);
});
$rows = [];
foreach ($labels as $code) {
$value = (int) $countries[$code];
$label = $code === AnalyticsDailyDim::DIM_UNKNOWN
? AnalyticsDailyDim::DIM_UNKNOWN
: CfIpCountry::label($code);
$rows[] = [
'label' => $label ?: $code,
$valueKey => $value,
'pct' => $total > 0 ? round(($value / $total) * 100, 1) : 0.0,
];
}
return $rows;
}
/**
* @return array{label: string, uv: int, pct: float}
*/
@@ -410,6 +497,11 @@ class AnalyticsReportService
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE TRIM({$column}) END";
}
private function countryDimSql(string $column): string
{
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE UPPER(TRIM({$column})) END";
}
private function survivalSecondsSql(): string
{
if (DB::connection()->getDriverName() === 'sqlite') {