diff --git a/app/Models/AnalyticsDailyDim.php b/app/Models/AnalyticsDailyDim.php index b4550a1..c5bdebb 100644 --- a/app/Models/AnalyticsDailyDim.php +++ b/app/Models/AnalyticsDailyDim.php @@ -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'; diff --git a/app/Services/AnalyticsReportService.php b/app/Services/AnalyticsReportService.php index 4df1f8a..e82ac6c 100644 --- a/app/Services/AnalyticsReportService.php +++ b/app/Services/AnalyticsReportService.php @@ -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, * by_ios_version: list, * by_device_ios_version: list, + * by_visit_country: list, + * by_device_country: list, * control_by_version: list * } */ @@ -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, * by_ios_version: list, * by_device_ios_version: list, + * by_visit_country: list, + * by_device_country: list, * control_by_version: list * } */ @@ -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 $countries + * @return list|list + */ + 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') { diff --git a/database/migrations/2026_09_26_200000_add_country_indexes_for_analytics.php b/database/migrations/2026_09_26_200000_add_country_indexes_for_analytics.php new file mode 100644 index 0000000..1636b03 --- /dev/null +++ b/database/migrations/2026_09_26_200000_add_country_indexes_for_analytics.php @@ -0,0 +1,35 @@ +index(['created_at', 'country'], 'page_visits_created_at_country_index'); + }); + + Schema::table('devices', function (Blueprint $table) { + $table->index(['created_at', 'country'], 'devices_created_at_country_index'); + }); + } + + public function down(): void + { + Schema::table('page_visits', function (Blueprint $table) { + $table->dropIndex('page_visits_created_at_country_index'); + }); + + Schema::table('devices', function (Blueprint $table) { + $table->dropIndex('devices_created_at_country_index'); + }); + } +}; diff --git a/resources/views/admin/reports/analytics.blade.php b/resources/views/admin/reports/analytics.blade.php index c7b1be0..ec87cb1 100644 --- a/resources/views/admin/reports/analytics.blade.php +++ b/resources/views/admin/reports/analytics.blade.php @@ -146,6 +146,27 @@ +
+
+
+
访问 · 国家
+
+
+

+
+
+
+
+
+
受控设备 · 国家
+
+
+

+
+
+
+
+
受控设备版本明细
@@ -158,6 +179,18 @@
—
+
+
访问国家明细
+
+
—
+
+
+
+
受控设备国家明细
+
+
—
+
+
@endsection @@ -340,12 +373,20 @@ layui.use(['form', 'laydate', 'layer', 'echarts'], function () { renderPie('an-os-chart', 'an-os-hint', data.by_os, 'uv', '系统'); renderPie('an-ios-chart', 'an-ios-hint', data.by_ios_version, 'uv', 'iOS 版本'); renderPie('an-device-chart', 'an-device-hint', data.by_device_ios_version, 'count', '受控版本'); + renderPie('an-visit-country-chart', 'an-visit-country-hint', data.by_visit_country, 'uv', '访问国家'); + renderPie('an-device-country-chart', 'an-device-country-hint', data.by_device_country, 'count', '受控国家'); fillTags('#an-by-ios-version', data.by_ios_version, 'uv'); fillTags('#an-by-device-ios', data.by_device_ios_version, 'count'); + fillTags('#an-by-visit-country', data.by_visit_country, 'uv'); + fillTags('#an-by-device-country', data.by_device_country, 'count'); var iosN = (data.by_ios_version || []).length; var deviceN = (data.by_device_ios_version || []).length; + var visitCountryN = (data.by_visit_country || []).length; + var deviceCountryN = (data.by_device_country || []).length; $('#an-ios-count').text(iosN ? ('· ' + iosN + ' 个') : ''); $('#an-device-count').text(deviceN ? ('· ' + deviceN + ' 个') : ''); + $('#an-visit-country-count').text(visitCountryN ? ('· ' + visitCountryN + ' 个') : ''); + $('#an-device-country-count').text(deviceCountryN ? ('· ' + deviceCountryN + ' 个') : ''); var computed = data.computed_at ? (' · 汇总于 ' + data.computed_at) : ''; $('#an-range').text('统计区间:' + data.from + ' ~ ' + data.to + '(按日去重后加总,今日约 15 分钟刷新)' + computed); }