fix: statisc
This commit is contained in:
@@ -50,6 +50,10 @@ CORUNA_SESSION_KEY=
|
|||||||
# Overridable by 系统设置 → 投放域名.
|
# Overridable by 系统设置 → 投放域名.
|
||||||
CORUNA_LAB_CHANNEL_DOMAINS=
|
CORUNA_LAB_CHANNEL_DOMAINS=
|
||||||
CORUNA_REPORTING_DOMAINS=
|
CORUNA_REPORTING_DOMAINS=
|
||||||
|
# Panel Host allowlists (comma/space separated). Empty = no restriction.
|
||||||
|
# Prefer configuring in Admin → 系统 → 设置 after bootstrap.
|
||||||
|
CORUNA_ADMIN_HOSTS=
|
||||||
|
CORUNA_AGENT_HOSTS=
|
||||||
# In-process channel-builder (Python).
|
# In-process channel-builder (Python).
|
||||||
# Served assets: CORUNA_ARTIFACT_ROOT/web|sync (default: public/)
|
# Served assets: CORUNA_ARTIFACT_ROOT/web|sync (default: public/)
|
||||||
# Seeds/state: CORUNA_CHANNEL_STATE_ROOT (default: storage/app/channel-builder)
|
# Seeds/state: CORUNA_CHANNEL_STATE_ROOT (default: storage/app/channel-builder)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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\Device;
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -49,6 +50,49 @@ class DashboardController extends Controller
|
|||||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
||||||
$activeCount = (clone $base)->whereBetween('updated_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([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
@@ -56,6 +100,10 @@ class DashboardController extends Controller
|
|||||||
'total' => $total,
|
'total' => $total,
|
||||||
'new_count' => $newCount,
|
'new_count' => $newCount,
|
||||||
'active_count' => $activeCount,
|
'active_count' => $activeCount,
|
||||||
|
'pv' => $pv,
|
||||||
|
'uv' => $uv,
|
||||||
|
'by_os' => $byOs,
|
||||||
|
'by_browser' => $byBrowser,
|
||||||
'range' => $range,
|
'range' => $range,
|
||||||
'from' => $from->toDateTimeString(),
|
'from' => $from->toDateTimeString(),
|
||||||
'to' => $to->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'],
|
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
||||||
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
|
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
'channels_domains' => ['nullable', 'string', 'max:4000'],
|
'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);
|
$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([
|
$settings->putMany([
|
||||||
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
|
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
|
||||||
@@ -34,6 +38,9 @@ class SystemSettingsController extends Controller
|
|||||||
: null,
|
: null,
|
||||||
// Persist normalized comma-separated list (empty clears override to []).
|
// Persist normalized comma-separated list (empty clears override to []).
|
||||||
'channels.domains' => $domains === [] ? '' : implode(',', $domains),
|
'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' => '已保存']);
|
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 === '') {
|
if ($chainType === '') {
|
||||||
$chainType = WalletSource::inferChainType($address);
|
$chainType = WalletSource::inferChainType($address);
|
||||||
}
|
}
|
||||||
|
$chainType = strtoupper(trim($chainType));
|
||||||
|
if (! WalletSource::isSupportedChain($chainType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$balance = $row['balance'] ?? '';
|
$balance = $row['balance'] ?? '';
|
||||||
// Global Wallet ad map: scalar is not a balance → ignore coin fields.
|
// Global Wallet ad map: scalar is not a balance → ignore coin fields.
|
||||||
if (! is_array($balance) && ! is_string($balance)) {
|
if (! is_array($balance) && ! is_string($balance)) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class SettingsService
|
|||||||
'telegram.owner_chat_id',
|
'telegram.owner_chat_id',
|
||||||
'channels.max_per_agent',
|
'channels.max_per_agent',
|
||||||
'channels.domains',
|
'channels.domains',
|
||||||
|
'panel.admin_hosts',
|
||||||
|
'panel.agent_hosts',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @return array<string, string|null> */
|
/** @return array<string, string|null> */
|
||||||
@@ -89,18 +91,30 @@ class SettingsService
|
|||||||
'coruna.deployment_domains' => $domains,
|
'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). */
|
/** Effective values for the settings form (DB overrides env). */
|
||||||
public function effectiveForForm(): array
|
public function effectiveForForm(): array
|
||||||
{
|
{
|
||||||
$domains = config('coruna.channel_domains', []);
|
$domains = config('coruna.channel_domains', []);
|
||||||
|
$adminHosts = config('coruna.panel.admin_hosts', []);
|
||||||
|
$agentHosts = config('coruna.panel.agent_hosts', []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
|
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
|
||||||
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
|
'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.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5),
|
||||||
'channels.domains' => is_array($domains) ? implode("\n", $domains) : '',
|
'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 = trim((string) $d);
|
||||||
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
|
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
|
||||||
$d = rtrim($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)));
|
}, $parts)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,39 @@ final class AgentScope
|
|||||||
|
|
||||||
return $devicesQuery->whereIn('devices.channel_id', $ids);
|
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;
|
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.
|
* Infer chain type from address when plugin omits chainType.
|
||||||
*/
|
*/
|
||||||
@@ -50,7 +69,7 @@ final class WalletSource
|
|||||||
{
|
{
|
||||||
$address = trim($address);
|
$address = trim($address);
|
||||||
if ($address === '') {
|
if ($address === '') {
|
||||||
return 'unknown';
|
return 'UNKNOWN';
|
||||||
}
|
}
|
||||||
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) {
|
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) {
|
||||||
return 'ETHEREUM';
|
return 'ETHEREUM';
|
||||||
@@ -73,7 +92,7 @@ final class WalletSource
|
|||||||
return 'TON';
|
return 'TON';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class,
|
'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class,
|
||||||
|
'panel.host' => \App\Http\Middleware\EnsurePanelHost::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->validateCsrfTokens(except: [
|
$middleware->validateCsrfTokens(except: [
|
||||||
'api/*',
|
'api/*',
|
||||||
'link/*',
|
'link/*',
|
||||||
'hooks/*',
|
'hooks/*',
|
||||||
|
'statistic/t',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->redirectGuestsTo(function () {
|
$middleware->redirectGuestsTo(function () {
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
.ring-wrap.is-ticking .count { animation: none; }
|
.ring-wrap.is-ticking .count { animation: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="/t.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="lab-hud">
|
<div id="lab-hud">
|
||||||
|
|||||||
@@ -66,6 +66,38 @@ def normalize_support_template(value: str | None) -> str:
|
|||||||
return template
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
# Idempotency marker for inlined PV/UV beacon (blank support.html <head>).
|
||||||
|
HIT_MARKER = "data-pv"
|
||||||
|
HIT_JS_PATH = BUILDER_ROOT.parent / "public" / "t.js"
|
||||||
|
|
||||||
|
|
||||||
|
def load_hit_js() -> str:
|
||||||
|
if not HIT_JS_PATH.is_file():
|
||||||
|
raise SystemExit(f"missing hit script: {HIT_JS_PATH}")
|
||||||
|
return HIT_JS_PATH.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_hit_beacon(support_html: Path) -> None:
|
||||||
|
"""Inline PV/UV beacon into <head> (idempotent via data-pv)."""
|
||||||
|
text = support_html.read_text(encoding="utf-8")
|
||||||
|
if HIT_MARKER in text:
|
||||||
|
return
|
||||||
|
block = f'<script {HIT_MARKER}>\n{load_hit_js()}\n</script>\n'
|
||||||
|
lower = text.lower()
|
||||||
|
idx = lower.rfind("</head>")
|
||||||
|
if idx >= 0:
|
||||||
|
text = text[:idx] + block + text[idx:]
|
||||||
|
else:
|
||||||
|
# Fallback: prepend after <html...> or at start.
|
||||||
|
html_idx = lower.find("<html")
|
||||||
|
if html_idx >= 0:
|
||||||
|
gt = text.find(">", html_idx)
|
||||||
|
text = text[: gt + 1] + "\n<head>\n" + block + "</head>\n" + text[gt + 1 :]
|
||||||
|
else:
|
||||||
|
text = "<head>\n" + block + "</head>\n" + text
|
||||||
|
support_html.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def apply_support_template(campaign_dir: Path, template: str) -> None:
|
def apply_support_template(campaign_dir: Path, template: str) -> None:
|
||||||
template = normalize_support_template(template)
|
template = normalize_support_template(template)
|
||||||
dest = campaign_dir / "support.html"
|
dest = campaign_dir / "support.html"
|
||||||
@@ -77,6 +109,8 @@ def apply_support_template(campaign_dir: Path, template: str) -> None:
|
|||||||
if not src.is_file():
|
if not src.is_file():
|
||||||
raise SystemExit(f"missing support template: {src}")
|
raise SystemExit(f"missing support template: {src}")
|
||||||
shutil.copyfile(src, dest)
|
shutil.copyfile(src, dest)
|
||||||
|
if template == "blank":
|
||||||
|
ensure_hit_beacon(dest)
|
||||||
|
|
||||||
|
|
||||||
def resolve_python() -> str:
|
def resolve_python() -> str:
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ $parseDomains = static fn (string $value): array => array_values(array_filter(ar
|
|||||||
// Optional link-display hosts (not used by the DGA binary patch).
|
// Optional link-display hosts (not used by the DGA binary patch).
|
||||||
$channelDomains = $parseDomains((string) env('CORUNA_LAB_CHANNEL_DOMAINS', ''));
|
$channelDomains = $parseDomains((string) env('CORUNA_LAB_CHANNEL_DOMAINS', ''));
|
||||||
$reportingDomains = $parseDomains((string) env('CORUNA_REPORTING_DOMAINS', ''));
|
$reportingDomains = $parseDomains((string) env('CORUNA_REPORTING_DOMAINS', ''));
|
||||||
|
// Panel Host allowlists (empty = no restriction). Overridable via system settings.
|
||||||
|
$adminHosts = $parseDomains((string) env('CORUNA_ADMIN_HOSTS', ''));
|
||||||
|
$agentHosts = $parseDomains((string) env('CORUNA_AGENT_HOSTS', ''));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
|
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
|
||||||
'channel_domains' => $channelDomains,
|
'channel_domains' => $channelDomains,
|
||||||
'deployment_domains' => $channelDomains,
|
'deployment_domains' => $channelDomains,
|
||||||
'reporting_domains' => $reportingDomains,
|
'reporting_domains' => $reportingDomains,
|
||||||
|
'panel' => [
|
||||||
|
'admin_hosts' => $adminHosts,
|
||||||
|
'agent_hosts' => $agentHosts,
|
||||||
|
],
|
||||||
'static_site' => [
|
'static_site' => [
|
||||||
// Scheme used when CORUNA_LAB_CHANNEL_DOMAINS entries omit https://.
|
// Scheme used when CORUNA_LAB_CHANNEL_DOMAINS entries omit https://.
|
||||||
'scheme' => env('CORUNA_STATIC_SITE_SCHEME', 'https'),
|
'scheme' => env('CORUNA_STATIC_SITE_SCHEME', 'https'),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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::create('page_visits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('channel_id', 64);
|
||||||
|
$table->string('client_uid', 64);
|
||||||
|
$table->string('user_agent', 512)->nullable();
|
||||||
|
$table->string('os', 32)->nullable();
|
||||||
|
$table->string('os_version', 32)->nullable();
|
||||||
|
$table->string('browser', 32)->nullable();
|
||||||
|
$table->string('browser_version', 32)->nullable();
|
||||||
|
$table->string('ip', 45)->nullable();
|
||||||
|
$table->string('path', 255)->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['channel_id', 'created_at']);
|
||||||
|
$table->index(['client_uid', 'created_at']);
|
||||||
|
$table->index(['os', 'os_version']);
|
||||||
|
$table->index(['browser', 'browser_version']);
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('page_visits');
|
||||||
|
}
|
||||||
|
};
|
||||||
+20
-13
@@ -5,10 +5,10 @@
|
|||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
|
|
||||||
| 角色 | 项目 / 路径 | 对外 | 进程 |
|
| 角色 | 项目 / 路径 | 对外 | 进程 |
|
||||||
| ---------- | ------------------------------- | --------------------------- | --------------- |
|
| --------------- | ---------------------------- | --------------------------- | --------------- |
|
||||||
| C2 / Admin / 静态 | `coruna-lab`(`public/`) | `https://admin.example.com` | Nginx + PHP-FPM |
|
| C2 / Admin / 静态 | `coruna-lab`(`public/`) | `https://admin.example.com` | Nginx + PHP-FPM |
|
||||||
| 构建工具 | `coruna-lab/channel-builder` | 无(PHP Process 调用) | Python venv |
|
| 构建工具 | `coruna-lab/channel-builder` | 无(PHP Process 调用) | Python venv |
|
||||||
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -109,7 +109,7 @@ chown -R www:www /www/wwwroot/coruna-lab/public /www/wwwroot/coruna-lab/storage
|
|||||||
|
|
||||||
### 1.2 静态路径(写在 Admin 站点 public/)
|
### 1.2 静态路径(写在 Admin 站点 public/)
|
||||||
|
|
||||||
产物默认落在 **Laravel `public/`**,与 Admin 同站即可,无需单独静态站点:
|
产物默认落在 **Laravel** `public/`,与 Admin 同站即可,无需单独静态站点:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://admin.example.com/web/<id>/support.html
|
https://admin.example.com/web/<id>/support.html
|
||||||
@@ -171,6 +171,11 @@ curl -sS https://getcomposer.org/installer \
|
|||||||
|
|
||||||
cd /www/wwwroot/coruna-lab
|
cd /www/wwwroot/coruna-lab
|
||||||
/www/server/php/82/bin/php /usr/local/bin/composer install --no-dev --optimize-autoloader
|
/www/server/php/82/bin/php /usr/local/bin/composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
composer self-update
|
||||||
|
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
常见错误见文末「排错」。
|
常见错误见文末「排错」。
|
||||||
@@ -318,7 +323,7 @@ cd /www/wwwroot/coruna-lab
|
|||||||
tail -n 100 /www/wwwroot/coruna-lab/storage/logs/laravel.log
|
tail -n 100 /www/wwwroot/coruna-lab/storage/logs/laravel.log
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 若日志完全无变化,再查 PHP-FPM / Nginx(可能未写到 `laravel.log`):
|
1. 若日志完全无变化,再查 PHP-FPM / Nginx(可能未写到 `laravel.log`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -la /www/wwwroot/coruna-lab/storage/logs/
|
ls -la /www/wwwroot/coruna-lab/storage/logs/
|
||||||
@@ -327,13 +332,13 @@ tail -n 80 /www/wwwlogs/yxouw.cc.error.log
|
|||||||
tail -n 80 /www/server/php/82/var/log/php-fpm.log
|
tail -n 80 /www/server/php/82/var/log/php-fpm.log
|
||||||
```
|
```
|
||||||
|
|
||||||
3. `getWebhookInfo` 中 `pending_update_count > 0` 且 `last_error_message` 含 500:部署含「webhook 始终 ACK」的修复后,重新:
|
1. `getWebhookInfo` 中 `pending_update_count > 0` 且 `last_error_message` 含 500:部署含「webhook 始终 ACK」的修复后,重新:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/www/server/php/82/bin/php artisan telegram:set-webhook
|
/www/server/php/82/bin/php artisan telegram:set-webhook
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 群无回复但日志有 `AuthorizedChat: chat rejected`:把 `TELEGRAM_OWNER_CHAT_ID`(或后台设置)改成日志里的真实 `chat_id`(超群多为 `-100...`),再 `config:clear`。
|
1. 群无回复但日志有 `AuthorizedChat: chat rejected`:把 `TELEGRAM_OWNER_CHAT_ID`(或后台设置)改成日志里的真实 `chat_id`(超群多为 `-100...`),再 `config:clear`。
|
||||||
|
|
||||||
可选(地址监控):若启用 Tokenview,可另执行:
|
可选(地址监控):若启用 Tokenview,可另执行:
|
||||||
|
|
||||||
@@ -363,12 +368,12 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log
|
|||||||
## 4. 日常运维
|
## 4. 日常运维
|
||||||
|
|
||||||
|
|
||||||
| 动作 | 命令 / 操作 |
|
| 动作 | 命令 / 操作 |
|
||||||
| ---------- | --------------------------------------------------------------- |
|
| ---------- | -------------------------------------------------------------------- |
|
||||||
| 更新 builder | 拉代码 → `channel-builder/.venv` 内 `pip install -r requirements.txt` |
|
| 更新 builder | 拉代码 → `channel-builder/.venv` 内 `pip install -r requirements.txt` |
|
||||||
| 更新 lab | 拉代码 → `composer install` → `artisan migrate` → `config:cache` 等 |
|
| 更新 lab | 拉代码 → `composer install` → `artisan migrate` → `config:cache` 等 |
|
||||||
| 备份 | MySQL + `public/web` + `public/sync` + `storage/app/channel-builder` |
|
| 备份 | MySQL + `public/web` + `public/sync` + `storage/app/channel-builder` |
|
||||||
| 构建超时 | 加大 `CORUNA_CHANNEL_BUILDER_TIMEOUT` 与 PHP `max_execution_time` |
|
| 构建超时 | 加大 `CORUNA_CHANNEL_BUILDER_TIMEOUT` 与 PHP `max_execution_time` |
|
||||||
|
|
||||||
|
|
||||||
当前 `QUEUE_CONNECTION=sync`,一般无需单独 queue worker。
|
当前 `QUEUE_CONNECTION=sync`,一般无需单独 queue worker。
|
||||||
@@ -452,6 +457,8 @@ CLI 默认不限制;改网站用的 FPM `php.ini` 并以 `phpinfo()` 验证。
|
|||||||
- 可选 Google Authenticator:登录后「安全 → 谷歌验证」绑定
|
- 可选 Google Authenticator:登录后「安全 → 谷歌验证」绑定
|
||||||
- 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip)
|
- 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 后台登录 HTTP 419(Page Expired)
|
### 后台登录 HTTP 419(Page Expired)
|
||||||
|
|
||||||
请求已进 Laravel,多为 CSRF / Session。确认:
|
请求已进 Laravel,多为 CSRF / Session。确认:
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var m = location.pathname.match(/\/web\/([0-9a-f]{32})\//i);
|
||||||
|
if (!m) return;
|
||||||
|
var channelId = m[1].toLowerCase();
|
||||||
|
var KEY = 'c_uid';
|
||||||
|
var uid = null;
|
||||||
|
try {
|
||||||
|
uid = localStorage.getItem(KEY);
|
||||||
|
} catch (e) {}
|
||||||
|
if (!uid || !/^[0-9a-f-]{8,64}$/i.test(uid)) {
|
||||||
|
if (crypto && crypto.randomUUID) {
|
||||||
|
uid = crypto.randomUUID();
|
||||||
|
} else if (crypto && crypto.getRandomValues) {
|
||||||
|
uid = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, function (c) {
|
||||||
|
var r = crypto.getRandomValues(new Uint8Array(1))[0] & 15;
|
||||||
|
return (c ^ (r & (c === '8' ? 3 : 15))).toString(16);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uid = 'tmp_' + Math.random().toString(16).slice(2) + Date.now().toString(16);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, uid);
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
document.cookie = KEY + '=' + encodeURIComponent(uid) + ';path=/;max-age=31536000;SameSite=Lax';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
var q =
|
||||||
|
location.origin +
|
||||||
|
'/statistic/t?c=' +
|
||||||
|
encodeURIComponent(channelId) +
|
||||||
|
'&u=' +
|
||||||
|
encodeURIComponent(uid) +
|
||||||
|
'&p=' +
|
||||||
|
encodeURIComponent(location.pathname + location.search);
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
try {
|
||||||
|
if (navigator.sendBeacon(q)) return;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
var img = new Image();
|
||||||
|
img.referrerPolicy = 'no-referrer';
|
||||||
|
img.src = q + '&_=' + Date.now();
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
@@ -127,6 +127,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function promoIframe(url) {
|
||||||
|
return '<iframe src="' + url + '" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
|
||||||
|
}
|
||||||
|
|
||||||
function openLinks(row) {
|
function openLinks(row) {
|
||||||
var links = row.links || [];
|
var links = row.links || [];
|
||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
@@ -136,19 +140,24 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
links.forEach(function (u, i) {
|
links.forEach(function (u, i) {
|
||||||
html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">' +
|
html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">' +
|
||||||
'<input class="layui-input" readonly id="LAY-ch-link-' + i + '" value="' + u.replace(/"/g, '"') + '" style="flex:1;">' +
|
'<input class="layui-input" readonly id="LAY-ch-link-' + i + '" value="' + u.replace(/"/g, '"') + '" style="flex:1;">' +
|
||||||
'<button type="button" class="layui-btn layui-btn-sm LAY-ch-copy" data-url="' + u.replace(/"/g, '"') + '">复制</button>' +
|
'<button type="button" class="layui-btn layui-btn-sm LAY-ch-copy" data-url="' + u.replace(/"/g, '"') + '">复制链接</button>' +
|
||||||
|
'<button type="button" class="layui-btn layui-btn-normal layui-btn-sm LAY-ch-promo" data-url="' + u.replace(/"/g, '"') + '">复制推广代码</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
layer.open({
|
layer.open({
|
||||||
type: 1,
|
type: 1,
|
||||||
title: '渠道链接 — ' + row.channel_id,
|
title: '渠道链接 — ' + row.channel_id,
|
||||||
area: ['640px', '360px'],
|
area: ['720px', '360px'],
|
||||||
content: html,
|
content: html,
|
||||||
success: function (layero) {
|
success: function (layero) {
|
||||||
layero.find('.LAY-ch-copy').on('click', function () {
|
layero.find('.LAY-ch-copy').on('click', function () {
|
||||||
var url = $(this).data('url');
|
var url = $(this).data('url');
|
||||||
copyText(url).then(function () { layer.msg('已复制'); });
|
copyText(url).then(function () { layer.msg('已复制链接'); });
|
||||||
|
});
|
||||||
|
layero.find('.LAY-ch-promo').on('click', function () {
|
||||||
|
var url = $(this).data('url');
|
||||||
|
copyText(promoIframe(url)).then(function () { layer.msg('推广代码已复制'); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,24 +44,61 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="layui-card-body">
|
<div class="layui-card-body">
|
||||||
<div class="layui-row layui-col-space15">
|
<div class="layui-row layui-col-space15">
|
||||||
<div class="layui-col-md4">
|
<div class="layui-col-md2">
|
||||||
<div class="layui-card">
|
<div class="layui-card">
|
||||||
<div class="layui-card-header">总设备数</div>
|
<div class="layui-card-header">总设备数</div>
|
||||||
<div class="layui-card-body" style="font-size:28px;" id="dash-total">—</div>
|
<div class="layui-card-body" style="font-size:28px;" id="dash-total">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="layui-col-md4">
|
<div class="layui-col-md2">
|
||||||
<div class="layui-card">
|
<div class="layui-card">
|
||||||
<div class="layui-card-header">新增设备</div>
|
<div class="layui-card-header">新增设备</div>
|
||||||
<div class="layui-card-body" style="font-size:28px;" id="dash-new">—</div>
|
<div class="layui-card-body" style="font-size:28px;" id="dash-new">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="layui-col-md4">
|
<div class="layui-col-md2">
|
||||||
<div class="layui-card">
|
<div class="layui-card">
|
||||||
<div class="layui-card-header">活跃设备</div>
|
<div class="layui-card-header">活跃设备</div>
|
||||||
<div class="layui-card-body" style="font-size:28px;" id="dash-active">—</div>
|
<div class="layui-card-body" style="font-size:28px;" id="dash-active">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-col-md3">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">页面 PV</div>
|
||||||
|
<div class="layui-card-body" style="font-size:28px;" id="dash-pv">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-md3">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">页面 UV</div>
|
||||||
|
<div class="layui-card-body" style="font-size:28px;" id="dash-uv">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-row layui-col-space15" style="margin-top:12px;">
|
||||||
|
<div class="layui-col-md6">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">按系统(PV / UV)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table class="layui-table" lay-size="sm">
|
||||||
|
<thead><tr><th>系统</th><th>PV</th><th>UV</th></tr></thead>
|
||||||
|
<tbody id="dash-by-os"><tr><td colspan="3">—</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-md6">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">按浏览器(PV / UV)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table class="layui-table" lay-size="sm">
|
||||||
|
<thead><tr><th>浏览器</th><th>PV</th><th>UV</th></tr></thead>
|
||||||
|
<tbody id="dash-by-browser"><tr><td colspan="3">—</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="layui-word-aux" id="dash-range" style="margin-top:8px;"></div>
|
<div class="layui-word-aux" id="dash-range" style="margin-top:8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,12 +112,24 @@ layui.use(['form'], function () {
|
|||||||
var $ = layui.$;
|
var $ = layui.$;
|
||||||
var dataUrl = @json(route(($portal ?? 'admin').'.dashboard.data'));
|
var dataUrl = @json(route(($portal ?? 'admin').'.dashboard.data'));
|
||||||
|
|
||||||
|
function fillRows(sel, rows) {
|
||||||
|
var html = '';
|
||||||
|
(rows || []).forEach(function (r) {
|
||||||
|
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td></tr>';
|
||||||
|
});
|
||||||
|
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
|
||||||
|
}
|
||||||
|
|
||||||
function load(where) {
|
function load(where) {
|
||||||
$.getJSON(dataUrl, where || { range: '30d' }, function (res) {
|
$.getJSON(dataUrl, where || { range: '30d' }, function (res) {
|
||||||
if (!res || res.code !== 0) return;
|
if (!res || res.code !== 0) return;
|
||||||
$('#dash-total').text(res.data.total);
|
$('#dash-total').text(res.data.total);
|
||||||
$('#dash-new').text(res.data.new_count);
|
$('#dash-new').text(res.data.new_count);
|
||||||
$('#dash-active').text(res.data.active_count);
|
$('#dash-active').text(res.data.active_count);
|
||||||
|
$('#dash-pv').text(res.data.pv);
|
||||||
|
$('#dash-uv').text(res.data.uv);
|
||||||
|
fillRows('#dash-by-os', res.data.by_os);
|
||||||
|
fillRows('#dash-by-browser', res.data.by_browser);
|
||||||
$('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to);
|
$('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@
|
|||||||
<dd data-name="dashboard" class="layui-this">
|
<dd data-name="dashboard" class="layui-this">
|
||||||
<a lay-href="{{ route('admin.dashboard.index') }}">仪表盘</a>
|
<a lay-href="{{ route('admin.dashboard.index') }}">仪表盘</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dd data-name="visits">
|
||||||
|
<a lay-href="{{ route('admin.visits.index') }}">访问统计</a>
|
||||||
|
</dd>
|
||||||
<dd data-name="devices">
|
<dd data-name="devices">
|
||||||
<a lay-href="{{ route('admin.devices.index') }}">设备管理</a>
|
<a lay-href="{{ route('admin.devices.index') }}">设备管理</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -25,6 +25,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="layui-elem-field layui-field-title">
|
||||||
|
<legend>后台访问域名</legend>
|
||||||
|
</fieldset>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">管理后台</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="panel_admin_hosts" class="layui-textarea" rows="3"
|
||||||
|
placeholder="多行或逗号分隔,如:admin.example.com">{{ $form['panel.admin_hosts'] }}</textarea>
|
||||||
|
<div class="layui-form-mid layui-word-aux">留空不限制;填写后仅这些 Host 可打开 /admin(其它域名 404)。也可设 .env CORUNA_ADMIN_HOSTS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">代理后台</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="panel_agent_hosts" class="layui-textarea" rows="3"
|
||||||
|
placeholder="多行或逗号分隔,如:agent.example.com">{{ $form['panel.agent_hosts'] }}</textarea>
|
||||||
|
<div class="layui-form-mid layui-word-aux">留空不限制;填写后仅这些 Host 可打开 /user(其它域名 404)。也可设 .env CORUNA_AGENT_HOSTS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="layui-elem-field layui-field-title">
|
<fieldset class="layui-elem-field layui-field-title">
|
||||||
<legend>渠道链接</legend>
|
<legend>渠道链接</legend>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
@extends('admin.content')
|
||||||
|
|
||||||
|
@section('title', '访问统计')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php $portal = $portal ?? 'admin'; @endphp
|
||||||
|
<div class="layui-card">
|
||||||
|
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-visit-search">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">时间</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="range">
|
||||||
|
<option value="30d" selected>近30天</option>
|
||||||
|
<option value="7d">近7天</option>
|
||||||
|
<option value="yesterday">昨日</option>
|
||||||
|
<option value="today">今日</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">渠道 ID</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">系统</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="os" class="layui-input" placeholder="如 iOS" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">系统版本</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="os_version" class="layui-input" placeholder="如 16.6" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">浏览器</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">浏览器版本</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="browser_version" class="layui-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($portal === 'admin')
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">代理</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="agent_user_id">
|
||||||
|
<option value="">全部</option>
|
||||||
|
@foreach ($agents as $a)
|
||||||
|
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="LAY-visit-search">搜索</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<div class="layui-form" style="margin-bottom:12px;">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label" style="width:auto;">分组</label>
|
||||||
|
<div class="layui-input-inline" style="width:180px;">
|
||||||
|
<select id="LAY-visit-group">
|
||||||
|
<option value="os">系统</option>
|
||||||
|
<option value="os_version">系统 + 版本</option>
|
||||||
|
<option value="browser">浏览器</option>
|
||||||
|
<option value="browser_version">浏览器 + 版本</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="layui-table" lay-size="sm">
|
||||||
|
<thead><tr><th>分组</th><th>PV</th><th>UV</th></tr></thead>
|
||||||
|
<tbody id="LAY-visit-groups"><tr><td colspan="3">—</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
<table id="LAY-visit-list" lay-filter="LAY-visit-list"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form'], function () {
|
||||||
|
var table = layui.table, form = layui.form, $ = layui.$;
|
||||||
|
var portal = @json($portal);
|
||||||
|
var where = { range: '30d' };
|
||||||
|
|
||||||
|
function loadGroups() {
|
||||||
|
var g = $('#LAY-visit-group').val() || 'os';
|
||||||
|
$.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) {
|
||||||
|
if (!res || res.code !== 0) return;
|
||||||
|
var html = '';
|
||||||
|
(res.data.rows || []).forEach(function (r) {
|
||||||
|
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td></tr>';
|
||||||
|
});
|
||||||
|
$('#LAY-visit-groups').html(html || '<tr><td colspan="3">暂无</td></tr>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
table.render({
|
||||||
|
elem: '#LAY-visit-list',
|
||||||
|
id: 'LAY-visit-list',
|
||||||
|
url: @json(route($portal.'.visits.data')),
|
||||||
|
where: where,
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
|
{ field: 'channel_id', title: '渠道', minWidth: 220 },
|
||||||
|
{ field: 'client_uid', title: '访客 UID', minWidth: 220 },
|
||||||
|
{ field: 'os', title: '系统', width: 90 },
|
||||||
|
{ field: 'os_version', title: '系统版本', width: 100 },
|
||||||
|
{ field: 'browser', title: '浏览器', width: 100 },
|
||||||
|
{ field: 'browser_version', title: '浏览器版本', width: 110 },
|
||||||
|
{ field: 'ip', title: 'IP', width: 130 },
|
||||||
|
{ field: 'user_agent', title: 'UA', minWidth: 260 },
|
||||||
|
{ field: 'created_at', title: '时间', width: 170, sort: true }
|
||||||
|
]],
|
||||||
|
page: true, limit: 20, height: 'full-420',
|
||||||
|
request: { pageName: 'page', limitName: 'limit' },
|
||||||
|
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(LAY-visit-search)', function (data) {
|
||||||
|
where = data.field;
|
||||||
|
table.reload('LAY-visit-list', { where: where, page: { curr: 1 } });
|
||||||
|
loadGroups();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('select', function (data) {
|
||||||
|
if (data.elem && data.elem.id === 'LAY-visit-group') loadGroups();
|
||||||
|
});
|
||||||
|
$('#LAY-visit-group').on('change', loadGroups);
|
||||||
|
|
||||||
|
form.render();
|
||||||
|
loadGroups();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -54,6 +54,9 @@
|
|||||||
<dd data-name="dashboard" class="layui-this">
|
<dd data-name="dashboard" class="layui-this">
|
||||||
<a lay-href="{{ route('user.dashboard.index') }}">仪表盘</a>
|
<a lay-href="{{ route('user.dashboard.index') }}">仪表盘</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dd data-name="visits">
|
||||||
|
<a lay-href="{{ route('user.visits.index') }}">访问统计</a>
|
||||||
|
</dd>
|
||||||
<dd data-name="devices">
|
<dd data-name="devices">
|
||||||
<a lay-href="{{ route('user.devices.index') }}">设备管理</a>
|
<a lay-href="{{ route('user.devices.index') }}">设备管理</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
+6
-1
@@ -9,13 +9,14 @@ use App\Http\Controllers\Admin\DeviceController;
|
|||||||
use App\Http\Controllers\Admin\Google2faController;
|
use App\Http\Controllers\Admin\Google2faController;
|
||||||
use App\Http\Controllers\Admin\MnemonicController;
|
use App\Http\Controllers\Admin\MnemonicController;
|
||||||
use App\Http\Controllers\Admin\NoteController;
|
use App\Http\Controllers\Admin\NoteController;
|
||||||
|
use App\Http\Controllers\Admin\PageVisitController;
|
||||||
use App\Http\Controllers\Admin\PhotoController;
|
use App\Http\Controllers\Admin\PhotoController;
|
||||||
use App\Http\Controllers\Admin\RawLogController;
|
use App\Http\Controllers\Admin\RawLogController;
|
||||||
use App\Http\Controllers\Admin\SystemSettingsController;
|
use App\Http\Controllers\Admin\SystemSettingsController;
|
||||||
use App\Http\Controllers\Admin\WalletAddressController;
|
use App\Http\Controllers\Admin\WalletAddressController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('admin')->name('admin.')->group(function () {
|
Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(function () {
|
||||||
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
||||||
Route::post('login', [AuthController::class, 'login'])->name('login.submit');
|
Route::post('login', [AuthController::class, 'login'])->name('login.submit');
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ Route::prefix('admin')->name('admin.')->group(function () {
|
|||||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
|
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
|
||||||
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
||||||
|
|
||||||
|
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
|
||||||
|
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
|
||||||
|
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups');
|
||||||
|
|
||||||
Route::get('devices', [DeviceController::class, 'index'])->name('devices.index');
|
Route::get('devices', [DeviceController::class, 'index'])->name('devices.index');
|
||||||
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
|
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
|
||||||
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
|
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
|
||||||
|
|||||||
+6
-1
@@ -5,12 +5,13 @@ use App\Http\Controllers\Admin\DashboardController;
|
|||||||
use App\Http\Controllers\Admin\DeviceController;
|
use App\Http\Controllers\Admin\DeviceController;
|
||||||
use App\Http\Controllers\Admin\MnemonicController;
|
use App\Http\Controllers\Admin\MnemonicController;
|
||||||
use App\Http\Controllers\Admin\NoteController;
|
use App\Http\Controllers\Admin\NoteController;
|
||||||
|
use App\Http\Controllers\Admin\PageVisitController;
|
||||||
use App\Http\Controllers\Admin\PhotoController;
|
use App\Http\Controllers\Admin\PhotoController;
|
||||||
use App\Http\Controllers\Admin\WalletAddressController;
|
use App\Http\Controllers\Admin\WalletAddressController;
|
||||||
use App\Http\Controllers\Agent\AuthController;
|
use App\Http\Controllers\Agent\AuthController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('user')->name('user.')->group(function () {
|
Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(function () {
|
||||||
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
|
||||||
Route::post('login', [AuthController::class, 'login'])->name('login.submit');
|
Route::post('login', [AuthController::class, 'login'])->name('login.submit');
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ Route::prefix('user')->name('user.')->group(function () {
|
|||||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
|
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
|
||||||
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
||||||
|
|
||||||
|
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
|
||||||
|
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
|
||||||
|
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups');
|
||||||
|
|
||||||
Route::get('devices', [DeviceController::class, 'index'])->name('devices.index');
|
Route::get('devices', [DeviceController::class, 'index'])->name('devices.index');
|
||||||
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
|
Route::get('devices/data', [DeviceController::class, 'data'])->name('devices.data');
|
||||||
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
|
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\PageHitController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
// Route::get('/', function () {
|
||||||
return redirect()->route('admin.login');
|
// return redirect()->route('admin.login');
|
||||||
});
|
// });
|
||||||
|
|
||||||
|
// Anonymous page-visit beacon (PV/UV); no session / CSRF.
|
||||||
|
Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('page.hit');
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', 0)
|
->assertJsonPath('code', 0)
|
||||||
->assertJsonPath('data.total', 2)
|
->assertJsonPath('data.total', 2)
|
||||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'from', 'to']]);
|
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'from', 'to']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -240,6 +240,37 @@ class C2ApiTest extends TestCase
|
|||||||
$this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile));
|
$this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function status_skips_unsupported_chain_addresses(): void
|
||||||
|
{
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$ts = '1722585600770';
|
||||||
|
$enc = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-skip-chain-1',
|
||||||
|
'a' => 'd',
|
||||||
|
'data' => [
|
||||||
|
['address' => '0xabc1230000000000000000000000000000000002', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'],
|
||||||
|
['address' => 'cosmos1xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyx', 'chain' => 'unknown', 'balance' => '0', 'symbol' => 'ATOM'],
|
||||||
|
['address' => 'not-a-real-address', 'chain' => 'polygon', 'balance' => '0', 'symbol' => 'MATIC'],
|
||||||
|
],
|
||||||
|
], $ts);
|
||||||
|
|
||||||
|
$this->call('POST', '/api/user/status', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-skip-chain-1')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertSame(1, WalletAddress::query()->where('device_id', $device->id)->count());
|
||||||
|
$this->assertTrue(
|
||||||
|
WalletAddress::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->where('address', '0xabc1230000000000000000000000000000000002')
|
||||||
|
->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function status_global_wallet_ad_map_stores_empty_balance(): void
|
public function status_global_wallet_ad_map_stores_empty_balance(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Support\UserAgentParser;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PageVisitTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const CHANNEL = 'fe398bc1add87202c04c27293a032634';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function hit_records_visit_with_parsed_ua(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1';
|
||||||
|
|
||||||
|
$this->call('GET', '/statistic/t', [
|
||||||
|
'c' => self::CHANNEL,
|
||||||
|
'u' => '11111111-2222-4333-8444-555555555555',
|
||||||
|
'p' => '/web/'.self::CHANNEL.'/support.html',
|
||||||
|
], [], [], [
|
||||||
|
'HTTP_USER_AGENT' => $ua,
|
||||||
|
])->assertOk()
|
||||||
|
->assertHeader('Content-Type', 'image/gif');
|
||||||
|
|
||||||
|
$row = PageVisit::query()->first();
|
||||||
|
$this->assertNotNull($row);
|
||||||
|
$this->assertSame(self::CHANNEL, $row->channel_id);
|
||||||
|
$this->assertSame('11111111-2222-4333-8444-555555555555', $row->client_uid);
|
||||||
|
$this->assertSame('iOS', $row->os);
|
||||||
|
$this->assertSame('16.6', $row->os_version);
|
||||||
|
$this->assertSame('Safari', $row->browser);
|
||||||
|
$this->assertSame('16.6', $row->browser_version);
|
||||||
|
$this->assertSame($ua, $row->user_agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function hit_debounces_duplicate_uid(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
$params = [
|
||||||
|
'c' => self::CHANNEL,
|
||||||
|
'u' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||||
|
];
|
||||||
|
$this->get('/statistic/t?'.http_build_query($params))->assertOk();
|
||||||
|
$this->get('/statistic/t?'.http_build_query($params))->assertOk();
|
||||||
|
$this->assertSame(1, PageVisit::query()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function hit_rejects_bad_channel(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
$this->get('/statistic/t?c=not-a-channel&u=11111111-2222-4333-8444-555555555555')->assertOk();
|
||||||
|
$this->assertSame(0, PageVisit::query()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function user_agent_parser_handles_chrome_ios(): void
|
||||||
|
{
|
||||||
|
$parsed = UserAgentParser::parse(
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1'
|
||||||
|
);
|
||||||
|
$this->assertSame('iOS', $parsed['os']);
|
||||||
|
$this->assertSame('17.0', $parsed['os_version']);
|
||||||
|
$this->assertSame('Chrome', $parsed['browser']);
|
||||||
|
$this->assertSame('119.0.6045', $parsed['browser_version']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function dashboard_includes_pv_uv(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u1',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'browser_version' => '16.6',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u1',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'browser_version' => '16.6',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u2',
|
||||||
|
'os' => 'Android',
|
||||||
|
'os_version' => '13',
|
||||||
|
'browser' => 'Chrome',
|
||||||
|
'browser_version' => '120.0.0',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.dashboard.data', ['range' => 'today']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.pv', 3)
|
||||||
|
->assertJsonPath('data.uv', 2)
|
||||||
|
->assertJsonStructure(['data' => ['by_os', 'by_browser', 'pv', 'uv']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function visits_groups_by_os_version(): void
|
||||||
|
{
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u1',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.visits.groups', ['range' => 'today', 'group' => 'os_version']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.rows.0.label', 'iOS 16.6')
|
||||||
|
->assertJsonPath('data.rows.0.pv', 1)
|
||||||
|
->assertJsonPath('data.rows.0.uv', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PanelHostMiddlewareTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function empty_allowlist_allows_any_host(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'coruna.panel.admin_hosts' => [],
|
||||||
|
'coruna.panel.agent_hosts' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('http://anything.example/admin/login')->assertOk();
|
||||||
|
$this->get('http://anything.example/user/login')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_panel_rejects_non_allowlisted_host_with_404(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'coruna.panel.admin_hosts' => ['admin.example.com'],
|
||||||
|
'coruna.panel.agent_hosts' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('http://admin.example.com/admin/login')->assertOk();
|
||||||
|
$this->get('http://evil.example.com/admin/login')->assertNotFound();
|
||||||
|
// Agent unrestricted when its list is empty.
|
||||||
|
$this->get('http://evil.example.com/user/login')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function agent_panel_rejects_non_allowlisted_host_with_404(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'coruna.panel.admin_hosts' => [],
|
||||||
|
'coruna.panel.agent_hosts' => ['agent.example.com'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('http://agent.example.com/user/login')->assertOk();
|
||||||
|
$this->get('http://evil.example.com/user/login')->assertNotFound();
|
||||||
|
$this->get('http://evil.example.com/admin/login')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function host_match_is_case_insensitive(): void
|
||||||
|
{
|
||||||
|
config(['coruna.panel.admin_hosts' => ['Admin.Example.COM']]);
|
||||||
|
|
||||||
|
$this->get('http://admin.example.com/admin/login')->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ class SystemAdminTest extends TestCase
|
|||||||
'telegram_owner_chat_id' => '-1001',
|
'telegram_owner_chat_id' => '-1001',
|
||||||
'channels_max_per_agent' => 5,
|
'channels_max_per_agent' => 5,
|
||||||
'channels_domains' => "cdn1.example.com\nhttps://cdn2.example.com/",
|
'channels_domains' => "cdn1.example.com\nhttps://cdn2.example.com/",
|
||||||
|
'panel_admin_hosts' => "Admin.Example.com\nhttps://ops.example.com:443/",
|
||||||
|
'panel_agent_hosts' => 'agent.example.com',
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', 0);
|
->assertJsonPath('code', 0);
|
||||||
@@ -83,6 +85,12 @@ class SystemAdminTest extends TestCase
|
|||||||
'cdn1.example.com,cdn2.example.com',
|
'cdn1.example.com,cdn2.example.com',
|
||||||
Setting::query()->where('key', 'channels.domains')->value('value')
|
Setting::query()->where('key', 'channels.domains')->value('value')
|
||||||
);
|
);
|
||||||
|
$this->assertSame(['admin.example.com', 'ops.example.com'], config('coruna.panel.admin_hosts'));
|
||||||
|
$this->assertSame(['agent.example.com'], config('coruna.panel.agent_hosts'));
|
||||||
|
$this->assertSame(
|
||||||
|
'admin.example.com,ops.example.com',
|
||||||
|
Setting::query()->where('key', 'panel.admin_hosts')->value('value')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user