fix: statisc
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
@@ -49,6 +50,49 @@ class DashboardController extends Controller
|
||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
|
||||
$visits = PageVisit::query();
|
||||
AgentScope::applyChannelIdScope($visits, $this->agent());
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
|
||||
}
|
||||
if ($channelId !== '') {
|
||||
$visits->where('channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
$visits->whereBetween('created_at', [$from, $to]);
|
||||
|
||||
$pv = (clone $visits)->count();
|
||||
$uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$byOs = (clone $visits)
|
||||
->select('os')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('os')
|
||||
->orderByDesc('pv')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(static fn ($r) => [
|
||||
'label' => ((string) ($r->os ?? '')) !== '' ? (string) $r->os : 'Unknown',
|
||||
'pv' => (int) $r->pv,
|
||||
'uv' => (int) $r->uv,
|
||||
])
|
||||
->values();
|
||||
|
||||
$byBrowser = (clone $visits)
|
||||
->select('browser')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('browser')
|
||||
->orderByDesc('pv')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(static fn ($r) => [
|
||||
'label' => ((string) ($r->browser ?? '')) !== '' ? (string) $r->browser : 'Unknown',
|
||||
'pv' => (int) $r->pv,
|
||||
'uv' => (int) $r->uv,
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
@@ -56,6 +100,10 @@ class DashboardController extends Controller
|
||||
'total' => $total,
|
||||
'new_count' => $newCount,
|
||||
'active_count' => $activeCount,
|
||||
'pv' => $pv,
|
||||
'uv' => $uv,
|
||||
'by_os' => $byOs,
|
||||
'by_browser' => $byBrowser,
|
||||
'range' => $range,
|
||||
'from' => $from->toDateTimeString(),
|
||||
'to' => $to->toDateTimeString(),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
class PageVisitController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.visits.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->scopedQuery($request);
|
||||
$limit = min(100, max(1, (int) $request->query('limit', 20)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$total = (clone $q)->count();
|
||||
$rows = (clone $q)
|
||||
->orderByDesc('id')
|
||||
->forPage($page, $limit)
|
||||
->get([
|
||||
'id', 'channel_id', 'client_uid', 'os', 'os_version',
|
||||
'browser', 'browser_version', 'user_agent', 'ip', 'path', 'created_at',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $total,
|
||||
'data' => $rows->map(static fn (PageVisit $v) => [
|
||||
'id' => $v->id,
|
||||
'channel_id' => $v->channel_id,
|
||||
'client_uid' => $v->client_uid,
|
||||
'os' => $v->os ?: '',
|
||||
'os_version' => $v->os_version ?: '',
|
||||
'browser' => $v->browser ?: '',
|
||||
'browser_version' => $v->browser_version ?: '',
|
||||
'user_agent' => $v->user_agent ?: '',
|
||||
'ip' => $v->ip ?: '',
|
||||
'path' => $v->path ?: '',
|
||||
'created_at' => optional($v->created_at)?->toDateTimeString(),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function groups(Request $request)
|
||||
{
|
||||
$group = (string) $request->query('group', 'os');
|
||||
$base = $this->scopedQuery($request);
|
||||
$builder = match ($group) {
|
||||
'os_version' => $base
|
||||
->select('os', 'os_version')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('os', 'os_version'),
|
||||
'browser' => $base
|
||||
->select('browser')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('browser'),
|
||||
'browser_version' => $base
|
||||
->select('browser', 'browser_version')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('browser', 'browser_version'),
|
||||
default => $base
|
||||
->select('os')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('os'),
|
||||
};
|
||||
|
||||
$rows = $builder
|
||||
->orderByDesc('pv')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(static function ($row) use ($group) {
|
||||
$label = match ($group) {
|
||||
'os_version' => trim(((string) ($row->os ?? '')).' '.((string) ($row->os_version ?? ''))),
|
||||
'browser' => (string) ($row->browser ?? ''),
|
||||
'browser_version' => trim(((string) ($row->browser ?? '')).' '.((string) ($row->browser_version ?? ''))),
|
||||
default => (string) ($row->os ?? ''),
|
||||
};
|
||||
|
||||
return [
|
||||
'label' => $label !== '' ? $label : 'Unknown',
|
||||
'pv' => (int) $row->pv,
|
||||
'uv' => (int) $row->uv,
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'group' => $group,
|
||||
'rows' => $rows,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function scopedQuery(Request $request)
|
||||
{
|
||||
$q = PageVisit::query();
|
||||
AgentScope::applyChannelIdScope($q, $this->agent());
|
||||
|
||||
$agentUserId = $this->isAgentPortal()
|
||||
? (int) ($this->agent()?->id ?? 0)
|
||||
: (int) $request->query('agent_user_id', 0);
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyChannelIdAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
if ($channelId !== '') {
|
||||
$q->where('channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
|
||||
$os = trim((string) $request->query('os', ''));
|
||||
if ($os !== '') {
|
||||
$q->where('os', $os);
|
||||
}
|
||||
$osVersion = trim((string) $request->query('os_version', ''));
|
||||
if ($osVersion !== '') {
|
||||
$q->where('os_version', $osVersion);
|
||||
}
|
||||
$browser = trim((string) $request->query('browser', ''));
|
||||
if ($browser !== '') {
|
||||
$q->where('browser', $browser);
|
||||
}
|
||||
$browserVersion = trim((string) $request->query('browser_version', ''));
|
||||
if ($browserVersion !== '') {
|
||||
$q->where('browser_version', $browserVersion);
|
||||
}
|
||||
|
||||
[$from, $to] = $this->rangeBounds((string) $request->query('range', '30d'));
|
||||
$q->whereBetween('created_at', [$from, $to]);
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
private function rangeBounds(string $range): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
return match ($range) {
|
||||
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||
'yesterday' => [
|
||||
$now->copy()->subDay()->startOfDay(),
|
||||
$now->copy()->subDay()->endOfDay(),
|
||||
],
|
||||
'7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
|
||||
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,13 @@ class SystemSettingsController extends Controller
|
||||
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
||||
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'channels_domains' => ['nullable', 'string', 'max:4000'],
|
||||
'panel_admin_hosts' => ['nullable', 'string', 'max:4000'],
|
||||
'panel_agent_hosts' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
$domains = SettingsService::parseDomains($data['channels_domains'] ?? null);
|
||||
$adminHosts = SettingsService::parseDomains($data['panel_admin_hosts'] ?? null);
|
||||
$agentHosts = SettingsService::parseDomains($data['panel_agent_hosts'] ?? null);
|
||||
|
||||
$settings->putMany([
|
||||
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
|
||||
@@ -34,6 +38,9 @@ class SystemSettingsController extends Controller
|
||||
: null,
|
||||
// Persist normalized comma-separated list (empty clears override to []).
|
||||
'channels.domains' => $domains === [] ? '' : implode(',', $domains),
|
||||
// Empty = no Host restriction for that panel.
|
||||
'panel.admin_hosts' => $adminHosts === [] ? '' : implode(',', $adminHosts),
|
||||
'panel.agent_hosts' => $agentHosts === [] ? '' : implode(',', $agentHosts),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '已保存']);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PageVisit;
|
||||
use App\Support\UserAgentParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PageHitController extends Controller
|
||||
{
|
||||
private const GIF_1X1 = "GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$channelId = strtolower(trim((string) $request->query('c', $request->input('c', ''))));
|
||||
$clientUid = trim((string) $request->query('u', $request->input('u', '')));
|
||||
$path = trim((string) $request->query('p', $request->input('p', '')));
|
||||
|
||||
if (! preg_match('/^[0-9a-f]{32}$/', $channelId)) {
|
||||
return $this->pixel();
|
||||
}
|
||||
if (! preg_match('/^[0-9a-fA-F-]{8,64}$/', $clientUid) && ! preg_match('/^tmp_[0-9a-f]+$/i', $clientUid)) {
|
||||
return $this->pixel();
|
||||
}
|
||||
if (strlen($path) > 255) {
|
||||
$path = substr($path, 0, 255);
|
||||
}
|
||||
|
||||
$debounceKey = 'page_hit:'.$channelId.':'.$clientUid;
|
||||
if (! Cache::add($debounceKey, 1, now()->addSeconds(8))) {
|
||||
return $this->pixel();
|
||||
}
|
||||
|
||||
$ua = substr((string) $request->userAgent(), 0, 512);
|
||||
$parsed = UserAgentParser::parse($ua);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'client_uid' => substr($clientUid, 0, 64),
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'os' => $parsed['os'],
|
||||
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
|
||||
'browser' => $parsed['browser'],
|
||||
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||
'ip' => $request->ip(),
|
||||
'path' => $path !== '' ? $path : null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->pixel();
|
||||
}
|
||||
|
||||
private function pixel(): Response
|
||||
{
|
||||
return response(self::GIF_1X1, 200, [
|
||||
'Content-Type' => 'image/gif',
|
||||
'Content-Length' => (string) strlen(self::GIF_1X1),
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Restrict admin / agent panels to configured Host allowlists.
|
||||
* Empty allowlist = no restriction (dev / bootstrap safe).
|
||||
*/
|
||||
class EnsurePanelHost
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $panel = 'admin'): Response
|
||||
{
|
||||
$hosts = match ($panel) {
|
||||
'admin' => config('coruna.panel.admin_hosts', []),
|
||||
'agent' => config('coruna.panel.agent_hosts', []),
|
||||
default => [],
|
||||
};
|
||||
|
||||
if (! is_array($hosts) || $hosts === []) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$allowed = array_values(array_filter(array_map(
|
||||
static fn ($h) => strtolower(trim((string) $h)),
|
||||
$hosts
|
||||
)));
|
||||
|
||||
if ($allowed === []) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$host = strtolower($request->getHost());
|
||||
if (! in_array($host, $allowed, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PageVisit extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'channel_id',
|
||||
'client_uid',
|
||||
'user_agent',
|
||||
'os',
|
||||
'os_version',
|
||||
'browser',
|
||||
'browser_version',
|
||||
'ip',
|
||||
'path',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@ class IngestService
|
||||
if ($chainType === '') {
|
||||
$chainType = WalletSource::inferChainType($address);
|
||||
}
|
||||
$chainType = strtoupper(trim($chainType));
|
||||
if (! WalletSource::isSupportedChain($chainType)) {
|
||||
continue;
|
||||
}
|
||||
$balance = $row['balance'] ?? '';
|
||||
// Global Wallet ad map: scalar is not a balance → ignore coin fields.
|
||||
if (! is_array($balance) && ! is_string($balance)) {
|
||||
|
||||
@@ -13,6 +13,8 @@ class SettingsService
|
||||
'telegram.owner_chat_id',
|
||||
'channels.max_per_agent',
|
||||
'channels.domains',
|
||||
'panel.admin_hosts',
|
||||
'panel.agent_hosts',
|
||||
];
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
@@ -89,18 +91,30 @@ class SettingsService
|
||||
'coruna.deployment_domains' => $domains,
|
||||
]);
|
||||
}
|
||||
|
||||
// Once saved in settings, DB wins (null/empty = no Host restriction).
|
||||
if (array_key_exists('panel.admin_hosts', $map)) {
|
||||
config(['coruna.panel.admin_hosts' => self::parseDomains($map['panel.admin_hosts'])]);
|
||||
}
|
||||
if (array_key_exists('panel.agent_hosts', $map)) {
|
||||
config(['coruna.panel.agent_hosts' => self::parseDomains($map['panel.agent_hosts'])]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Effective values for the settings form (DB overrides env). */
|
||||
public function effectiveForForm(): array
|
||||
{
|
||||
$domains = config('coruna.channel_domains', []);
|
||||
$adminHosts = config('coruna.panel.admin_hosts', []);
|
||||
$agentHosts = config('coruna.panel.agent_hosts', []);
|
||||
|
||||
return [
|
||||
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
|
||||
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
|
||||
'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5),
|
||||
'channels.domains' => is_array($domains) ? implode("\n", $domains) : '',
|
||||
'panel.admin_hosts' => is_array($adminHosts) ? implode("\n", $adminHosts) : '',
|
||||
'panel.agent_hosts' => is_array($agentHosts) ? implode("\n", $agentHosts) : '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -116,8 +130,11 @@ class SettingsService
|
||||
$d = trim((string) $d);
|
||||
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
|
||||
$d = rtrim($d, '/');
|
||||
// Host only (drop path / port).
|
||||
$d = explode('/', $d, 2)[0];
|
||||
$d = preg_replace('/:\d+$/', '', $d) ?? $d;
|
||||
|
||||
return $d;
|
||||
return strtolower($d);
|
||||
}, $parts)));
|
||||
}
|
||||
|
||||
|
||||
@@ -60,4 +60,39 @@ final class AgentScope
|
||||
|
||||
return $devicesQuery->whereIn('devices.channel_id', $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a query that has a bare channel_id column (e.g. page_visits).
|
||||
*/
|
||||
public static function applyChannelIdScope(Builder $query, ?User $agent, string $column = 'channel_id'): Builder
|
||||
{
|
||||
if ($agent === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$ids = self::channelIdsFor($agent);
|
||||
if ($ids === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn($column, $ids);
|
||||
}
|
||||
|
||||
public static function applyChannelIdAgentUserFilter(Builder $query, ?int $agentUserId, string $column = 'channel_id'): Builder
|
||||
{
|
||||
if ($agentUserId === null || $agentUserId <= 0) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$ids = Channel::query()
|
||||
->where('user_id', $agentUserId)
|
||||
->pluck('channel_id')
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn($column, $ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Lightweight UA → OS / browser fields for page-visit grouping.
|
||||
*/
|
||||
final class UserAgentParser
|
||||
{
|
||||
/**
|
||||
* @return array{os: string, os_version: string, browser: string, browser_version: string}
|
||||
*/
|
||||
public static function parse(?string $ua): array
|
||||
{
|
||||
$ua = trim((string) $ua);
|
||||
if ($ua === '') {
|
||||
return [
|
||||
'os' => 'Unknown',
|
||||
'os_version' => '',
|
||||
'browser' => 'Unknown',
|
||||
'browser_version' => '',
|
||||
];
|
||||
}
|
||||
|
||||
[$os, $osVersion] = self::parseOs($ua);
|
||||
[$browser, $browserVersion] = self::parseBrowser($ua);
|
||||
|
||||
return [
|
||||
'os' => $os,
|
||||
'os_version' => $osVersion,
|
||||
'browser' => $browser,
|
||||
'browser_version' => $browserVersion,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private static function parseOs(string $ua): array
|
||||
{
|
||||
if (preg_match('/iPhone OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)
|
||||
|| preg_match('/CPU OS (\d+)[._](\d+)(?:[._](\d+))?.*like Mac OS X/i', $ua, $m)) {
|
||||
$ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : '');
|
||||
|
||||
return ['iOS', $ver];
|
||||
}
|
||||
if (preg_match('/iPad.*OS (\d+)[._](\d+)/i', $ua, $m)) {
|
||||
return ['iPadOS', $m[1].'.'.$m[2]];
|
||||
}
|
||||
if (preg_match('/Android (\d+(?:\.\d+){0,2})/i', $ua, $m)) {
|
||||
return ['Android', $m[1]];
|
||||
}
|
||||
if (preg_match('/Windows NT (\d+\.\d+)/i', $ua, $m)) {
|
||||
$map = [
|
||||
'10.0' => '10/11',
|
||||
'6.3' => '8.1',
|
||||
'6.2' => '8',
|
||||
'6.1' => '7',
|
||||
];
|
||||
|
||||
return ['Windows', $map[$m[1]] ?? $m[1]];
|
||||
}
|
||||
if (preg_match('/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) {
|
||||
$ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : '');
|
||||
|
||||
return ['macOS', $ver];
|
||||
}
|
||||
if (preg_match('/Linux/i', $ua)) {
|
||||
return ['Linux', ''];
|
||||
}
|
||||
|
||||
return ['Unknown', ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private static function parseBrowser(string $ua): array
|
||||
{
|
||||
// Order matters: more specific tokens first (iOS Chrome/Firefox/Edge before Safari).
|
||||
$rules = [
|
||||
['CriOS', 'Chrome'],
|
||||
['Chrome', 'Chrome'],
|
||||
['EdgiOS', 'Edge'],
|
||||
['Edg/', 'Edge'],
|
||||
['FxiOS', 'Firefox'],
|
||||
['Firefox', 'Firefox'],
|
||||
['OPT/', 'Opera'],
|
||||
['OPiOS', 'Opera'],
|
||||
['Opera', 'Opera'],
|
||||
['SamsungBrowser', 'Samsung'],
|
||||
['UCBrowser', 'UC'],
|
||||
['Version/', 'Safari'], // Safari reports Version/x before Safari/
|
||||
['Safari', 'Safari'],
|
||||
];
|
||||
|
||||
foreach ($rules as [$token, $name]) {
|
||||
if ($token === 'Version/' && ! preg_match('/Safari/i', $ua)) {
|
||||
continue;
|
||||
}
|
||||
if ($token === 'Safari' && preg_match('/CriOS|FxiOS|EdgiOS|Chrome|Android/i', $ua)) {
|
||||
continue;
|
||||
}
|
||||
$pattern = $token === 'Version/' || str_ends_with($token, '/')
|
||||
? '/'.preg_quote(rtrim($token, '/'), '/').'\/(\d+(?:\.\d+){0,2})/i'
|
||||
: '/'.preg_quote($token, '/').'[\/\s]?(\d+(?:\.\d+){0,2})?/i';
|
||||
if (preg_match($pattern, $ua, $m)) {
|
||||
return [$name, $m[1] ?? ''];
|
||||
}
|
||||
}
|
||||
|
||||
return ['Unknown', ''];
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,25 @@ final class WalletSource
|
||||
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chains we persist from C2 ingest (skip UNKNOWN / niche networks).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SUPPORTED_CHAINS = [
|
||||
'ETH', 'ETHEREUM', 'EVM',
|
||||
'TRX', 'TRON',
|
||||
'BTC', 'BITCOIN',
|
||||
'SOL', 'SOLANA',
|
||||
'BNB', 'BSC', 'BINANCE',
|
||||
'TON',
|
||||
];
|
||||
|
||||
public static function isSupportedChain(string $chainType): bool
|
||||
{
|
||||
return in_array(strtoupper(trim($chainType)), self::SUPPORTED_CHAINS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer chain type from address when plugin omits chainType.
|
||||
*/
|
||||
@@ -50,7 +69,7 @@ final class WalletSource
|
||||
{
|
||||
$address = trim($address);
|
||||
if ($address === '') {
|
||||
return 'unknown';
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) {
|
||||
return 'ETHEREUM';
|
||||
@@ -73,7 +92,7 @@ final class WalletSource
|
||||
return 'TON';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user