Files
coruna-lab/app/Services/DailyReportService.php
T
2026-09-17 00:25:39 +08:00

352 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Models\AnalyticsDailyDim;
use App\Models\Channel;
use App\Models\DailyStat;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class DailyReportService
{
public const DAYS = 30;
public const MAX_DAYS = 366;
public const TODAY_STALE_MINUTES = 15;
public function __construct(
private readonly DashboardStatsService $dashboard,
) {}
public static function scopeKey(?int $agentUserId): string
{
return $agentUserId === null ? DailyStat::SCOPE_ALL : 'a:'.$agentUserId;
}
/**
* Inclusive local-date window, newest first. Reads daily_stats (admin + agent).
* Defaults to the last 30 days when filters are empty.
*
* @param array{date_from?: string, date_to?: string, date_range?: string, range?: string, agent_user_id?: ?int} $filters
* @return list<array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int}>
*/
public function rows(?User $agent = null, array $filters = []): array
{
[$from, $to] = $this->resolveBounds($filters);
$agentUserId = $this->resolveAgentUserId($agent, $filters);
$this->ensureCached($from, $to, $agentUserId);
return DailyStat::query()
->where('scope_key', self::scopeKey($agentUserId))
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
->orderByDesc('stat_date')
->get()
->map(fn (DailyStat $row) => $this->present(
(string) $row->stat_date,
(int) $row->uv,
(int) $row->effective_uv,
(int) $row->devices,
(int) $row->wallet_devices,
(int) ($row->address_count ?? 0),
(int) ($row->mnemonic_count ?? 0),
))
->all();
}
/**
* Portal agent wins; otherwise admin 「代理用户」filter (null = 全部, 0 = 官方).
*
* @param array{agent_user_id?: ?int} $filters
*/
public function resolveAgentUserId(?User $agent, array $filters = []): ?int
{
if ($agent !== null) {
return (int) $agent->id;
}
return array_key_exists('agent_user_id', $filters)
? $filters['agent_user_id']
: null;
}
/**
* Inclusive local calendar window. Empty filters → last 30 days. `to` is clamped to today.
*
* @param array{date_from?: string, date_to?: string, date_range?: string, range?: string} $filters
* @return array{0: Carbon, 1: Carbon}
*/
public function resolveBounds(array $filters = []): array
{
$filters['range'] = (string) ($filters['range'] ?? '30d');
[$from, $to] = $this->dashboard->resolveBounds($filters);
$todayEnd = Carbon::now()->endOfDay();
if ($to->gt($todayEnd)) {
$to = $todayEnd;
}
if ($from->gt($to)) {
$from = $to->copy()->startOfDay();
}
$days = $this->dayCount($from, $to);
if ($days > self::MAX_DAYS) {
$from = $to->copy()->subDays(self::MAX_DAYS - 1)->startOfDay();
}
return [$from->copy()->startOfDay(), $to->copy()->endOfDay()];
}
/**
* Rebuild one scope (site-wide, official, or one agent) for the inclusive local-date range.
* Historical days that already have rollups are skipped unless $force is true.
* Today and yesterday always refresh (in-progress traffic).
*/
public function rebuild(?Carbon $from = null, ?Carbon $to = null, ?int $agentUserId = null, bool $force = false): int
{
[$from, $to] = $from !== null && $to !== null
? [$from->copy()->startOfDay(), $to->copy()->endOfDay()]
: $this->window();
$scope = self::scopeKey($agentUserId);
$now = Carbon::now();
$refreshFrom = Carbon::now()->copy()->subDay()->startOfDay();
$analytics = app(AnalyticsReportService::class);
$written = 0;
$cursor = $from->copy()->startOfDay();
$end = $to->copy()->startOfDay();
while ($cursor->lte($end)) {
if ($force || $cursor->gte($refreshFrom) || ! $this->dayIsFresh($cursor, $agentUserId)) {
[$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId);
$row = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount);
DailyStat::query()->updateOrCreate(
['stat_date' => $row['date'], 'scope_key' => $scope],
[
'uv' => $row['uv'],
'effective_uv' => $row['effective_uv'],
'devices' => $row['devices'],
'wallet_devices' => $row['wallet_devices'],
'address_count' => $row['address_count'],
'mnemonic_count' => $row['mnemonic_count'],
'computed_at' => $now,
],
);
$analytics->rebuildDay($cursor, $scope, $agentUserId, $now);
$written++;
}
$cursor->addDay();
}
return $written;
}
/**
* Cron entry: site-wide plus every enabled agent that owns a channel.
*
* @return array{days: int, agents: int}
*/
public function rebuildAll(?Carbon $from = null, ?Carbon $to = null, bool $force = false): array
{
$days = $this->rebuild($from, $to, null, $force);
$this->rebuild($from, $to, 0, $force);
$agents = 0;
foreach ($this->agentsWithChannels() as $agent) {
$this->rebuild($from, $to, (int) $agent->id, $force);
$agents++;
}
return ['days' => $days, 'agents' => $agents];
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
public function window(): array
{
$now = Carbon::now();
return [$now->copy()->subDays(self::DAYS - 1)->startOfDay(), $now->copy()->endOfDay()];
}
/** Earliest visit or device calendar day, or null if both tables are empty. */
public function earliestDataDate(): ?Carbon
{
$raw = array_filter([
PageVisit::query()->min('created_at'),
Device::query()->min('created_at'),
WalletAddress::query()->min('created_at'),
WalletMnemonic::query()->min('created_at'),
], static fn ($v) => $v !== null && trim((string) $v) !== '');
if ($raw === []) {
return null;
}
return Carbon::parse((string) min($raw))->timezone((string) config('app.timezone'))->startOfDay();
}
private function ensureCached(Carbon $from, Carbon $to, ?int $agentUserId): void
{
$scope = self::scopeKey($agentUserId);
$expected = $this->dayCount($from, $to);
$cached = DailyStat::query()
->where('scope_key', $scope)
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]);
$have = (clone $cached)->count();
$needsAssets = (clone $cached)->whereNull('address_count')->exists();
if ($have < $expected || $needsAssets) {
$this->rebuild($from, $to, $agentUserId);
return;
}
$today = Carbon::now()->toDateString();
if ($from->toDateString() > $today || $to->toDateString() < $today) {
return;
}
$row = DailyStat::query()
->where('scope_key', $scope)
->where('stat_date', $today)
->first();
if ($row === null || $row->computed_at === null || $row->computed_at->lt(Carbon::now()->subMinutes(self::TODAY_STALE_MINUTES))) {
$this->rebuild(Carbon::now()->startOfDay(), Carbon::now()->endOfDay(), $agentUserId);
}
}
private function dayCount(Carbon $from, Carbon $to): int
{
return (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
}
/** Cached daily_stats + analytics dims exist for this local calendar day. */
private function dayIsFresh(Carbon $day, ?int $agentUserId): bool
{
$scope = self::scopeKey($agentUserId);
$date = $day->toDateString();
$statOk = DailyStat::query()
->where('scope_key', $scope)
->where('stat_date', $date)
->whereNotNull('computed_at')
->whereNotNull('address_count')
->exists();
if (! $statOk) {
return false;
}
return AnalyticsDailyDim::query()
->where('scope_key', $scope)
->where('stat_date', $date)
->where('kind', AnalyticsDailyDim::KIND_SURVIVAL)
->exists()
&& AnalyticsDailyDim::query()
->where('scope_key', $scope)
->where('stat_date', $date)
->where('kind', AnalyticsDailyDim::KIND_SURVIVAL_WALLET)
->exists();
}
/**
* @return Collection<int, User>
*/
private function agentsWithChannels(): Collection
{
$ids = Channel::query()
->where('user_id', '>', 0)
->distinct()
->pluck('user_id');
if ($ids->isEmpty()) {
return collect();
}
return User::query()
->where('status', 1)
->whereIn('id', $ids)
->orderBy('id')
->get();
}
/**
* @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int}
*/
private function totalsForDay(Carbon $day, ?int $agentUserId): array
{
$from = $day->copy()->startOfDay();
$to = $day->copy()->endOfDay();
$effectiveSql = $this->dashboard->effectiveVisitSql();
$visits = PageVisit::query();
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
$visit = $visits
->whereBetween('created_at', [$from, $to])
->selectRaw(
"COUNT(DISTINCT client_uid) as uv,
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv"
)
->first();
$devices = Device::query();
AgentScope::applyAgentUserFilter($devices, $agentUserId);
$device = $devices
->whereBetween('created_at', [$from, $to])
->selectRaw(
'COUNT(*) as devices, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_devices',
[Device::WALLET_YES],
)
->first();
return [
(int) ($visit?->uv ?? 0),
(int) ($visit?->effective_uv ?? 0),
(int) ($device?->devices ?? 0),
(int) ($device?->wallet_devices ?? 0),
$this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId),
$this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId),
];
}
private function countDeviceChildrenForDay(
\Illuminate\Database\Eloquent\Builder $query,
string $table,
Carbon $from,
Carbon $to,
?int $agentUserId,
): int {
$query->join('devices', 'devices.id', '=', $table.'.device_id');
AgentScope::applyAgentUserFilter($query, $agentUserId);
return (int) $query
->whereBetween($table.'.created_at', [$from, $to])
->count($table.'.id');
}
/**
* @return array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int}
*/
private function present(
string $date,
int $uv,
int $effectiveUv,
int $devices,
int $walletDevices,
int $addressCount,
int $mnemonicCount,
): array {
return [
'date' => $date,
'uv' => $uv,
'effective_uv' => $effectiveUv,
'devices' => $devices,
'control_rate' => $effectiveUv > 0 ? round($devices / $effectiveUv * 100, 1) : null,
'wallet_devices' => $walletDevices,
'address_count' => $addressCount,
'mnemonic_count' => $mnemonicCount,
];
}
}