258 lines
10 KiB
PHP
258 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\AcademyContentMetricDaily;
|
|
use App\Models\AcademyEvent;
|
|
use App\Models\AcademyLike;
|
|
use App\Models\AcademySave;
|
|
use App\Models\AcademySearchLog;
|
|
use App\Services\Academy\AcademyPopularityService;
|
|
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
|
use Carbon\CarbonPeriod;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
final class AcademyAnalyticsRollupCommand extends Command
|
|
{
|
|
protected $signature = 'academy:analytics-rollup {--date=} {--from=} {--to=}';
|
|
|
|
protected $description = 'Aggregate Academy analytics raw events and interaction records into daily content metrics';
|
|
|
|
public function __construct(private readonly AcademyPopularityService $popularity)
|
|
{
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
[$from, $to] = $this->resolveRange();
|
|
|
|
foreach (CarbonPeriod::create($from, $to) as $date) {
|
|
$this->rollupDate(Carbon::parse($date));
|
|
$this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString()));
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function rollupDate(Carbon $date): void
|
|
{
|
|
$start = $date->copy()->startOfDay();
|
|
$end = $date->copy()->endOfDay();
|
|
$metrics = [];
|
|
$uniqueVisitors = [];
|
|
$engagedDurations = [];
|
|
|
|
AcademyEvent::query()
|
|
->whereBetween('occurred_at', [$start, $end])
|
|
->orderBy('id')
|
|
->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void {
|
|
foreach ($events as $event) {
|
|
if ($event->is_bot || $event->is_admin || $event->is_suspicious) {
|
|
continue;
|
|
}
|
|
|
|
$key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null);
|
|
$this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key);
|
|
|
|
$visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? ''));
|
|
if ($visitorKey !== '') {
|
|
$uniqueVisitors[$key][$visitorKey] = true;
|
|
}
|
|
|
|
$eventType = (string) $event->event_type;
|
|
if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) {
|
|
$metrics[$key]['views']++;
|
|
if ($event->is_logged_in) {
|
|
$metrics[$key]['user_views']++;
|
|
} else {
|
|
$metrics[$key]['guest_views']++;
|
|
}
|
|
if ($event->is_subscriber) {
|
|
$metrics[$key]['subscriber_views']++;
|
|
}
|
|
}
|
|
|
|
if ($eventType === 'academy_engaged_view') {
|
|
$metrics[$key]['engaged_views']++;
|
|
$engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15));
|
|
}
|
|
|
|
if ($eventType === 'academy_scroll_50') {
|
|
$metrics[$key]['scroll_50']++;
|
|
}
|
|
if ($eventType === 'academy_scroll_75') {
|
|
$metrics[$key]['scroll_75']++;
|
|
}
|
|
if ($eventType === 'academy_scroll_100') {
|
|
$metrics[$key]['scroll_100']++;
|
|
}
|
|
if ($eventType === 'academy_prompt_copy') {
|
|
$metrics[$key]['prompt_copies']++;
|
|
}
|
|
if ($eventType === 'academy_prompt_negative_copy') {
|
|
$metrics[$key]['negative_prompt_copies']++;
|
|
}
|
|
if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) {
|
|
$metrics[$key]['starts']++;
|
|
}
|
|
if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) {
|
|
$metrics[$key]['completions']++;
|
|
}
|
|
if ($eventType === 'academy_upgrade_click') {
|
|
$metrics[$key]['upgrade_clicks']++;
|
|
}
|
|
if ($eventType === 'academy_premium_preview_view') {
|
|
$metrics[$key]['premium_preview_views']++;
|
|
}
|
|
if ($eventType === 'academy_search_result_click') {
|
|
$metrics[$key]['search_clicks']++;
|
|
|
|
$searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
|
|
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey);
|
|
$metrics[$searchKey]['search_clicks']++;
|
|
}
|
|
}
|
|
});
|
|
|
|
foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) {
|
|
$key = $this->metricKey((string) $like->content_type, (int) $like->content_id);
|
|
$this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key);
|
|
$metrics[$key]['likes']++;
|
|
}
|
|
|
|
foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) {
|
|
$key = $this->metricKey((string) $save->content_type, (int) $save->content_id);
|
|
$this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key);
|
|
$metrics[$key]['saves']++;
|
|
}
|
|
|
|
foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) {
|
|
$key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
|
|
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key);
|
|
$metrics[$key]['search_impressions']++;
|
|
if ((int) $searchLog->results_count === 0) {
|
|
$metrics[$key]['bounce_count']++;
|
|
}
|
|
|
|
$visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? ''));
|
|
if ($visitorKey !== '') {
|
|
$uniqueVisitors[$key][$visitorKey] = true;
|
|
}
|
|
}
|
|
|
|
foreach ($metrics as $key => $metric) {
|
|
$metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0;
|
|
$metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== []
|
|
? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key]))
|
|
: null;
|
|
$metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views']));
|
|
$metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric);
|
|
$metric['conversion_score'] = $this->popularity->calculateConversionScore($metric);
|
|
|
|
AcademyContentMetricDaily::query()->upsert([
|
|
array_merge($metric, [
|
|
'date' => $date->copy()->startOfDay(),
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]),
|
|
], ['date', 'content_type', 'content_id'], [
|
|
'views',
|
|
'unique_visitors',
|
|
'guest_views',
|
|
'user_views',
|
|
'subscriber_views',
|
|
'engaged_views',
|
|
'scroll_50',
|
|
'scroll_75',
|
|
'scroll_100',
|
|
'likes',
|
|
'saves',
|
|
'prompt_copies',
|
|
'negative_prompt_copies',
|
|
'starts',
|
|
'completions',
|
|
'upgrade_clicks',
|
|
'premium_preview_views',
|
|
'search_impressions',
|
|
'search_clicks',
|
|
'bounce_count',
|
|
'avg_engaged_seconds',
|
|
'popularity_score',
|
|
'conversion_score',
|
|
'updated_at',
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $metrics
|
|
*/
|
|
private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void
|
|
{
|
|
if (isset($metrics[$key])) {
|
|
return;
|
|
}
|
|
|
|
$metrics[$key] = [
|
|
'content_type' => $contentType,
|
|
'content_id' => $contentId,
|
|
'views' => 0,
|
|
'unique_visitors' => 0,
|
|
'guest_views' => 0,
|
|
'user_views' => 0,
|
|
'subscriber_views' => 0,
|
|
'engaged_views' => 0,
|
|
'scroll_50' => 0,
|
|
'scroll_75' => 0,
|
|
'scroll_100' => 0,
|
|
'likes' => 0,
|
|
'saves' => 0,
|
|
'prompt_copies' => 0,
|
|
'negative_prompt_copies' => 0,
|
|
'starts' => 0,
|
|
'completions' => 0,
|
|
'upgrade_clicks' => 0,
|
|
'premium_preview_views' => 0,
|
|
'search_impressions' => 0,
|
|
'search_clicks' => 0,
|
|
'bounce_count' => 0,
|
|
'avg_engaged_seconds' => null,
|
|
'popularity_score' => 0,
|
|
'conversion_score' => 0,
|
|
];
|
|
}
|
|
|
|
private function metricKey(string $contentType, ?int $contentId): string
|
|
{
|
|
return sprintf('%s:%s', $contentType, $contentId ?? 'none');
|
|
}
|
|
|
|
/**
|
|
* @return array{0: Carbon, 1: Carbon}
|
|
*/
|
|
private function resolveRange(): array
|
|
{
|
|
$date = $this->option('date');
|
|
$from = $this->option('from');
|
|
$to = $this->option('to');
|
|
|
|
if (is_string($date) && trim($date) !== '') {
|
|
$resolved = Carbon::parse($date)->startOfDay();
|
|
|
|
return [$resolved, $resolved->copy()];
|
|
}
|
|
|
|
$resolvedFrom = is_string($from) && trim($from) !== ''
|
|
? Carbon::parse($from)->startOfDay()
|
|
: now()->subDay()->startOfDay();
|
|
$resolvedTo = is_string($to) && trim($to) !== ''
|
|
? Carbon::parse($to)->startOfDay()
|
|
: $resolvedFrom->copy();
|
|
|
|
return [$resolvedFrom, $resolvedTo];
|
|
}
|
|
} |