Files
SkinbaseNova/app/Console/Commands/AcademyAnalyticsRollupCommand.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];
}
}