Serve dashboard stats from daily_stats and default visit list to today.
Stop live-scanning page_visits on every dashboard load (including today) so the admin home stays fast; keep the live path only for channel filters and cache the result for 45s. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,12 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\DsChainLog;
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Models\DsChainLog;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\CfIpCountry;
|
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ class PageVisitController extends Controller
|
|||||||
$q->where('referer', 'like', '%'.$referer.'%');
|
$q->where('referer', 'like', '%'.$referer.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
[$from, $to] = $this->rangeBounds((string) $request->query('range', '30d'));
|
[$from, $to] = $this->rangeBounds((string) $request->query('range', 'today'));
|
||||||
$q->whereBetween('created_at', [$from, $to]);
|
$q->whereBetween('created_at', [$from, $to]);
|
||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ class DailyStat extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'stat_date',
|
'stat_date',
|
||||||
'scope_key',
|
'scope_key',
|
||||||
|
'pv',
|
||||||
'uv',
|
'uv',
|
||||||
|
'effective_pv',
|
||||||
'effective_uv',
|
'effective_uv',
|
||||||
'devices',
|
'devices',
|
||||||
'wallet_devices',
|
'wallet_devices',
|
||||||
@@ -25,7 +27,9 @@ class DailyStat extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'pv' => 'integer',
|
||||||
'uv' => 'integer',
|
'uv' => 'integer',
|
||||||
|
'effective_pv' => 'integer',
|
||||||
'effective_uv' => 'integer',
|
'effective_uv' => 'integer',
|
||||||
'devices' => 'integer',
|
'devices' => 'integer',
|
||||||
'wallet_devices' => 'integer',
|
'wallet_devices' => 'integer',
|
||||||
@@ -34,4 +38,4 @@ class DailyStat extends Model
|
|||||||
'computed_at' => 'datetime',
|
'computed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\WalletAddress;
|
|||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class DailyReportService
|
class DailyReportService
|
||||||
@@ -122,12 +123,14 @@ class DailyReportService
|
|||||||
$end = $to->copy()->startOfDay();
|
$end = $to->copy()->startOfDay();
|
||||||
while ($cursor->lte($end)) {
|
while ($cursor->lte($end)) {
|
||||||
if ($force || $cursor->gte($refreshFrom) || ! $this->dayIsFresh($cursor, $agentUserId)) {
|
if ($force || $cursor->gte($refreshFrom) || ! $this->dayIsFresh($cursor, $agentUserId)) {
|
||||||
[$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId);
|
[$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount, $pv, $effectivePv] = $this->totalsForDay($cursor, $agentUserId);
|
||||||
$row = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount);
|
$row = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount);
|
||||||
DailyStat::query()->updateOrCreate(
|
DailyStat::query()->updateOrCreate(
|
||||||
['stat_date' => $row['date'], 'scope_key' => $scope],
|
['stat_date' => $row['date'], 'scope_key' => $scope],
|
||||||
[
|
[
|
||||||
|
'pv' => $pv,
|
||||||
'uv' => $row['uv'],
|
'uv' => $row['uv'],
|
||||||
|
'effective_pv' => $effectivePv,
|
||||||
'effective_uv' => $row['effective_uv'],
|
'effective_uv' => $row['effective_uv'],
|
||||||
'devices' => $row['devices'],
|
'devices' => $row['devices'],
|
||||||
'wallet_devices' => $row['wallet_devices'],
|
'wallet_devices' => $row['wallet_devices'],
|
||||||
@@ -233,6 +236,7 @@ class DailyReportService
|
|||||||
->where('stat_date', $date)
|
->where('stat_date', $date)
|
||||||
->whereNotNull('computed_at')
|
->whereNotNull('computed_at')
|
||||||
->whereNotNull('address_count')
|
->whereNotNull('address_count')
|
||||||
|
->whereNotNull('pv')
|
||||||
->exists();
|
->exists();
|
||||||
if (! $statOk) {
|
if (! $statOk) {
|
||||||
return false;
|
return false;
|
||||||
@@ -272,7 +276,7 @@ class DailyReportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int}
|
* @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int, 6: int, 7: int}
|
||||||
*/
|
*/
|
||||||
private function totalsForDay(Carbon $day, ?int $agentUserId): array
|
private function totalsForDay(Carbon $day, ?int $agentUserId): array
|
||||||
{
|
{
|
||||||
@@ -285,7 +289,9 @@ class DailyReportService
|
|||||||
$visit = $visits
|
$visit = $visits
|
||||||
->whereBetween('created_at', [$from, $to])
|
->whereBetween('created_at', [$from, $to])
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
"COUNT(DISTINCT client_uid) as uv,
|
"COUNT(*) as pv,
|
||||||
|
COUNT(DISTINCT client_uid) as uv,
|
||||||
|
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
|
||||||
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv"
|
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv"
|
||||||
)
|
)
|
||||||
->first();
|
->first();
|
||||||
@@ -307,11 +313,13 @@ class DailyReportService
|
|||||||
(int) ($device?->wallet_devices ?? 0),
|
(int) ($device?->wallet_devices ?? 0),
|
||||||
$this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId),
|
$this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId),
|
||||||
$this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId),
|
$this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId),
|
||||||
|
(int) ($visit?->pv ?? 0),
|
||||||
|
(int) ($visit?->effective_pv ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function countDeviceChildrenForDay(
|
private function countDeviceChildrenForDay(
|
||||||
\Illuminate\Database\Eloquent\Builder $query,
|
Builder $query,
|
||||||
string $table,
|
string $table,
|
||||||
Carbon $from,
|
Carbon $from,
|
||||||
Carbon $to,
|
Carbon $to,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\DailyStat;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
use App\Models\TransferRecord;
|
use App\Models\TransferRecord;
|
||||||
@@ -11,10 +12,13 @@ use App\Models\WalletMnemonic;
|
|||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DashboardStatsService
|
class DashboardStatsService
|
||||||
{
|
{
|
||||||
|
public const CACHE_SECONDS = 45;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* range?: string,
|
* range?: string,
|
||||||
@@ -48,8 +52,6 @@ class DashboardStatsService
|
|||||||
public function collect(array $filters = []): array
|
public function collect(array $filters = []): array
|
||||||
{
|
{
|
||||||
[$from, $to] = $this->resolveBounds($filters);
|
[$from, $to] = $this->resolveBounds($filters);
|
||||||
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
|
|
||||||
|
|
||||||
$channelId = trim((string) ($filters['channel_id'] ?? ''));
|
$channelId = trim((string) ($filters['channel_id'] ?? ''));
|
||||||
$channelExact = (bool) ($filters['channel_exact'] ?? false);
|
$channelExact = (bool) ($filters['channel_exact'] ?? false);
|
||||||
$agent = $filters['agent'] ?? null;
|
$agent = $filters['agent'] ?? null;
|
||||||
@@ -57,6 +59,74 @@ class DashboardStatsService
|
|||||||
? $filters['agent_user_id']
|
? $filters['agent_user_id']
|
||||||
: null;
|
: null;
|
||||||
$agentUserId = $agentUserId === null ? null : (int) $agentUserId;
|
$agentUserId = $agentUserId === null ? null : (int) $agentUserId;
|
||||||
|
$scopeAgentId = $agent !== null ? (int) $agent->id : $agentUserId;
|
||||||
|
|
||||||
|
$cacheKey = 'dashboard:stats:'.md5(json_encode([
|
||||||
|
$from->toDateTimeString(),
|
||||||
|
$to->toDateTimeString(),
|
||||||
|
$channelId,
|
||||||
|
$channelExact,
|
||||||
|
$scopeAgentId,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use (
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
$channelId,
|
||||||
|
$channelExact,
|
||||||
|
$agent,
|
||||||
|
$agentUserId,
|
||||||
|
$scopeAgentId,
|
||||||
|
) {
|
||||||
|
return $this->compute(
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
$channelId,
|
||||||
|
$channelExact,
|
||||||
|
$agent,
|
||||||
|
$agentUserId,
|
||||||
|
$scopeAgentId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer daily_stats sums (including today, up to ~15 min stale) unless a
|
||||||
|
* channel filter cannot be served from the rollup. Missing rollup rows fall
|
||||||
|
* back to a live page_visits scan.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* total: int,
|
||||||
|
* wallet_count: int,
|
||||||
|
* new_count: int,
|
||||||
|
* active_count: int,
|
||||||
|
* pv: int,
|
||||||
|
* uv: int,
|
||||||
|
* effective_pv: int,
|
||||||
|
* effective_uv: int,
|
||||||
|
* mnemonic_count: int,
|
||||||
|
* address_count: int,
|
||||||
|
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
|
||||||
|
* transfer_count: int,
|
||||||
|
* transfers: array{usdt: string, trx: string, eth: string, btc: string},
|
||||||
|
* range: string,
|
||||||
|
* from: string,
|
||||||
|
* to: string,
|
||||||
|
* date_from: string,
|
||||||
|
* date_to: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function compute(
|
||||||
|
Carbon $from,
|
||||||
|
Carbon $to,
|
||||||
|
string $channelId,
|
||||||
|
bool $channelExact,
|
||||||
|
?User $agent,
|
||||||
|
?int $agentUserId,
|
||||||
|
?int $scopeAgentId,
|
||||||
|
): array {
|
||||||
|
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
|
||||||
|
$rollup = $channelId === '' ? $this->rollupFromDailyStats($from, $to, $scopeAgentId) : null;
|
||||||
|
|
||||||
$base = Device::query();
|
$base = Device::query();
|
||||||
AgentScope::applyDeviceChannelScope($base, $agent);
|
AgentScope::applyDeviceChannelScope($base, $agent);
|
||||||
@@ -71,7 +141,9 @@ class DashboardStatsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Period cohort: devices installed (created) in the selected range.
|
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||||
|
|
||||||
|
if ($rollup === null) {
|
||||||
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
||||||
$deviceAgg = (clone $createdInRange)
|
$deviceAgg = (clone $createdInRange)
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
@@ -81,9 +153,6 @@ class DashboardStatsService
|
|||||||
->first();
|
->first();
|
||||||
$total = (int) ($deviceAgg?->total ?? 0);
|
$total = (int) ($deviceAgg?->total ?? 0);
|
||||||
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
|
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
|
||||||
$newCount = $total;
|
|
||||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
|
||||||
|
|
||||||
$mnemonicCount = $this->countDeviceChildren(
|
$mnemonicCount = $this->countDeviceChildren(
|
||||||
WalletMnemonic::query(),
|
WalletMnemonic::query(),
|
||||||
'wallet_mnemonics',
|
'wallet_mnemonics',
|
||||||
@@ -102,6 +171,33 @@ class DashboardStatsService
|
|||||||
$from,
|
$from,
|
||||||
$to,
|
$to,
|
||||||
);
|
);
|
||||||
|
[$pv, $uv, $effectivePv, $effectiveUv] = $this->liveVisitTotals(
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
$channelId,
|
||||||
|
$channelExact,
|
||||||
|
$agent,
|
||||||
|
$agentUserId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$total = $rollup['devices'];
|
||||||
|
$walletCount = $rollup['wallet_devices'];
|
||||||
|
$mnemonicCount = $rollup['mnemonic_count'];
|
||||||
|
$addressCount = $rollup['address_count'];
|
||||||
|
$pv = $rollup['pv'];
|
||||||
|
$uv = $rollup['uv'];
|
||||||
|
$effectivePv = $rollup['effective_pv'];
|
||||||
|
$effectiveUv = $rollup['effective_uv'];
|
||||||
|
[, $balances] = $this->addressStats(
|
||||||
|
$agent,
|
||||||
|
$agentUserId,
|
||||||
|
$channelId,
|
||||||
|
$channelExact,
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[$transferCount, $transfers] = $this->transferStats(
|
[$transferCount, $transfers] = $this->transferStats(
|
||||||
$agent,
|
$agent,
|
||||||
$agentUserId,
|
$agentUserId,
|
||||||
@@ -111,38 +207,10 @@ class DashboardStatsService
|
|||||||
$to,
|
$to,
|
||||||
);
|
);
|
||||||
|
|
||||||
$visits = PageVisit::query();
|
|
||||||
AgentScope::applyChannelIdScope($visits, $agent);
|
|
||||||
if ($agent === null) {
|
|
||||||
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
|
|
||||||
}
|
|
||||||
if ($channelId !== '') {
|
|
||||||
if ($channelExact) {
|
|
||||||
$visits->where('channel_id', $channelId);
|
|
||||||
} else {
|
|
||||||
$visits->where('channel_id', 'like', '%'.$channelId.'%');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$visits->whereBetween('created_at', [$from, $to]);
|
|
||||||
|
|
||||||
$effectiveSql = $this->effectiveVisitSql();
|
|
||||||
$visitAgg = (clone $visits)
|
|
||||||
->selectRaw(
|
|
||||||
"COUNT(*) as pv,
|
|
||||||
COUNT(DISTINCT client_uid) as uv,
|
|
||||||
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
|
|
||||||
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv",
|
|
||||||
)
|
|
||||||
->first();
|
|
||||||
$pv = (int) ($visitAgg?->pv ?? 0);
|
|
||||||
$uv = (int) ($visitAgg?->uv ?? 0);
|
|
||||||
$effectivePv = (int) ($visitAgg?->effective_pv ?? 0);
|
|
||||||
$effectiveUv = (int) ($visitAgg?->effective_uv ?? 0);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'wallet_count' => $walletCount,
|
'wallet_count' => $walletCount,
|
||||||
'new_count' => $newCount,
|
'new_count' => $total,
|
||||||
'active_count' => $activeCount,
|
'active_count' => $activeCount,
|
||||||
'pv' => $pv,
|
'pv' => $pv,
|
||||||
'uv' => $uv,
|
'uv' => $uv,
|
||||||
@@ -161,6 +229,88 @@ class DashboardStatsService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level UV summed across the range (same as the daily report). Null when
|
||||||
|
* this scope has no rollup rows yet.
|
||||||
|
*
|
||||||
|
* @return array{pv: int, uv: int, effective_pv: int, effective_uv: int, devices: int, wallet_devices: int, address_count: int, mnemonic_count: int}|null
|
||||||
|
*/
|
||||||
|
private function rollupFromDailyStats(Carbon $from, Carbon $to, ?int $scopeAgentId): ?array
|
||||||
|
{
|
||||||
|
$scope = DailyReportService::scopeKey($scopeAgentId);
|
||||||
|
$q = DailyStat::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]);
|
||||||
|
if (! (clone $q)->exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $q->selectRaw(
|
||||||
|
'COALESCE(SUM(pv), 0) as pv,
|
||||||
|
COALESCE(SUM(uv), 0) as uv,
|
||||||
|
COALESCE(SUM(effective_pv), 0) as effective_pv,
|
||||||
|
COALESCE(SUM(effective_uv), 0) as effective_uv,
|
||||||
|
COALESCE(SUM(devices), 0) as devices,
|
||||||
|
COALESCE(SUM(wallet_devices), 0) as wallet_devices,
|
||||||
|
COALESCE(SUM(address_count), 0) as address_count,
|
||||||
|
COALESCE(SUM(mnemonic_count), 0) as mnemonic_count',
|
||||||
|
)->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pv' => (int) ($row?->pv ?? 0),
|
||||||
|
'uv' => (int) ($row?->uv ?? 0),
|
||||||
|
'effective_pv' => (int) ($row?->effective_pv ?? 0),
|
||||||
|
'effective_uv' => (int) ($row?->effective_uv ?? 0),
|
||||||
|
'devices' => (int) ($row?->devices ?? 0),
|
||||||
|
'wallet_devices' => (int) ($row?->wallet_devices ?? 0),
|
||||||
|
'address_count' => (int) ($row?->address_count ?? 0),
|
||||||
|
'mnemonic_count' => (int) ($row?->mnemonic_count ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: int, 2: int, 3: int}
|
||||||
|
*/
|
||||||
|
private function liveVisitTotals(
|
||||||
|
Carbon $from,
|
||||||
|
Carbon $to,
|
||||||
|
string $channelId,
|
||||||
|
bool $channelExact,
|
||||||
|
?User $agent,
|
||||||
|
?int $agentUserId,
|
||||||
|
): array {
|
||||||
|
$visits = PageVisit::query();
|
||||||
|
AgentScope::applyChannelIdScope($visits, $agent);
|
||||||
|
if ($agent === null) {
|
||||||
|
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
|
||||||
|
}
|
||||||
|
if ($channelId !== '') {
|
||||||
|
if ($channelExact) {
|
||||||
|
$visits->where('channel_id', $channelId);
|
||||||
|
} else {
|
||||||
|
$visits->where('channel_id', 'like', '%'.$channelId.'%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$visits->whereBetween('created_at', [$from, $to]);
|
||||||
|
|
||||||
|
$effectiveSql = $this->effectiveVisitSql();
|
||||||
|
$visitAgg = $visits
|
||||||
|
->selectRaw(
|
||||||
|
"COUNT(*) as pv,
|
||||||
|
COUNT(DISTINCT client_uid) as uv,
|
||||||
|
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
|
||||||
|
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv",
|
||||||
|
)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
(int) ($visitAgg?->pv ?? 0),
|
||||||
|
(int) ($visitAgg?->uv ?? 0),
|
||||||
|
(int) ($visitAgg?->effective_pv ?? 0),
|
||||||
|
(int) ($visitAgg?->effective_uv ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{range?: string, date_from?: string, date_to?: string, date_range?: string} $filters
|
* @param array{range?: string, date_from?: string, date_to?: string, date_range?: string} $filters
|
||||||
* @return array{0: Carbon, 1: Carbon}
|
* @return array{0: Carbon, 1: Carbon}
|
||||||
@@ -423,5 +573,4 @@ class DashboardStatsService
|
|||||||
|
|
||||||
return [$major, $minor, $patch];
|
return [$major, $minor, $patch];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('pv')->nullable()->after('scope_key');
|
||||||
|
$table->unsignedBigInteger('effective_pv')->nullable()->after('effective_uv');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['pv', 'effective_pv']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
<label class="layui-form-label">时间</label>
|
<label class="layui-form-label">时间</label>
|
||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
<select name="range">
|
<select name="range">
|
||||||
<option value="30d" selected>近30天</option>
|
<option value="today" selected>今日</option>
|
||||||
<option value="7d">近7天</option>
|
|
||||||
<option value="yesterday">昨日</option>
|
<option value="yesterday">昨日</option>
|
||||||
<option value="today">今日</option>
|
<option value="7d">近7天</option>
|
||||||
|
<option value="30d">近30天</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<script>
|
<script>
|
||||||
layui.use(['table', 'form', 'layer'], function () {
|
layui.use(['table', 'form', 'layer'], function () {
|
||||||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||||||
var where = { range: '30d' };
|
var where = { range: 'today' };
|
||||||
var logsUrl = @json(route($portal.'.visits.logs'));
|
var logsUrl = @json(route($portal.'.visits.logs'));
|
||||||
|
|
||||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\WalletAddress;
|
|||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
use App\Services\DashboardStatsService;
|
use App\Services\DashboardStatsService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ class DailyReportTest extends TestCase
|
|||||||
'channel_id' => self::CHANNEL,
|
'channel_id' => self::CHANNEL,
|
||||||
'has_wallet' => Device::WALLET_YES,
|
'has_wallet' => Device::WALLET_YES,
|
||||||
]);
|
]);
|
||||||
\Illuminate\Support\Facades\DB::table('devices')->where('id', $device->id)->update([
|
DB::table('devices')->where('id', $device->id)->update([
|
||||||
'created_at' => $yesterday->toDateTimeString(),
|
'created_at' => $yesterday->toDateTimeString(),
|
||||||
'updated_at' => $yesterday->toDateTimeString(),
|
'updated_at' => $yesterday->toDateTimeString(),
|
||||||
]);
|
]);
|
||||||
@@ -172,7 +173,7 @@ class DailyReportTest extends TestCase
|
|||||||
'address' => 'THistDailyAddr1111111111111111111',
|
'address' => 'THistDailyAddr1111111111111111111',
|
||||||
'chain_type' => 'TRON',
|
'chain_type' => 'TRON',
|
||||||
]);
|
]);
|
||||||
\Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([
|
DB::table('wallet_addresses')->where('id', $addr->id)->update([
|
||||||
'created_at' => $yesterday->toDateTimeString(),
|
'created_at' => $yesterday->toDateTimeString(),
|
||||||
'updated_at' => $yesterday->toDateTimeString(),
|
'updated_at' => $yesterday->toDateTimeString(),
|
||||||
]);
|
]);
|
||||||
@@ -182,7 +183,7 @@ class DailyReportTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
$mnemonic->save();
|
$mnemonic->save();
|
||||||
\Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([
|
DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([
|
||||||
'created_at' => $yesterday->toDateTimeString(),
|
'created_at' => $yesterday->toDateTimeString(),
|
||||||
'updated_at' => $yesterday->toDateTimeString(),
|
'updated_at' => $yesterday->toDateTimeString(),
|
||||||
]);
|
]);
|
||||||
@@ -198,6 +199,8 @@ class DailyReportTest extends TestCase
|
|||||||
$this->assertNotNull($row);
|
$this->assertNotNull($row);
|
||||||
$this->assertSame(1, $row->uv);
|
$this->assertSame(1, $row->uv);
|
||||||
$this->assertSame(1, $row->effective_uv);
|
$this->assertSame(1, $row->effective_uv);
|
||||||
|
$this->assertSame(1, $row->pv);
|
||||||
|
$this->assertSame(1, $row->effective_pv);
|
||||||
$this->assertSame(1, $row->devices);
|
$this->assertSame(1, $row->devices);
|
||||||
$this->assertSame(1, $row->wallet_devices);
|
$this->assertSame(1, $row->wallet_devices);
|
||||||
$this->assertSame(1, $row->address_count);
|
$this->assertSame(1, $row->address_count);
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\DailyStat;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DashboardStatsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const CHANNEL = 'ffffffffffffffffffffffffffffffff';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
config([
|
||||||
|
'coruna.panel.admin_hosts' => [],
|
||||||
|
'coruna.panel.agent_hosts' => [],
|
||||||
|
]);
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function collect_uses_daily_stats_when_present(): void
|
||||||
|
{
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'live-uv',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
Device::query()->create([
|
||||||
|
'device_id' => 'live-dev',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'has_wallet' => Device::WALLET_YES,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DailyStat::query()->create([
|
||||||
|
'stat_date' => now()->toDateString(),
|
||||||
|
'scope_key' => DailyStat::SCOPE_ALL,
|
||||||
|
'pv' => 99,
|
||||||
|
'uv' => 88,
|
||||||
|
'effective_pv' => 77,
|
||||||
|
'effective_uv' => 66,
|
||||||
|
'devices' => 5,
|
||||||
|
'wallet_devices' => 3,
|
||||||
|
'address_count' => 4,
|
||||||
|
'mnemonic_count' => 2,
|
||||||
|
'computed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = app(DashboardStatsService::class)->collect(['range' => 'today']);
|
||||||
|
$this->assertSame(99, $stats['pv']);
|
||||||
|
$this->assertSame(88, $stats['uv']);
|
||||||
|
$this->assertSame(77, $stats['effective_pv']);
|
||||||
|
$this->assertSame(66, $stats['effective_uv']);
|
||||||
|
$this->assertSame(5, $stats['total']);
|
||||||
|
$this->assertSame(3, $stats['wallet_count']);
|
||||||
|
$this->assertSame(4, $stats['address_count']);
|
||||||
|
$this->assertSame(2, $stats['mnemonic_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function collect_falls_back_to_live_visits_when_rollup_missing(): void
|
||||||
|
{
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'live-uv',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'live-uv',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = app(DashboardStatsService::class)->collect(['range' => 'today']);
|
||||||
|
$this->assertSame(2, $stats['pv']);
|
||||||
|
$this->assertSame(1, $stats['uv']);
|
||||||
|
$this->assertSame(2, $stats['effective_pv']);
|
||||||
|
$this->assertSame(1, $stats['effective_uv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function collect_scans_live_when_channel_filter_set(): void
|
||||||
|
{
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'mine',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
'client_uid' => 'other',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
DailyStat::query()->create([
|
||||||
|
'stat_date' => now()->toDateString(),
|
||||||
|
'scope_key' => DailyStat::SCOPE_ALL,
|
||||||
|
'pv' => 99,
|
||||||
|
'uv' => 88,
|
||||||
|
'effective_pv' => 77,
|
||||||
|
'effective_uv' => 66,
|
||||||
|
'devices' => 5,
|
||||||
|
'wallet_devices' => 3,
|
||||||
|
'address_count' => 0,
|
||||||
|
'mnemonic_count' => 0,
|
||||||
|
'computed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = app(DashboardStatsService::class)->collect([
|
||||||
|
'range' => 'today',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'channel_exact' => true,
|
||||||
|
]);
|
||||||
|
$this->assertSame(1, $stats['pv']);
|
||||||
|
$this->assertSame(1, $stats['uv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function collect_caches_result(): void
|
||||||
|
{
|
||||||
|
DailyStat::query()->create([
|
||||||
|
'stat_date' => now()->toDateString(),
|
||||||
|
'scope_key' => DailyStat::SCOPE_ALL,
|
||||||
|
'pv' => 10,
|
||||||
|
'uv' => 9,
|
||||||
|
'effective_pv' => 8,
|
||||||
|
'effective_uv' => 7,
|
||||||
|
'devices' => 1,
|
||||||
|
'wallet_devices' => 0,
|
||||||
|
'address_count' => 0,
|
||||||
|
'mnemonic_count' => 0,
|
||||||
|
'computed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$svc = app(DashboardStatsService::class);
|
||||||
|
$first = $svc->collect(['range' => 'today']);
|
||||||
|
DailyStat::query()->update(['pv' => 50, 'uv' => 40]);
|
||||||
|
$second = $svc->collect(['range' => 'today']);
|
||||||
|
$this->assertSame(10, $first['pv']);
|
||||||
|
$this->assertSame(10, $second['pv']);
|
||||||
|
$this->assertSame(9, $second['uv']);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
$third = $svc->collect(['range' => 'today']);
|
||||||
|
$this->assertSame(50, $third['pv']);
|
||||||
|
$this->assertSame(40, $third['uv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function collect_sums_daily_rows_across_range(): void
|
||||||
|
{
|
||||||
|
DailyStat::query()->create([
|
||||||
|
'stat_date' => now()->toDateString(),
|
||||||
|
'scope_key' => DailyStat::SCOPE_ALL,
|
||||||
|
'pv' => 10,
|
||||||
|
'uv' => 4,
|
||||||
|
'effective_pv' => 3,
|
||||||
|
'effective_uv' => 2,
|
||||||
|
'devices' => 1,
|
||||||
|
'wallet_devices' => 1,
|
||||||
|
'address_count' => 1,
|
||||||
|
'mnemonic_count' => 1,
|
||||||
|
'computed_at' => now(),
|
||||||
|
]);
|
||||||
|
DailyStat::query()->create([
|
||||||
|
'stat_date' => now()->subDay()->toDateString(),
|
||||||
|
'scope_key' => DailyStat::SCOPE_ALL,
|
||||||
|
'pv' => 7,
|
||||||
|
'uv' => 3,
|
||||||
|
'effective_pv' => 2,
|
||||||
|
'effective_uv' => 1,
|
||||||
|
'devices' => 2,
|
||||||
|
'wallet_devices' => 0,
|
||||||
|
'address_count' => 3,
|
||||||
|
'mnemonic_count' => 4,
|
||||||
|
'computed_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = app(DashboardStatsService::class)->collect([
|
||||||
|
'date_from' => now()->subDay()->toDateString(),
|
||||||
|
'date_to' => now()->toDateString(),
|
||||||
|
]);
|
||||||
|
$this->assertSame(17, $stats['pv']);
|
||||||
|
$this->assertSame(7, $stats['uv']);
|
||||||
|
$this->assertSame(5, $stats['effective_pv']);
|
||||||
|
$this->assertSame(3, $stats['effective_uv']);
|
||||||
|
$this->assertSame(3, $stats['total']);
|
||||||
|
$this->assertSame(1, $stats['wallet_count']);
|
||||||
|
$this->assertSame(4, $stats['address_count']);
|
||||||
|
$this->assertSame(5, $stats['mnemonic_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_dashboard_data_uses_rollup(): void
|
||||||
|
{
|
||||||
|
DailyStat::query()->create([
|
||||||
|
'stat_date' => now()->toDateString(),
|
||||||
|
'scope_key' => DailyStat::SCOPE_ALL,
|
||||||
|
'pv' => 12,
|
||||||
|
'uv' => 6,
|
||||||
|
'effective_pv' => 4,
|
||||||
|
'effective_uv' => 3,
|
||||||
|
'devices' => 2,
|
||||||
|
'wallet_devices' => 1,
|
||||||
|
'address_count' => 0,
|
||||||
|
'mnemonic_count' => 0,
|
||||||
|
'computed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.dashboard.data', ['range' => 'today']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.pv', 12)
|
||||||
|
->assertJsonPath('data.uv', 6)
|
||||||
|
->assertJsonPath('data.total', 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -698,4 +698,48 @@ class PageVisitTest extends TestCase
|
|||||||
->assertJsonPath('data.1.stage', 'pe')
|
->assertJsonPath('data.1.stage', 'pe')
|
||||||
->assertJsonPath('data.1.stage_label', '权限提升');
|
->assertJsonPath('data.1.stage_label', '权限提升');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function visits_data_defaults_to_today(): void
|
||||||
|
{
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'today-uid',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'old-uid',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$payload = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.visits.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertCount(1, $payload);
|
||||||
|
$this->assertSame('today-uid', $payload[0]['client_uid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function visits_page_defaults_range_to_today(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.visits.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('value="today" selected', false)
|
||||||
|
->assertSee("range: 'today'", false)
|
||||||
|
->assertDontSee("range: '30d'", false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user