feat: stats
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\DailyReportService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class DailyStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:daily-stats
|
||||||
|
{--days=30 : Inclusive local days ending today (ignored when --all or --from is set)}
|
||||||
|
{--from= : Start date Y-m-d}
|
||||||
|
{--to= : End date Y-m-d (default today)}
|
||||||
|
{--all : From the earliest visit/device through --to}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild daily_stats rollup (UV / devices / addresses / mnemonics)';
|
||||||
|
|
||||||
|
public function handle(DailyReportService $reports): int
|
||||||
|
{
|
||||||
|
$to = $this->parseDay((string) $this->option('to')) ?? Carbon::now()->endOfDay();
|
||||||
|
$from = $this->resolveFrom($reports, $to);
|
||||||
|
if ($from->gt($to)) {
|
||||||
|
[$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $reports->rebuildAll($from, $to);
|
||||||
|
$this->info(sprintf(
|
||||||
|
'daily-stats rebuilt=%d agents=%d from=%s to=%s',
|
||||||
|
$result['days'],
|
||||||
|
$result['agents'],
|
||||||
|
$from->toDateString(),
|
||||||
|
$to->toDateString(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFrom(DailyReportService $reports, Carbon $to): Carbon
|
||||||
|
{
|
||||||
|
if ((bool) $this->option('all')) {
|
||||||
|
return $reports->earliestDataDate() ?? $to->copy()->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->parseDay((string) $this->option('from'));
|
||||||
|
if ($from !== null) {
|
||||||
|
return $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = max(1, (int) $this->option('days'));
|
||||||
|
|
||||||
|
return $to->copy()->subDays($days - 1)->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDay(string $raw): ?Carbon
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Carbon::parse($raw)->startOfDay();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->warn('ignored invalid date: '.$raw);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\DailyReportService;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DailyReportController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly DailyReportService $reports,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->reports->window();
|
||||||
|
|
||||||
|
return view('admin.reports.daily', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
'defaultFrom' => $from->toDateString(),
|
||||||
|
'defaultTo' => $to->toDateString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => trim((string) $request->query('date_from', '')),
|
||||||
|
'date_to' => trim((string) $request->query('date_to', '')),
|
||||||
|
'date_range' => trim((string) $request->query('date_range', '')),
|
||||||
|
'agent_user_id' => $this->isAgentPortal()
|
||||||
|
? null
|
||||||
|
: AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
||||||
|
];
|
||||||
|
[$from, $to] = $this->reports->resolveBounds($filters);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => [
|
||||||
|
'rows' => $this->reports->rows($this->agent(), $filters),
|
||||||
|
'from' => $from->toDateString(),
|
||||||
|
'to' => $to->toDateString(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,12 +41,15 @@ class WalletAddressController extends Controller
|
|||||||
{
|
{
|
||||||
$q = $this->baseQuery($request);
|
$q = $this->baseQuery($request);
|
||||||
|
|
||||||
$sortable = ['id', 'address', 'chain_type', 'source', 'mnemonic_id', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
|
$sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
|
||||||
$field = (string) $request->query('field', 'id');
|
$field = (string) $request->query('field', 'id');
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
$field = 'id';
|
$field = 'id';
|
||||||
}
|
}
|
||||||
|
if ($field === 'collectable') {
|
||||||
|
$field = 'mnemonic_id';
|
||||||
|
}
|
||||||
$q->orderBy('wallet_addresses.'.$field, $order);
|
$q->orderBy('wallet_addresses.'.$field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
@@ -65,7 +68,7 @@ class WalletAddressController extends Controller
|
|||||||
'source' => $row->source ?: '',
|
'source' => $row->source ?: '',
|
||||||
'chain_type' => $row->chain_type ?: '',
|
'chain_type' => $row->chain_type ?: '',
|
||||||
'address' => $row->address,
|
'address' => $row->address,
|
||||||
'mnemonic_id' => $row->mnemonic_id,
|
'collectable' => $row->mnemonic_id !== null,
|
||||||
'usdt' => $coins['usdt'],
|
'usdt' => $coins['usdt'],
|
||||||
'trx' => $coins['trx'],
|
'trx' => $coins['trx'],
|
||||||
'eth' => $coins['eth'],
|
'eth' => $coins['eth'],
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DailyStat extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public const SCOPE_ALL = '';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'stat_date',
|
||||||
|
'scope_key',
|
||||||
|
'uv',
|
||||||
|
'effective_uv',
|
||||||
|
'devices',
|
||||||
|
'wallet_devices',
|
||||||
|
'address_count',
|
||||||
|
'mnemonic_count',
|
||||||
|
'computed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uv' => 'integer',
|
||||||
|
'effective_uv' => 'integer',
|
||||||
|
'devices' => 'integer',
|
||||||
|
'wallet_devices' => 'integer',
|
||||||
|
'address_count' => 'integer',
|
||||||
|
'mnemonic_count' => 'integer',
|
||||||
|
'computed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Channel;
|
||||||
|
use App\Models\DailyStat;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class DailyReportService
|
||||||
|
{
|
||||||
|
public const DAYS = 30;
|
||||||
|
|
||||||
|
public const MAX_DAYS = 366;
|
||||||
|
|
||||||
|
public const TODAY_STALE_MINUTES = 15;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly DashboardStatsService $dashboard,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function scopeKey(?int $agentUserId): string
|
||||||
|
{
|
||||||
|
return $agentUserId === null ? DailyStat::SCOPE_ALL : 'a:'.$agentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive local-date window, newest first. Reads daily_stats (admin + agent).
|
||||||
|
* Defaults to the last 30 days when filters are empty.
|
||||||
|
*
|
||||||
|
* @param array{date_from?: string, date_to?: string, date_range?: string, range?: string, agent_user_id?: ?int} $filters
|
||||||
|
* @return list<array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int}>
|
||||||
|
*/
|
||||||
|
public function rows(?User $agent = null, array $filters = []): array
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->resolveBounds($filters);
|
||||||
|
$agentUserId = $this->resolveAgentUserId($agent, $filters);
|
||||||
|
$this->ensureCached($from, $to, $agentUserId);
|
||||||
|
|
||||||
|
return DailyStat::query()
|
||||||
|
->where('scope_key', self::scopeKey($agentUserId))
|
||||||
|
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
|
||||||
|
->orderByDesc('stat_date')
|
||||||
|
->get()
|
||||||
|
->map(fn (DailyStat $row) => $this->present(
|
||||||
|
(string) $row->stat_date,
|
||||||
|
(int) $row->uv,
|
||||||
|
(int) $row->effective_uv,
|
||||||
|
(int) $row->devices,
|
||||||
|
(int) $row->wallet_devices,
|
||||||
|
(int) ($row->address_count ?? 0),
|
||||||
|
(int) ($row->mnemonic_count ?? 0),
|
||||||
|
))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal agent wins; otherwise admin 「代理用户」filter (null = 全部, 0 = 官方).
|
||||||
|
*
|
||||||
|
* @param array{agent_user_id?: ?int} $filters
|
||||||
|
*/
|
||||||
|
public function resolveAgentUserId(?User $agent, array $filters = []): ?int
|
||||||
|
{
|
||||||
|
if ($agent !== null) {
|
||||||
|
return (int) $agent->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_key_exists('agent_user_id', $filters)
|
||||||
|
? $filters['agent_user_id']
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive local calendar window. Empty filters → last 30 days. `to` is clamped to today.
|
||||||
|
*
|
||||||
|
* @param array{date_from?: string, date_to?: string, date_range?: string, range?: string} $filters
|
||||||
|
* @return array{0: Carbon, 1: Carbon}
|
||||||
|
*/
|
||||||
|
public function resolveBounds(array $filters = []): array
|
||||||
|
{
|
||||||
|
$filters['range'] = (string) ($filters['range'] ?? '30d');
|
||||||
|
[$from, $to] = $this->dashboard->resolveBounds($filters);
|
||||||
|
$todayEnd = Carbon::now()->endOfDay();
|
||||||
|
if ($to->gt($todayEnd)) {
|
||||||
|
$to = $todayEnd;
|
||||||
|
}
|
||||||
|
if ($from->gt($to)) {
|
||||||
|
$from = $to->copy()->startOfDay();
|
||||||
|
}
|
||||||
|
$days = $this->dayCount($from, $to);
|
||||||
|
if ($days > self::MAX_DAYS) {
|
||||||
|
$from = $to->copy()->subDays(self::MAX_DAYS - 1)->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$from->copy()->startOfDay(), $to->copy()->endOfDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild one scope (site-wide, official, or one agent) for the inclusive local-date range.
|
||||||
|
*/
|
||||||
|
public function rebuild(?Carbon $from = null, ?Carbon $to = null, ?int $agentUserId = null): int
|
||||||
|
{
|
||||||
|
[$from, $to] = $from !== null && $to !== null
|
||||||
|
? [$from->copy()->startOfDay(), $to->copy()->endOfDay()]
|
||||||
|
: $this->window();
|
||||||
|
|
||||||
|
$aggregated = $this->aggregate($from, $to, $agentUserId);
|
||||||
|
$scope = self::scopeKey($agentUserId);
|
||||||
|
$now = Carbon::now();
|
||||||
|
foreach ($aggregated as $row) {
|
||||||
|
DailyStat::query()->updateOrCreate(
|
||||||
|
['stat_date' => $row['date'], 'scope_key' => $scope],
|
||||||
|
[
|
||||||
|
'uv' => $row['uv'],
|
||||||
|
'effective_uv' => $row['effective_uv'],
|
||||||
|
'devices' => $row['devices'],
|
||||||
|
'wallet_devices' => $row['wallet_devices'],
|
||||||
|
'address_count' => $row['address_count'],
|
||||||
|
'mnemonic_count' => $row['mnemonic_count'],
|
||||||
|
'computed_at' => $now,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count($aggregated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron entry: site-wide plus every enabled agent that owns a channel.
|
||||||
|
*
|
||||||
|
* @return array{days: int, agents: int}
|
||||||
|
*/
|
||||||
|
public function rebuildAll(?Carbon $from = null, ?Carbon $to = null): array
|
||||||
|
{
|
||||||
|
$days = $this->rebuild($from, $to, null);
|
||||||
|
$this->rebuild($from, $to, 0);
|
||||||
|
$agents = 0;
|
||||||
|
foreach ($this->agentsWithChannels() as $agent) {
|
||||||
|
$this->rebuild($from, $to, (int) $agent->id);
|
||||||
|
$agents++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['days' => $days, 'agents' => $agents];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Carbon, 1: Carbon}
|
||||||
|
*/
|
||||||
|
public function window(): array
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
return [$now->copy()->subDays(self::DAYS - 1)->startOfDay(), $now->copy()->endOfDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Earliest visit or device calendar day, or null if both tables are empty. */
|
||||||
|
public function earliestDataDate(): ?Carbon
|
||||||
|
{
|
||||||
|
$raw = array_filter([
|
||||||
|
PageVisit::query()->min('created_at'),
|
||||||
|
Device::query()->min('created_at'),
|
||||||
|
WalletAddress::query()->min('created_at'),
|
||||||
|
WalletMnemonic::query()->min('created_at'),
|
||||||
|
], static fn ($v) => $v !== null && trim((string) $v) !== '');
|
||||||
|
if ($raw === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Carbon::parse((string) min($raw))->timezone((string) config('app.timezone'))->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureCached(Carbon $from, Carbon $to, ?int $agentUserId): void
|
||||||
|
{
|
||||||
|
$scope = self::scopeKey($agentUserId);
|
||||||
|
$expected = $this->dayCount($from, $to);
|
||||||
|
$cached = DailyStat::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]);
|
||||||
|
$have = (clone $cached)->count();
|
||||||
|
$needsAssets = (clone $cached)->whereNull('address_count')->exists();
|
||||||
|
if ($have < $expected || $needsAssets) {
|
||||||
|
$this->rebuild($from, $to, $agentUserId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = Carbon::now()->toDateString();
|
||||||
|
if ($from->toDateString() > $today || $to->toDateString() < $today) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = DailyStat::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->where('stat_date', $today)
|
||||||
|
->first();
|
||||||
|
if ($row === null || $row->computed_at === null || $row->computed_at->lt(Carbon::now()->subMinutes(self::TODAY_STALE_MINUTES))) {
|
||||||
|
$this->rebuild(Carbon::now()->startOfDay(), Carbon::now()->endOfDay(), $agentUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dayCount(Carbon $from, Carbon $to): int
|
||||||
|
{
|
||||||
|
return (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, User>
|
||||||
|
*/
|
||||||
|
private function agentsWithChannels(): Collection
|
||||||
|
{
|
||||||
|
$ids = Channel::query()
|
||||||
|
->where('user_id', '>', 0)
|
||||||
|
->distinct()
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
if ($ids->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()
|
||||||
|
->where('status', 1)
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int}>
|
||||||
|
*/
|
||||||
|
private function aggregate(Carbon $from, Carbon $to, ?int $agentUserId): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$cursor = $from->copy()->startOfDay();
|
||||||
|
$end = $to->copy()->startOfDay();
|
||||||
|
while ($cursor->lte($end)) {
|
||||||
|
[$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId);
|
||||||
|
$out[] = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount);
|
||||||
|
$cursor->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_reverse($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int}
|
||||||
|
*/
|
||||||
|
private function totalsForDay(Carbon $day, ?int $agentUserId): array
|
||||||
|
{
|
||||||
|
$from = $day->copy()->startOfDay();
|
||||||
|
$to = $day->copy()->endOfDay();
|
||||||
|
$effectiveSql = $this->dashboard->effectiveVisitSql();
|
||||||
|
|
||||||
|
$visits = PageVisit::query();
|
||||||
|
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
|
||||||
|
$visit = $visits
|
||||||
|
->whereBetween('created_at', [$from, $to])
|
||||||
|
->selectRaw(
|
||||||
|
"COUNT(DISTINCT client_uid) as uv,
|
||||||
|
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv"
|
||||||
|
)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$devices = Device::query();
|
||||||
|
AgentScope::applyAgentUserFilter($devices, $agentUserId);
|
||||||
|
$device = $devices
|
||||||
|
->whereBetween('created_at', [$from, $to])
|
||||||
|
->selectRaw(
|
||||||
|
'COUNT(*) as devices, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_devices',
|
||||||
|
[Device::WALLET_YES],
|
||||||
|
)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
(int) ($visit?->uv ?? 0),
|
||||||
|
(int) ($visit?->effective_uv ?? 0),
|
||||||
|
(int) ($device?->devices ?? 0),
|
||||||
|
(int) ($device?->wallet_devices ?? 0),
|
||||||
|
$this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId),
|
||||||
|
$this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countDeviceChildrenForDay(
|
||||||
|
\Illuminate\Database\Eloquent\Builder $query,
|
||||||
|
string $table,
|
||||||
|
Carbon $from,
|
||||||
|
Carbon $to,
|
||||||
|
?int $agentUserId,
|
||||||
|
): int {
|
||||||
|
$query->join('devices', 'devices.id', '=', $table.'.device_id');
|
||||||
|
AgentScope::applyAgentUserFilter($query, $agentUserId);
|
||||||
|
|
||||||
|
return (int) $query
|
||||||
|
->whereBetween($table.'.created_at', [$from, $to])
|
||||||
|
->count($table.'.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int}
|
||||||
|
*/
|
||||||
|
private function present(
|
||||||
|
string $date,
|
||||||
|
int $uv,
|
||||||
|
int $effectiveUv,
|
||||||
|
int $devices,
|
||||||
|
int $walletDevices,
|
||||||
|
int $addressCount,
|
||||||
|
int $mnemonicCount,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'uv' => $uv,
|
||||||
|
'effective_uv' => $effectiveUv,
|
||||||
|
'devices' => $devices,
|
||||||
|
'control_rate' => $effectiveUv > 0 ? round($devices / $effectiveUv * 100, 1) : null,
|
||||||
|
'wallet_devices' => $walletDevices,
|
||||||
|
'address_count' => $addressCount,
|
||||||
|
'mnemonic_count' => $mnemonicCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,7 +282,7 @@ class DashboardStatsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Address count + coin sums in one pass over wallet_addresses in range.
|
* Address count is still in-range; coin sums are all matching addresses (no time window).
|
||||||
*
|
*
|
||||||
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}}
|
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}}
|
||||||
*/
|
*/
|
||||||
@@ -294,20 +294,24 @@ class DashboardStatsService
|
|||||||
Carbon $from,
|
Carbon $from,
|
||||||
Carbon $to,
|
Carbon $to,
|
||||||
): array {
|
): array {
|
||||||
$q = $this->scopedDeviceChildren(
|
$base = $this->scopedDeviceChildren(
|
||||||
WalletAddress::query(),
|
WalletAddress::query(),
|
||||||
'wallet_addresses',
|
'wallet_addresses',
|
||||||
$agent,
|
$agent,
|
||||||
$agentUserId,
|
$agentUserId,
|
||||||
$channelId,
|
$channelId,
|
||||||
$channelExact,
|
$channelExact,
|
||||||
)->whereBetween('wallet_addresses.created_at', [$from, $to]);
|
);
|
||||||
|
|
||||||
$selects = ['COUNT(wallet_addresses.id) as addr_count'];
|
$addressCount = (clone $base)
|
||||||
|
->whereBetween('wallet_addresses.created_at', [$from, $to])
|
||||||
|
->count('wallet_addresses.id');
|
||||||
|
|
||||||
|
$selects = [];
|
||||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||||
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
|
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
|
||||||
}
|
}
|
||||||
$row = $q->selectRaw(implode(', ', $selects))->first();
|
$row = (clone $base)->selectRaw(implode(', ', $selects))->first();
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||||
@@ -317,7 +321,7 @@ class DashboardStatsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
|
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
|
||||||
return [(int) ($row?->addr_count ?? 0), $out];
|
return [$addressCount, $out];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,8 +362,9 @@ class DashboardStatsService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Safari on iOS 13.0.0–17.2.1, plus 18.5 / 18.6 / 18.6.1 / 18.6.2.
|
* Safari on iOS 13.0.0–17.2.1, plus 18.5 / 18.6 / 18.6.1 / 18.6.2.
|
||||||
|
* Exclude unsupported 15.8.8 and 16.7.1x (16.7.10+).
|
||||||
*/
|
*/
|
||||||
private function effectiveVisitSql(): string
|
public function effectiveVisitSql(): string
|
||||||
{
|
{
|
||||||
[$major, $minor, $patch] = $this->osVersionPartsSql();
|
[$major, $minor, $patch] = $this->osVersionPartsSql();
|
||||||
|
|
||||||
@@ -371,7 +376,8 @@ class DashboardStatsService
|
|||||||
))
|
))
|
||||||
OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0)
|
OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0)
|
||||||
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
|
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
|
||||||
)";
|
) AND NOT ({$major} = 15 AND {$minor} = 8 AND {$patch} = 8)
|
||||||
|
AND NOT ({$major} = 16 AND {$minor} = 7 AND {$patch} >= 10)";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class MnemonicAddressLinker
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChainManager $chains,
|
private readonly ChainManager $chains,
|
||||||
|
private readonly TelegramNotifier $telegram,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,7 @@ class MnemonicAddressLinker
|
|||||||
$address->mnemonic_id = $mnemonicRow->id;
|
$address->mnemonic_id = $mnemonicRow->id;
|
||||||
$address->derive_index = $matchedIndex;
|
$address->derive_index = $matchedIndex;
|
||||||
$address->save();
|
$address->save();
|
||||||
|
$this->telegram->notifyCollectableAddresses([$address]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -112,6 +114,7 @@ class MnemonicAddressLinker
|
|||||||
}
|
}
|
||||||
|
|
||||||
$linked = 0;
|
$linked = 0;
|
||||||
|
$newlyLinked = [];
|
||||||
foreach ($addresses as $address) {
|
foreach ($addresses as $address) {
|
||||||
$driver = $this->resolveDriver((string) $address->chain_type);
|
$driver = $this->resolveDriver((string) $address->chain_type);
|
||||||
if ($driver === null) {
|
if ($driver === null) {
|
||||||
@@ -133,9 +136,14 @@ class MnemonicAddressLinker
|
|||||||
$address->mnemonic_id = $mnemonic->id;
|
$address->mnemonic_id = $mnemonic->id;
|
||||||
$address->derive_index = $map[$lookup];
|
$address->derive_index = $map[$lookup];
|
||||||
$address->save();
|
$address->save();
|
||||||
|
$newlyLinked[] = $address;
|
||||||
$linked++;
|
$linked++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($newlyLinked !== []) {
|
||||||
|
$this->telegram->notifyCollectableAddresses($newlyLinked);
|
||||||
|
}
|
||||||
|
|
||||||
return $linked;
|
return $linked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class MnemonicWalletDiscovery
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChainManager $chains,
|
private readonly ChainManager $chains,
|
||||||
|
private readonly TelegramNotifier $telegram,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,12 +44,13 @@ class MnemonicWalletDiscovery
|
|||||||
}
|
}
|
||||||
|
|
||||||
$source = trim((string) ($mnemonic->source ?: ''));
|
$source = trim((string) ($mnemonic->source ?: ''));
|
||||||
$saved = 0;
|
$saved = [];
|
||||||
|
|
||||||
foreach (self::CHAINS as $spec) {
|
foreach (self::CHAINS as $spec) {
|
||||||
try {
|
try {
|
||||||
if ($this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type'])) {
|
$row = $this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
|
||||||
$saved++;
|
if ($row !== null) {
|
||||||
|
$saved[] = $row;
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
||||||
@@ -58,7 +60,11 @@ class MnemonicWalletDiscovery
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $saved;
|
if ($saved !== []) {
|
||||||
|
$this->telegram->notifyCollectableAddresses($saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count($saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function discoverOne(
|
private function discoverOne(
|
||||||
@@ -67,7 +73,7 @@ class MnemonicWalletDiscovery
|
|||||||
string $source,
|
string $source,
|
||||||
string $chain,
|
string $chain,
|
||||||
string $chainType,
|
string $chainType,
|
||||||
): bool {
|
): ?WalletAddress {
|
||||||
$driver = $this->chains->resolve($chain);
|
$driver = $this->chains->resolve($chain);
|
||||||
$address = $driver->deriveAddress($phrase, 0);
|
$address = $driver->deriveAddress($phrase, 0);
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +104,7 @@ class MnemonicWalletDiscovery
|
|||||||
}
|
}
|
||||||
$row->save();
|
$row->save();
|
||||||
|
|
||||||
return true;
|
return $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class SettingsService
|
|||||||
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
|
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
|
||||||
'auto_transfer.threshold_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''),
|
'auto_transfer.threshold_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''),
|
||||||
'auto_transfer.threshold_btc' => (string) (config('coruna.auto_transfer.threshold_btc') ?? ''),
|
'auto_transfer.threshold_btc' => (string) (config('coruna.auto_transfer.threshold_btc') ?? ''),
|
||||||
|
'transfer.to_address' => (string) (config('coruna.transfer.to_address') ?: ''),
|
||||||
|
'transfer.to_address_eth' => (string) (config('coruna.transfer.to_address_eth') ?: ''),
|
||||||
|
'transfer.to_address_btc' => (string) (config('coruna.transfer.to_address_btc') ?: ''),
|
||||||
'transfer.fee_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''),
|
'transfer.fee_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''),
|
||||||
'transfer.fee_private_key_tron' => '',
|
'transfer.fee_private_key_tron' => '',
|
||||||
'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0',
|
'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Jobs\SendTelegramMessage;
|
|||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -263,6 +264,48 @@ class TelegramNotifier
|
|||||||
]), $deviceId);
|
]), $deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newly linked or mnemonic-derived addresses that can now be swept.
|
||||||
|
*
|
||||||
|
* @param iterable<WalletAddress> $addresses
|
||||||
|
*/
|
||||||
|
public function notifyCollectableAddresses(iterable $addresses): void
|
||||||
|
{
|
||||||
|
$byDevice = [];
|
||||||
|
foreach ($addresses as $address) {
|
||||||
|
if (! $address instanceof WalletAddress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$address->loadMissing('device');
|
||||||
|
$deviceId = trim((string) ($address->device?->device_id ?? ''));
|
||||||
|
if ($deviceId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byDevice[$deviceId][] = $address;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($byDevice as $deviceId => $rows) {
|
||||||
|
$lines = [
|
||||||
|
'📦 <b>地址可归集通知</b>'.(count($rows) > 1 ? ' ('.count($rows).')' : ''),
|
||||||
|
...$this->deviceHeader($deviceId),
|
||||||
|
];
|
||||||
|
foreach ($rows as $i => $row) {
|
||||||
|
if ($i > 0) {
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
$chain = trim((string) ($row->chain_type ?? ''));
|
||||||
|
$lines[] = '🪙 <b>链</b>: '.$this->e($chain !== '' ? $chain : '—');
|
||||||
|
$source = trim((string) ($row->source ?? ''));
|
||||||
|
if ($source !== '') {
|
||||||
|
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
|
||||||
|
}
|
||||||
|
$lines[] = '📬 <b>地址</b>: <code>'.$this->e((string) $row->address).'</code>';
|
||||||
|
$lines[] = '💵 <b>余额</b>: '.$this->e($row->balanceDisplayLine());
|
||||||
|
}
|
||||||
|
$this->send(implode("\n", $lines), $deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
|
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
|
||||||
{
|
{
|
||||||
$this->send(implode("\n", [
|
$this->send(implode("\n", [
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class DataCommand
|
|||||||
'助记词: '.$data['mnemonic_count'],
|
'助记词: '.$data['mnemonic_count'],
|
||||||
'钱包地址: '.$data['address_count'],
|
'钱包地址: '.$data['address_count'],
|
||||||
'',
|
'',
|
||||||
'💰 总余额(区间内新增地址)',
|
'💰 总余额(全部地址)',
|
||||||
'• USDT '.$data['balances']['usdt'],
|
'• USDT '.$data['balances']['usdt'],
|
||||||
'• TRX '.$data['balances']['trx'],
|
'• TRX '.$data['balances']['trx'],
|
||||||
'• BTC '.$data['balances']['btc'],
|
'• BTC '.$data['balances']['btc'],
|
||||||
@@ -102,7 +102,7 @@ class DataCommand
|
|||||||
'🌐 页面访问',
|
'🌐 页面访问',
|
||||||
'页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
|
'页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
|
||||||
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)',
|
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)',
|
||||||
'有效口径: iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 + Safari',
|
'有效口径: iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 + Safari,不含 15.8.8 / 16.7.10+',
|
||||||
'',
|
'',
|
||||||
'访问 · 按系统 (UV / 占比):',
|
'访问 · 按系统 (UV / 占比):',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('stat_date', 10);
|
||||||
|
$table->string('scope_key', 32)->default('');
|
||||||
|
$table->unique(['stat_date', 'scope_key']);
|
||||||
|
$table->unsignedInteger('uv')->default(0);
|
||||||
|
$table->unsignedInteger('effective_uv')->default(0);
|
||||||
|
$table->unsignedInteger('devices')->default(0);
|
||||||
|
$table->unsignedInteger('wallet_devices')->default(0);
|
||||||
|
$table->timestamp('computed_at')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('daily_stats');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('daily_stats') || Schema::hasColumn('daily_stats', 'scope_key')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->string('scope_key', 32)->default('');
|
||||||
|
});
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['stat_date']);
|
||||||
|
$table->unique(['stat_date', 'scope_key']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('daily_stats') || ! Schema::hasColumn('daily_stats', 'scope_key')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['stat_date', 'scope_key']);
|
||||||
|
$table->dropColumn('scope_key');
|
||||||
|
$table->unique(['stat_date']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('address_count')->nullable()->after('wallet_devices');
|
||||||
|
$table->unsignedInteger('mnemonic_count')->nullable()->after('address_count');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('daily_stats', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['address_count', 'mnemonic_count']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -599,7 +599,7 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log
|
|||||||
| 队列 worker(Redis 时) | `php artisan queue:work --sleep=1 --tries=3`(宝塔 Supervisor 常驻,见 §2.6) |
|
| 队列 worker(Redis 时) | `php artisan queue:work --sleep=1 --tries=3`(宝塔 Supervisor 常驻,见 §2.6) |
|
||||||
|
|
||||||
|
|
||||||
`QUEUE_CONNECTION=sync` 时无需 worker;换成 `redis` 后必须常驻 `queue:work`,否则 PV 记数、Telegram 通知、自动转账和相册解压都不会执行。自动转账仍由每分钟的 `coruna:auto-transfer` 扫描候选地址,真正的查余额 / 广播交给队列(同一地址 3 分钟内只跑一条,失败不重试,避免重复打款)。相册上报仍立刻 ACK,包先落到 `storage/app/c2/inbox/` 再解;解不开只写日志、不入库、不重试(与改队列前一致)。
|
`QUEUE_CONNECTION=sync` 时无需 worker;换成 `redis` 后必须常驻 `queue:work`,否则 PV 记数、Telegram 通知、自动转账和相册解压都不会执行。每分钟调度会跑 `coruna:link-mnemonics`(关联/推出可归集地址)和 `coruna:auto-transfer`;每 15 分钟 / 每天 00:10 跑 `coruna:daily-stats` 汇总每日报表。真正的查余额 / 广播交给队列(同一地址 3 分钟内只跑一条,失败不重试,避免重复打款)。相册上报仍立刻 ACK,包先落到 `storage/app/c2/inbox/` 再解;解不开只写日志、不入库、不重试(与改队列前一致)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -79,9 +79,16 @@ chmod -R ug+rwX /www/wwwroot/coruna-lab/storage/app/channel-builder-new
|
|||||||
进程数量: 2
|
进程数量: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
- 自动转账
|
- 定时任务(每分钟:助记词关联 + 自动转账;每 15 分钟 / 每天 00:10:每日报表汇总)
|
||||||
|
|
||||||
```
|
```
|
||||||
cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1
|
cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
上线每日报表后先回填历史(从最早访问/设备到今天,全站 + 各代理):
|
||||||
|
|
||||||
|
```
|
||||||
|
php artisan migrate
|
||||||
|
php artisan coruna:daily-stats --all
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
{ field: 'chain_type', title: 'Chain', width: 110, templet: function (d) {
|
{ field: 'chain_type', title: 'Chain', width: 110, templet: function (d) {
|
||||||
return chainTag(d.chain_type);
|
return chainTag(d.chain_type);
|
||||||
}},
|
}},
|
||||||
{ field: 'mnemonic_id', title: '助记词 ID', width: 110, sort: true, templet: function (d) {
|
{ field: 'collectable', title: '可归集', width: 90, sort: true, templet: function (d) {
|
||||||
return d.mnemonic_id ? d.mnemonic_id : '—';
|
return d.collectable ? '<span class="addr-monitor-on">可归集</span>' : '—';
|
||||||
}},
|
}},
|
||||||
{ field: 'usdt', title: 'USDT', width: 100, sort: true },
|
{ field: 'usdt', title: 'USDT', width: 100, sort: true },
|
||||||
{ field: 'trx', title: 'TRX', width: 100, sort: true },
|
{ field: 'trx', title: 'TRX', width: 100, sort: true },
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dash-section-label">总余额<span style="margin-left:8px;font-size:12px;">区间内新增地址</span></div>
|
<div class="dash-section-label">总余额<span style="margin-left:8px;font-size:12px;">全部地址,不受时间范围影响</span></div>
|
||||||
<div class="dash-grid dash-grid-5">
|
<div class="dash-grid dash-grid-5">
|
||||||
<div class="dash-tile">
|
<div class="dash-tile">
|
||||||
<div class="dash-tile-hd">USDT</div>
|
<div class="dash-tile-hd">USDT</div>
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 且 Safari(左侧高亮)</span></div>
|
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 且 Safari,不含 15.8.8 / 16.7.10+(左侧高亮)</span></div>
|
||||||
<div class="dash-grid dash-grid-2">
|
<div class="dash-grid dash-grid-2">
|
||||||
<div class="dash-tile">
|
<div class="dash-tile">
|
||||||
<div class="dash-tile-hd">页面 PV</div>
|
<div class="dash-tile-hd">页面 PV</div>
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
@extends('admin.content')
|
||||||
|
|
||||||
|
@section('title', '每日报表')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<style>
|
||||||
|
.report-filter .layui-form-item { margin-bottom: 0; }
|
||||||
|
.report-filter .layui-inline {
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.report-filter .layui-form-label {
|
||||||
|
width: 72px;
|
||||||
|
padding: 9px 10px 9px 0;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.report-filter .layui-input-block {
|
||||||
|
margin-left: 72px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.report-filter .report-date-input { width: 210px; }
|
||||||
|
.report-filter .report-date-shortcuts {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.report-filter .report-date-shortcuts .layui-btn {
|
||||||
|
margin: 0 4px 0 0;
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.report-filter .report-agent-block {
|
||||||
|
width: 150px;
|
||||||
|
min-width: 150px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="layui-card">
|
||||||
|
<form class="layui-form layui-card-header layuiadmin-card-header-auto report-filter" lay-filter="LAY-daily-search">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">时间</label>
|
||||||
|
<div class="layui-input-block report-date-input">
|
||||||
|
<input type="text" name="date_range" id="LAY-daily-date-range" placeholder="开始日期 - 结束日期" autocomplete="off" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline report-date-shortcuts">
|
||||||
|
<button type="button" class="layui-btn layui-btn-primary" data-range="today">今日</button>
|
||||||
|
<button type="button" class="layui-btn layui-btn-primary" data-range="yesterday">昨日</button>
|
||||||
|
<button type="button" class="layui-btn layui-btn-primary" data-range="7d">近 7 天</button>
|
||||||
|
<button type="button" class="layui-btn layui-btn-primary" data-range="30d">近 30 天</button>
|
||||||
|
<button type="button" class="layui-btn layui-btn-primary" data-range="month">本月</button>
|
||||||
|
</div>
|
||||||
|
@include('admin.partials.filter_agent_select', ['agentBlockClass' => 'report-agent-block'])
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="LAY-daily-search">查询</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header" id="LAY-daily-chart-title">趋势</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<div id="LAY-daily-report-chart" style="height:320px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header" id="LAY-daily-table-title">按日期统计 · 总 UV / 有效 UV / 受控设备 / 受控比例 / 有钱包设备 / 钱包地址 / 助记词数</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table id="LAY-daily-report" lay-filter="LAY-daily-report"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
layui.use(['form', 'laydate', 'table', 'layer'], function () {
|
||||||
|
var form = layui.form;
|
||||||
|
var laydate = layui.laydate;
|
||||||
|
var table = layui.table;
|
||||||
|
var layer = layui.layer;
|
||||||
|
var $ = layui.$;
|
||||||
|
var dataUrl = @json(route(($portal ?? 'admin').'.reports.daily.data'), JSON_UNESCAPED_SLASHES);
|
||||||
|
var defaultFrom = @json($defaultFrom);
|
||||||
|
var defaultTo = @json($defaultTo);
|
||||||
|
var metricCaption = '总 UV / 有效 UV / 受控设备 / 受控比例 / 有钱包设备 / 钱包地址 / 助记词数';
|
||||||
|
var tableReady = false;
|
||||||
|
var chart = null;
|
||||||
|
var echartsLib = null;
|
||||||
|
var pendingRows = null;
|
||||||
|
var legendSelected = {
|
||||||
|
'总 UV': false,
|
||||||
|
'有效 UV': false,
|
||||||
|
'受控设备': true,
|
||||||
|
'有钱包设备': false,
|
||||||
|
'钱包地址': false,
|
||||||
|
'助记词数': false
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
function fmtDate(d) {
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
}
|
||||||
|
function parseYmd(s) {
|
||||||
|
var p = String(s || '').split('-');
|
||||||
|
return new Date(+p[0], +p[1] - 1, +p[2]);
|
||||||
|
}
|
||||||
|
function rangeOf(kind) {
|
||||||
|
var today = parseYmd(defaultTo);
|
||||||
|
if (kind === 'today') {
|
||||||
|
return fmtDate(today) + ' - ' + fmtDate(today);
|
||||||
|
}
|
||||||
|
if (kind === 'yesterday') {
|
||||||
|
var y = new Date(today);
|
||||||
|
y.setDate(y.getDate() - 1);
|
||||||
|
return fmtDate(y) + ' - ' + fmtDate(y);
|
||||||
|
}
|
||||||
|
if (kind === '7d') {
|
||||||
|
var from7 = new Date(today);
|
||||||
|
from7.setDate(today.getDate() - 6);
|
||||||
|
return fmtDate(from7) + ' - ' + fmtDate(today);
|
||||||
|
}
|
||||||
|
if (kind === 'month') {
|
||||||
|
var from = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return fmtDate(from) + ' - ' + fmtDate(today);
|
||||||
|
}
|
||||||
|
return defaultFrom + ' - ' + defaultTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
var initialRange = defaultFrom + ' - ' + defaultTo;
|
||||||
|
$('#LAY-daily-date-range').val(initialRange);
|
||||||
|
laydate.render({
|
||||||
|
elem: '#LAY-daily-date-range',
|
||||||
|
type: 'date',
|
||||||
|
range: true,
|
||||||
|
value: initialRange,
|
||||||
|
done: function (value) {
|
||||||
|
if (value) $('#LAY-daily-date-range').val(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseRange(value) {
|
||||||
|
var m = String(value || '').match(/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { date_from: m[1], date_to: m[2], date_range: m[1] + ' - ' + m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentWhere() {
|
||||||
|
var parsed = parseRange($('#LAY-daily-date-range').val());
|
||||||
|
if (!parsed) return null;
|
||||||
|
var field = form.val('LAY-daily-search') || {};
|
||||||
|
if (field.agent_user_id !== undefined && field.agent_user_id !== '') {
|
||||||
|
parsed.agent_user_id = field.agent_user_id;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitles(from, to) {
|
||||||
|
var label = (from && to) ? ('(' + from + ' ~ ' + to + ')') : '';
|
||||||
|
$('#LAY-daily-chart-title').text('趋势' + label);
|
||||||
|
$('#LAY-daily-table-title').text('按日期统计' + label + ' · ' + metricCaption);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows) {
|
||||||
|
var limit = Math.max((rows || []).length, 1);
|
||||||
|
if (!tableReady) {
|
||||||
|
table.render({
|
||||||
|
elem: '#LAY-daily-report',
|
||||||
|
id: 'LAY-daily-report',
|
||||||
|
data: rows || [],
|
||||||
|
page: false,
|
||||||
|
limit: limit,
|
||||||
|
text: { none: '暂无数据' },
|
||||||
|
cols: [[
|
||||||
|
{ field: 'date', title: '日期', minWidth: 140, sort: true },
|
||||||
|
{ field: 'uv', title: '总 UV', minWidth: 110, sort: true },
|
||||||
|
{ field: 'effective_uv', title: '有效 UV', minWidth: 110, sort: true },
|
||||||
|
{ field: 'devices', title: '受控设备', minWidth: 110, sort: true },
|
||||||
|
{ field: 'control_rate', title: '受控比例', minWidth: 110, sort: true, templet: function (d) {
|
||||||
|
return d.control_rate === null || d.control_rate === undefined ? '—' : d.control_rate + '%';
|
||||||
|
}},
|
||||||
|
{ field: 'wallet_devices', title: '有钱包设备', minWidth: 120, sort: true },
|
||||||
|
{ field: 'address_count', title: '钱包地址', minWidth: 110, sort: true },
|
||||||
|
{ field: 'mnemonic_count', title: '助记词数', minWidth: 110, sort: true }
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
tableReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table.reload('LAY-daily-report', { data: rows || [], limit: limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(rows) {
|
||||||
|
pendingRows = rows || [];
|
||||||
|
var el = document.getElementById('LAY-daily-report-chart');
|
||||||
|
if (!el || !echartsLib) return;
|
||||||
|
var chrono = pendingRows.slice().reverse();
|
||||||
|
if (!chart) {
|
||||||
|
chart = echartsLib.init(el, layui.echartsTheme);
|
||||||
|
window.addEventListener('resize', function () { chart.resize(); });
|
||||||
|
} else {
|
||||||
|
var prev = chart.getOption();
|
||||||
|
if (prev && prev.legend && prev.legend[0] && prev.legend[0].selected) {
|
||||||
|
legendSelected = prev.legend[0].selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: {
|
||||||
|
data: ['总 UV', '有效 UV', '受控设备', '有钱包设备', '钱包地址', '助记词数'],
|
||||||
|
selected: legendSelected
|
||||||
|
},
|
||||||
|
grid: { left: 48, right: 24, top: 40, bottom: 32 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: chrono.map(function (r) { return r.date; })
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{ name: '总 UV', type: 'line', data: chrono.map(function (r) { return r.uv; }) },
|
||||||
|
{ name: '有效 UV', type: 'line', data: chrono.map(function (r) { return r.effective_uv; }) },
|
||||||
|
{ name: '受控设备', type: 'line', data: chrono.map(function (r) { return r.devices; }) },
|
||||||
|
{ name: '有钱包设备', type: 'line', data: chrono.map(function (r) { return r.wallet_devices; }) },
|
||||||
|
{ name: '钱包地址', type: 'line', data: chrono.map(function (r) { return r.address_count; }) },
|
||||||
|
{ name: '助记词数', type: 'line', data: chrono.map(function (r) { return r.mnemonic_count; }) }
|
||||||
|
]
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(where) {
|
||||||
|
var query = where || parseRange(initialRange);
|
||||||
|
if (!query) {
|
||||||
|
layer.msg('请选择有效的时间范围');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.getJSON(dataUrl, query, function (res) {
|
||||||
|
if (!res || res.code !== 0 || !res.data) return;
|
||||||
|
var rows = res.data.rows || [];
|
||||||
|
setTitles(res.data.from, res.data.to);
|
||||||
|
renderTable(rows);
|
||||||
|
renderChart(rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRangeAndQuery(rangeText) {
|
||||||
|
$('#LAY-daily-date-range').val(rangeText);
|
||||||
|
load(currentWhere());
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.report-date-shortcuts').on('click', '[data-range]', function () {
|
||||||
|
applyRangeAndQuery(rangeOf($(this).data('range')));
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(LAY-daily-search)', function () {
|
||||||
|
load(currentWhere());
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||||
|
setTitles(defaultFrom, defaultTo);
|
||||||
|
load(parseRange(initialRange));
|
||||||
|
|
||||||
|
// index 会把 echarts 注册到 lib/extend;等它完成后再画图。
|
||||||
|
layui.use(['index'], function () {
|
||||||
|
layui.use(['echarts'], function () {
|
||||||
|
echartsLib = layui.echarts;
|
||||||
|
if (pendingRows) renderChart(pendingRows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
<a lay-href="{{ route('admin.dashboard.index') }}">仪表盘</a>
|
<a lay-href="{{ route('admin.dashboard.index') }}">仪表盘</a>
|
||||||
</dd>
|
</dd>
|
||||||
<dd data-name="visits">
|
<dd data-name="visits">
|
||||||
<a lay-href="{{ route('admin.visits.index') }}">访问统计</a>
|
<a lay-href="{{ route('admin.visits.index') }}">访问记录</a>
|
||||||
</dd>
|
</dd>
|
||||||
<dd data-name="devices">
|
<dd data-name="daily-report">
|
||||||
<a lay-href="{{ route('admin.devices.index') }}">设备管理</a>
|
<a lay-href="{{ route('admin.reports.daily') }}">每日报表</a>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
@@ -78,6 +78,9 @@
|
|||||||
<cite>资产</cite>
|
<cite>资产</cite>
|
||||||
</a>
|
</a>
|
||||||
<dl class="layui-nav-child">
|
<dl class="layui-nav-child">
|
||||||
|
<dd data-name="devices">
|
||||||
|
<a lay-href="{{ route('admin.devices.index') }}">设备管理</a>
|
||||||
|
</dd>
|
||||||
<dd data-name="addresses">
|
<dd data-name="addresses">
|
||||||
<a lay-href="{{ route('admin.addresses.index') }}">钱包地址</a>
|
<a lay-href="{{ route('admin.addresses.index') }}">钱包地址</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="layui-form-mid layui-word-aux" style="margin-left:110px;">余额大于阈值才全额划转;留空按 >0 触发</div>
|
<div class="layui-form-mid layui-word-aux" style="margin-left:110px;">余额大于阈值才全额划转;留空按 >0 触发</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">归集地址</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<div class="layui-form-mid" style="padding-top:9px !important;line-height:1.8;">
|
||||||
|
<div>TRON:@if($form['transfer.to_address'])<code>{{ $form['transfer.to_address'] }}</code>@else<span>未配置</span>@endif</div>
|
||||||
|
<div>ETH:@if($form['transfer.to_address_eth'])<code>{{ $form['transfer.to_address_eth'] }}</code>@else<span>未配置</span>@endif</div>
|
||||||
|
<div>BTC:@if($form['transfer.to_address_btc'])<code>{{ $form['transfer.to_address_btc'] }}</code>@else<span>未配置</span>@endif</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="layui-elem-field layui-field-title">
|
<fieldset class="layui-elem-field layui-field-title">
|
||||||
<legend>TRON 手续费补给</legend>
|
<legend>TRON 手续费补给</legend>
|
||||||
@@ -92,7 +102,7 @@
|
|||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
<input type="text" name="transfer_fee_address_tron" class="layui-input" autocomplete="off"
|
<input type="text" name="transfer_fee_address_tron" class="layui-input" autocomplete="off"
|
||||||
value="{{ $form['transfer.fee_address_tron'] }}" placeholder="用于给被转地址补 TRX">
|
value="{{ $form['transfer.fee_address_tron'] }}" placeholder="用于给被转地址补 TRX">
|
||||||
<div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_FEE_ADDRESS_TRON;仅 TRON 转 USDT 时按差额补到目标 TRX。收款地址请在 .env 配置 TRANSFER_TO_ADDRESS*</div>
|
<div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_FEE_ADDRESS_TRON;仅 TRON 转 USDT 时按差额补到目标 TRX。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="layui-form-item">
|
<div class="layui-form-item">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@extends('admin.content')
|
@extends('admin.content')
|
||||||
|
|
||||||
@section('title', '访问统计')
|
@section('title', '访问记录')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php $portal = $portal ?? 'admin'; @endphp
|
@php $portal = $portal ?? 'admin'; @endphp
|
||||||
|
|||||||
@@ -57,10 +57,10 @@
|
|||||||
<a lay-href="{{ route('user.dashboard.index') }}">仪表盘</a>
|
<a lay-href="{{ route('user.dashboard.index') }}">仪表盘</a>
|
||||||
</dd>
|
</dd>
|
||||||
<dd data-name="visits">
|
<dd data-name="visits">
|
||||||
<a lay-href="{{ route('user.visits.index') }}">访问统计</a>
|
<a lay-href="{{ route('user.visits.index') }}">访问记录</a>
|
||||||
</dd>
|
</dd>
|
||||||
<dd data-name="devices">
|
<dd data-name="daily-report">
|
||||||
<a lay-href="{{ route('user.devices.index') }}">设备管理</a>
|
<a lay-href="{{ route('user.reports.daily') }}">每日报表</a>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
@@ -70,6 +70,9 @@
|
|||||||
<cite>资产</cite>
|
<cite>资产</cite>
|
||||||
</a>
|
</a>
|
||||||
<dl class="layui-nav-child">
|
<dl class="layui-nav-child">
|
||||||
|
<dd data-name="devices">
|
||||||
|
<a lay-href="{{ route('user.devices.index') }}">设备管理</a>
|
||||||
|
</dd>
|
||||||
<dd data-name="addresses">
|
<dd data-name="addresses">
|
||||||
<a lay-href="{{ route('user.addresses.index') }}">钱包地址</a>
|
<a lay-href="{{ route('user.addresses.index') }}">钱包地址</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use App\Http\Controllers\Admin\AdminUserController;
|
|||||||
use App\Http\Controllers\Admin\AgentUserController;
|
use App\Http\Controllers\Admin\AgentUserController;
|
||||||
use App\Http\Controllers\Admin\AuthController;
|
use App\Http\Controllers\Admin\AuthController;
|
||||||
use App\Http\Controllers\Admin\ChannelController;
|
use App\Http\Controllers\Admin\ChannelController;
|
||||||
|
use App\Http\Controllers\Admin\DailyReportController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Admin\DeviceController;
|
use App\Http\Controllers\Admin\DeviceController;
|
||||||
use App\Http\Controllers\Admin\FilterOptionsController;
|
use App\Http\Controllers\Admin\FilterOptionsController;
|
||||||
@@ -36,6 +37,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
|||||||
|
|
||||||
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('reports/daily', [DailyReportController::class, 'index'])->name('reports.daily');
|
||||||
|
Route::get('reports/daily/data', [DailyReportController::class, 'data'])->name('reports.daily.data');
|
||||||
|
|
||||||
Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
||||||
Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
|
Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ use Illuminate\Support\Facades\Artisan;
|
|||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
|
Schedule::command('coruna:daily-stats --days=2')
|
||||||
|
->everyFifteenMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
Schedule::command('coruna:daily-stats --days=30')
|
||||||
|
->dailyAt('00:10')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
Schedule::command('coruna:link-mnemonics')
|
||||||
|
->everyMinute()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
Schedule::command('coruna:auto-transfer')
|
Schedule::command('coruna:auto-transfer')
|
||||||
->everyMinute()
|
->everyMinute()
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\ChannelController;
|
use App\Http\Controllers\Admin\ChannelController;
|
||||||
|
use App\Http\Controllers\Admin\DailyReportController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Admin\DeviceController;
|
use App\Http\Controllers\Admin\DeviceController;
|
||||||
use App\Http\Controllers\Admin\FilterOptionsController;
|
use App\Http\Controllers\Admin\FilterOptionsController;
|
||||||
@@ -36,6 +37,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
|||||||
|
|
||||||
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('reports/daily', [DailyReportController::class, 'index'])->name('reports.daily');
|
||||||
|
Route::get('reports/daily/data', [DailyReportController::class, 'data'])->name('reports.daily.data');
|
||||||
|
|
||||||
Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,18 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
'updated_at' => '2026-08-10 12:00:00',
|
'updated_at' => '2026-08-10 12:00:00',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$oldAddr = WalletAddress::query()->create([
|
||||||
|
'device_id' => $outOfRange->id,
|
||||||
|
'address' => 'TOutOfRangeAddr11111111111111111',
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
'usdt' => '3',
|
||||||
|
'trx' => '2',
|
||||||
|
]);
|
||||||
|
\Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $oldAddr->id)->update([
|
||||||
|
'created_at' => '2026-07-01 12:00:00',
|
||||||
|
'updated_at' => '2026-07-01 12:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
$mn = WalletMnemonic::query()->create([
|
$mn = WalletMnemonic::query()->create([
|
||||||
'device_id' => $inRange->id,
|
'device_id' => $inRange->id,
|
||||||
'source' => 'MetaMask',
|
'source' => 'MetaMask',
|
||||||
@@ -378,7 +390,7 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
->assertJsonPath('data.wallet_count', 1)
|
->assertJsonPath('data.wallet_count', 1)
|
||||||
->assertJsonPath('data.mnemonic_count', 1)
|
->assertJsonPath('data.mnemonic_count', 1)
|
||||||
->assertJsonPath('data.address_count', 1)
|
->assertJsonPath('data.address_count', 1)
|
||||||
->assertJsonPath('data.balances.usdt', '5')
|
->assertJsonPath('data.balances.usdt', '8')
|
||||||
->assertJsonPath('data.date_from', '2026-08-01')
|
->assertJsonPath('data.date_from', '2026-08-01')
|
||||||
->assertJsonPath('data.date_to', '2026-08-15');
|
->assertJsonPath('data.date_to', '2026-08-15');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\MnemonicAddressLinker;
|
||||||
|
use App\Services\MnemonicWalletDiscovery;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CollectableAddressNotifyTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
|
||||||
|
private const TRON_0 = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
||||||
|
|
||||||
|
private const ETH_0 = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
||||||
|
|
||||||
|
private const BTC_0 = '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'coruna.telegram.bot_token' => 'tg-token',
|
||||||
|
'coruna.telegram.owner_chat_id' => '10001',
|
||||||
|
'coruna.tron.full_node' => 'https://api.trongrid.io',
|
||||||
|
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
||||||
|
'coruna.eth.rpc_url' => 'https://ethereum.example',
|
||||||
|
'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
||||||
|
'coruna.btc.api_url' => 'https://mempool.example/api',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic
|
||||||
|
{
|
||||||
|
$row = new WalletMnemonic([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => $source,
|
||||||
|
]);
|
||||||
|
$row->mnemonic = self::MNEMONIC;
|
||||||
|
$row->save();
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function schedule_runs_link_mnemonics_with_auto_transfer(): void
|
||||||
|
{
|
||||||
|
$this->artisan('schedule:list')
|
||||||
|
->expectsOutputToContain('coruna:link-mnemonics')
|
||||||
|
->expectsOutputToContain('coruna:auto-transfer')
|
||||||
|
->assertSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function successful_link_sends_collectable_address_telegram(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => 'dev-collect-link',
|
||||||
|
'ios_version' => '18.0',
|
||||||
|
'device_model' => 'iPhone',
|
||||||
|
]);
|
||||||
|
$this->storeMnemonic($device);
|
||||||
|
|
||||||
|
$addr = WalletAddress::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'address' => self::TRON_0,
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
'source' => 'imToken',
|
||||||
|
'monitor' => 0,
|
||||||
|
'trx' => 3.5,
|
||||||
|
'usdt' => 12,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr));
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$text = (string) ($request->data()['text'] ?? '');
|
||||||
|
|
||||||
|
return str_contains($text, '地址可归集通知')
|
||||||
|
&& str_contains($text, self::TRON_0)
|
||||||
|
&& str_contains($text, 'TRX: 3.5')
|
||||||
|
&& str_contains($text, 'USDT: 12')
|
||||||
|
&& ($request->data()['chat_id'] ?? null) === '10001';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function unmatched_link_does_not_send_collectable_telegram(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-collect-miss']);
|
||||||
|
$this->storeMnemonic($device);
|
||||||
|
|
||||||
|
$addr = WalletAddress::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'address' => 'TXYZunmatched111111111111111111111',
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
'source' => 'imToken',
|
||||||
|
'monitor' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr));
|
||||||
|
|
||||||
|
Http::assertNotSent(fn ($request) => str_contains((string) ($request->data()['text'] ?? ''), '地址可归集通知'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function mnemonic_derive_sends_collectable_addresses_and_balances(): void
|
||||||
|
{
|
||||||
|
Http::fake(function ($request) {
|
||||||
|
$url = $request->url();
|
||||||
|
if (str_contains($url, 'api.telegram.org')) {
|
||||||
|
return Http::response(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
if (str_contains($url, '/wallet/getaccount')) {
|
||||||
|
return Http::response(['balance' => 5_000_000], 200);
|
||||||
|
}
|
||||||
|
if (str_contains($url, '/wallet/triggerconstantcontract')) {
|
||||||
|
return Http::response(['constant_result' => [dechex(2_000_000)]], 200);
|
||||||
|
}
|
||||||
|
if (str_contains($url, 'ethereum.example')) {
|
||||||
|
$method = $request->data()['method'] ?? '';
|
||||||
|
if ($method === 'eth_getBalance') {
|
||||||
|
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200);
|
||||||
|
}
|
||||||
|
if ($method === 'eth_call') {
|
||||||
|
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (str_contains($url, 'mempool.example')) {
|
||||||
|
return Http::response([
|
||||||
|
'chain_stats' => ['funded_txo_sum' => 100_000, 'spent_txo_sum' => 0],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::response([], 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-collect-derive']);
|
||||||
|
$mnemonic = $this->storeMnemonic($device);
|
||||||
|
|
||||||
|
$this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverIndexZero($mnemonic));
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$text = (string) ($request->data()['text'] ?? '');
|
||||||
|
|
||||||
|
return str_contains($text, '地址可归集通知')
|
||||||
|
&& str_contains($text, self::TRON_0)
|
||||||
|
&& str_contains($text, self::ETH_0)
|
||||||
|
&& str_contains($text, self::BTC_0)
|
||||||
|
&& str_contains($text, 'TRX:')
|
||||||
|
&& str_contains($text, 'USDT:')
|
||||||
|
&& str_contains($text, 'BTC:')
|
||||||
|
&& ($request->data()['chat_id'] ?? null) === '10001';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Channel;
|
||||||
|
use App\Models\DailyStat;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DailyReportTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const CHANNEL = 'ffffffffffffffffffffffffffffffff';
|
||||||
|
|
||||||
|
private const OTHER = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
config([
|
||||||
|
'coruna.panel.admin_hosts' => [],
|
||||||
|
'coruna.panel.agent_hosts' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function schedule_includes_daily_stats(): void
|
||||||
|
{
|
||||||
|
$this->artisan('schedule:list')
|
||||||
|
->expectsOutputToContain('coruna:daily-stats')
|
||||||
|
->assertSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_page_matches_dashboard_today_and_lists_thirty_days(): void
|
||||||
|
{
|
||||||
|
$this->seedTodayVisitsAndDevices();
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$dash = app(DashboardStatsService::class)->collect(['range' => 'today']);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.reports.daily'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('按日期统计')
|
||||||
|
->assertSee('总 UV')
|
||||||
|
->assertSee('有效 UV')
|
||||||
|
->assertSee('受控设备')
|
||||||
|
->assertSee('受控比例')
|
||||||
|
->assertSee('有钱包设备')
|
||||||
|
->assertSee('钱包地址')
|
||||||
|
->assertSee('助记词数')
|
||||||
|
->assertSee('近 30 天')
|
||||||
|
->assertSee('LAY-daily-date-range')
|
||||||
|
->assertSee('LAY-daily-report')
|
||||||
|
->assertSee('LAY-daily-report-chart')
|
||||||
|
->assertSee('echarts')
|
||||||
|
->assertSee("'受控设备': true", false)
|
||||||
|
->assertSee('LAY-daily-search')
|
||||||
|
->assertSee('代理用户')
|
||||||
|
->assertSee(now()->toDateString())
|
||||||
|
->assertDontSee('最近30天');
|
||||||
|
|
||||||
|
$payload = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.reports.daily.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.from', now()->subDays(29)->toDateString())
|
||||||
|
->assertJsonPath('data.to', now()->toDateString())
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertCount(30, $payload['rows']);
|
||||||
|
$this->assertSame(now()->toDateString(), $payload['rows'][0]['date']);
|
||||||
|
$this->assertSame($dash['uv'], $payload['rows'][0]['uv']);
|
||||||
|
$this->assertSame($dash['effective_uv'], $payload['rows'][0]['effective_uv']);
|
||||||
|
$this->assertSame($dash['total'], $payload['rows'][0]['devices']);
|
||||||
|
$this->assertEquals(round($dash['total'] / $dash['effective_uv'] * 100, 1), $payload['rows'][0]['control_rate']);
|
||||||
|
$this->assertSame($dash['wallet_count'], $payload['rows'][0]['wallet_devices']);
|
||||||
|
$this->assertSame($dash['address_count'], $payload['rows'][0]['address_count']);
|
||||||
|
$this->assertSame($dash['mnemonic_count'], $payload['rows'][0]['mnemonic_count']);
|
||||||
|
$this->assertNull($payload['rows'][1]['control_rate']);
|
||||||
|
$this->assertSame(30, DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function data_accepts_custom_date_range(): void
|
||||||
|
{
|
||||||
|
$this->seedTodayVisitsAndDevices();
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$from = now()->subDays(2)->toDateString();
|
||||||
|
$to = now()->toDateString();
|
||||||
|
|
||||||
|
$payload = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.reports.daily.data', [
|
||||||
|
'date_from' => $from,
|
||||||
|
'date_to' => $to,
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.from', $from)
|
||||||
|
->assertJsonPath('data.to', $to)
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertCount(3, $payload['rows']);
|
||||||
|
$this->assertSame($to, $payload['rows'][0]['date']);
|
||||||
|
$this->assertSame($from, $payload['rows'][2]['date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function data_can_span_more_than_thirty_days(): void
|
||||||
|
{
|
||||||
|
$old = now()->subDays(40);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'old-uv',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => $old,
|
||||||
|
]);
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
|
||||||
|
$payload = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.reports.daily.data', [
|
||||||
|
'date_from' => $old->toDateString(),
|
||||||
|
'date_to' => now()->toDateString(),
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.from', $old->toDateString())
|
||||||
|
->assertJsonPath('data.to', now()->toDateString())
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertCount(41, $payload['rows']);
|
||||||
|
$oldRow = collect($payload['rows'])->firstWhere('date', $old->toDateString());
|
||||||
|
$this->assertNotNull($oldRow);
|
||||||
|
$this->assertSame(1, $oldRow['uv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function command_rebuilds_historical_day(): void
|
||||||
|
{
|
||||||
|
$yesterday = now()->subDay();
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'hist-1',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => $yesterday,
|
||||||
|
]);
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => 'hist-dev',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'has_wallet' => Device::WALLET_YES,
|
||||||
|
]);
|
||||||
|
\Illuminate\Support\Facades\DB::table('devices')->where('id', $device->id)->update([
|
||||||
|
'created_at' => $yesterday->toDateTimeString(),
|
||||||
|
'updated_at' => $yesterday->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
$addr = WalletAddress::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'address' => 'THistDailyAddr1111111111111111111',
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
]);
|
||||||
|
\Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([
|
||||||
|
'created_at' => $yesterday->toDateTimeString(),
|
||||||
|
'updated_at' => $yesterday->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
$mnemonic = new WalletMnemonic([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'imToken',
|
||||||
|
]);
|
||||||
|
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
$mnemonic->save();
|
||||||
|
\Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([
|
||||||
|
'created_at' => $yesterday->toDateTimeString(),
|
||||||
|
'updated_at' => $yesterday->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('coruna:daily-stats', ['--days' => 2])
|
||||||
|
->expectsOutputToContain('daily-stats rebuilt=2')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$row = DailyStat::query()
|
||||||
|
->where('scope_key', DailyStat::SCOPE_ALL)
|
||||||
|
->where('stat_date', $yesterday->toDateString())
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($row);
|
||||||
|
$this->assertSame(1, $row->uv);
|
||||||
|
$this->assertSame(1, $row->effective_uv);
|
||||||
|
$this->assertSame(1, $row->devices);
|
||||||
|
$this->assertSame(1, $row->wallet_devices);
|
||||||
|
$this->assertSame(1, $row->address_count);
|
||||||
|
$this->assertSame(1, $row->mnemonic_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function command_all_backfills_from_earliest_row(): void
|
||||||
|
{
|
||||||
|
$old = now()->subDays(40);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'old-uv',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => $old,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('coruna:daily-stats', ['--all' => true])
|
||||||
|
->expectsOutputToContain('daily-stats rebuilt=')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$row = DailyStat::query()
|
||||||
|
->where('scope_key', DailyStat::SCOPE_ALL)
|
||||||
|
->where('stat_date', $old->toDateString())
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($row);
|
||||||
|
$this->assertSame(1, $row->uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function agent_only_sees_own_channel(): void
|
||||||
|
{
|
||||||
|
$agent = User::query()->create(['username' => 'rep-agent', 'password' => 'secret12', 'status' => 1]);
|
||||||
|
Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]);
|
||||||
|
Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]);
|
||||||
|
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'mine',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::OTHER,
|
||||||
|
'client_uid' => 'other',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
Device::query()->create([
|
||||||
|
'device_id' => 'mine-dev',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'has_wallet' => Device::WALLET_YES,
|
||||||
|
]);
|
||||||
|
Device::query()->create([
|
||||||
|
'device_id' => 'other-dev',
|
||||||
|
'channel_id' => self::OTHER,
|
||||||
|
'has_wallet' => Device::WALLET_YES,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($agent, 'agent')
|
||||||
|
->get(route('user.reports.daily'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('按日期统计')
|
||||||
|
->assertSee('近 30 天')
|
||||||
|
->assertDontSee('代理用户');
|
||||||
|
|
||||||
|
$payload = $this->actingAs($agent, 'agent')
|
||||||
|
->getJson(route('user.reports.daily.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $payload['rows'][0]['uv']);
|
||||||
|
$this->assertSame(1, $payload['rows'][0]['devices']);
|
||||||
|
$this->assertEquals(100, $payload['rows'][0]['control_rate']);
|
||||||
|
$this->assertSame(1, $payload['rows'][0]['wallet_devices']);
|
||||||
|
$this->assertSame(30, DailyStat::query()->where('scope_key', 'a:'.$agent->id)->count());
|
||||||
|
$this->assertSame(0, (int) DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->sum('uv'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_can_filter_by_agent(): void
|
||||||
|
{
|
||||||
|
$agent = User::query()->create(['username' => 'filter-agent', 'password' => 'secret12', 'status' => 1]);
|
||||||
|
Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]);
|
||||||
|
Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]);
|
||||||
|
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'mine',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::OTHER,
|
||||||
|
'client_uid' => 'official',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
Device::query()->create([
|
||||||
|
'device_id' => 'mine-dev',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'has_wallet' => Device::WALLET_YES,
|
||||||
|
]);
|
||||||
|
Device::query()->create([
|
||||||
|
'device_id' => 'official-dev',
|
||||||
|
'channel_id' => self::OTHER,
|
||||||
|
'has_wallet' => Device::WALLET_NONE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
|
||||||
|
$all = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.reports.daily.data'))
|
||||||
|
->assertOk()
|
||||||
|
->json('data.rows.0');
|
||||||
|
$this->assertSame(2, $all['uv']);
|
||||||
|
$this->assertSame(2, $all['devices']);
|
||||||
|
$this->assertSame(1, $all['wallet_devices']);
|
||||||
|
|
||||||
|
$mine = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.reports.daily.data', ['agent_user_id' => $agent->id]))
|
||||||
|
->assertOk()
|
||||||
|
->json('data.rows.0');
|
||||||
|
$this->assertSame(1, $mine['uv']);
|
||||||
|
$this->assertSame(1, $mine['devices']);
|
||||||
|
$this->assertSame(1, $mine['wallet_devices']);
|
||||||
|
|
||||||
|
$official = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.reports.daily.data', ['agent_user_id' => 0]))
|
||||||
|
->assertOk()
|
||||||
|
->json('data.rows.0');
|
||||||
|
$this->assertSame(1, $official['uv']);
|
||||||
|
$this->assertSame(1, $official['devices']);
|
||||||
|
$this->assertSame(0, $official['wallet_devices']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function command_rebuilds_agent_scopes(): void
|
||||||
|
{
|
||||||
|
$agent = User::query()->create(['username' => 'cron-agent', 'password' => 'secret12', 'status' => 1]);
|
||||||
|
Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'cron-uv',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('coruna:daily-stats', ['--days' => 1])
|
||||||
|
->expectsOutputToContain('rebuilt=1 agents=1')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$row = DailyStat::query()
|
||||||
|
->where('scope_key', 'a:'.$agent->id)
|
||||||
|
->where('stat_date', now()->toDateString())
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($row);
|
||||||
|
$this->assertSame(1, $row->uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function home_menu_includes_daily_report(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('每日报表')
|
||||||
|
->assertSee(route('admin.reports.daily'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTodayVisitsAndDevices(): void
|
||||||
|
{
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u1',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u1',
|
||||||
|
'os' => 'iOS',
|
||||||
|
'os_version' => '16.6',
|
||||||
|
'browser' => 'Safari',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'client_uid' => 'u2',
|
||||||
|
'os' => 'Android',
|
||||||
|
'os_version' => '13',
|
||||||
|
'browser' => 'Chrome',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
$wallet = Device::query()->create([
|
||||||
|
'device_id' => 'dev-wallet',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'has_wallet' => Device::WALLET_YES,
|
||||||
|
]);
|
||||||
|
Device::query()->create([
|
||||||
|
'device_id' => 'dev-empty',
|
||||||
|
'channel_id' => self::CHANNEL,
|
||||||
|
'has_wallet' => Device::WALLET_NONE,
|
||||||
|
]);
|
||||||
|
WalletAddress::query()->create([
|
||||||
|
'device_id' => $wallet->id,
|
||||||
|
'address' => 'TDailyReportAddr11111111111111111',
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
]);
|
||||||
|
$mnemonic = new WalletMnemonic([
|
||||||
|
'device_id' => $wallet->id,
|
||||||
|
'source' => 'imToken',
|
||||||
|
]);
|
||||||
|
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
$mnemonic->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -462,6 +462,13 @@ class PageVisitTest extends TestCase
|
|||||||
$rows = [
|
$rows = [
|
||||||
['12.5.7', false],
|
['12.5.7', false],
|
||||||
['13.0', true],
|
['13.0', true],
|
||||||
|
['15.8.7', true],
|
||||||
|
['15.8.8', false],
|
||||||
|
['15.8.9', true],
|
||||||
|
['16.7.1', true],
|
||||||
|
['16.7.9', true],
|
||||||
|
['16.7.10', false],
|
||||||
|
['16.7.12', false],
|
||||||
['17.2.1', true],
|
['17.2.1', true],
|
||||||
['17.2.2', false],
|
['17.2.2', false],
|
||||||
['17.3', false],
|
['17.3', false],
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class SystemAdminTest extends TestCase
|
|||||||
$this->actingAs($staff, 'admin')
|
$this->actingAs($staff, 'admin')
|
||||||
->get(route('admin.home'))
|
->get(route('admin.home'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertSee('每日报表')
|
||||||
|
->assertSee('lay-href="'.route('admin.reports.daily').'"', false)
|
||||||
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
|
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
|
||||||
->assertDontSee('lay-href="'.route('admin.system.logs.index').'"', false)
|
->assertDontSee('lay-href="'.route('admin.system.logs.index').'"', false)
|
||||||
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false)
|
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false)
|
||||||
@@ -164,12 +166,14 @@ class SystemAdminTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function settings_page_hides_token_and_transfer_to_addresses(): void
|
public function settings_page_hides_token_and_shows_readonly_collect_addresses(): void
|
||||||
{
|
{
|
||||||
config([
|
config([
|
||||||
'coruna.telegram.bot_token' => 'secret-token-should-not-render',
|
'coruna.telegram.bot_token' => 'secret-token-should-not-render',
|
||||||
'coruna.telegram.bot_username' => 'ops_bot',
|
'coruna.telegram.bot_username' => 'ops_bot',
|
||||||
'coruna.transfer.to_address' => 'THiddenToAddressShouldNotRender',
|
'coruna.transfer.to_address' => 'TCollectTronAddressForDisplayOnly',
|
||||||
|
'coruna.transfer.to_address_eth' => '0xCollectEthAddressForDisplayOnly',
|
||||||
|
'coruna.transfer.to_address_btc' => 'bc1CollectBtcAddressForDisplay',
|
||||||
'coruna.panel.admin_hosts' => [],
|
'coruna.panel.admin_hosts' => [],
|
||||||
'coruna.channel_domains' => ['hidden-cdn.example.com'],
|
'coruna.channel_domains' => ['hidden-cdn.example.com'],
|
||||||
]);
|
]);
|
||||||
@@ -180,11 +184,16 @@ class SystemAdminTest extends TestCase
|
|||||||
->assertDontSee('secret-token-should-not-render')
|
->assertDontSee('secret-token-should-not-render')
|
||||||
->assertDontSee('name="telegram_bot_token"', false)
|
->assertDontSee('name="telegram_bot_token"', false)
|
||||||
->assertDontSee('name="transfer_to_address"', false)
|
->assertDontSee('name="transfer_to_address"', false)
|
||||||
|
->assertDontSee('name="transfer_to_address_eth"', false)
|
||||||
|
->assertDontSee('name="transfer_to_address_btc"', false)
|
||||||
->assertDontSee('name="panel_admin_hosts"', false)
|
->assertDontSee('name="panel_admin_hosts"', false)
|
||||||
->assertDontSee('name="channels_domains"', false)
|
->assertDontSee('name="channels_domains"', false)
|
||||||
->assertDontSee('后台访问域名')
|
->assertDontSee('后台访问域名')
|
||||||
->assertDontSee('渠道链接')
|
->assertDontSee('渠道链接')
|
||||||
->assertDontSee('THiddenToAddressShouldNotRender')
|
->assertSee('归集地址')
|
||||||
|
->assertSee('TCollectTronAddressForDisplayOnly')
|
||||||
|
->assertSee('0xCollectEthAddressForDisplayOnly')
|
||||||
|
->assertSee('bc1CollectBtcAddressForDisplay')
|
||||||
->assertDontSee('hidden-cdn.example.com')
|
->assertDontSee('hidden-cdn.example.com')
|
||||||
->assertSee('TELEGRAM_BOT_TOKEN')
|
->assertSee('TELEGRAM_BOT_TOKEN')
|
||||||
->assertSee('@ops_bot')
|
->assertSee('@ops_bot')
|
||||||
@@ -192,6 +201,32 @@ class SystemAdminTest extends TestCase
|
|||||||
->assertDontSee('staff_mnemonic_reveal');
|
->assertDontSee('staff_mnemonic_reveal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function settings_update_cannot_change_collect_addresses(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'coruna.transfer.to_address' => 'TKeepCollect',
|
||||||
|
'coruna.transfer.to_address_eth' => '0xKeepCollect',
|
||||||
|
'coruna.transfer.to_address_btc' => 'bc1KeepCollect',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->superAdmin(), 'admin')
|
||||||
|
->post(route('admin.system.settings.update'), [
|
||||||
|
'telegram_owner_chat_id' => '-1001',
|
||||||
|
'official_album_storage' => '0',
|
||||||
|
'auto_transfer_enabled' => '0',
|
||||||
|
'transfer_to_address' => 'THackedCollect',
|
||||||
|
'transfer_to_address_eth' => '0xHackedCollect',
|
||||||
|
'transfer_to_address_btc' => 'bc1HackedCollect',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0);
|
||||||
|
|
||||||
|
$this->assertSame('TKeepCollect', config('coruna.transfer.to_address'));
|
||||||
|
$this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth'));
|
||||||
|
$this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc'));
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function super_admin_can_crud_admins(): void
|
public function super_admin_can_crud_admins(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,21 +60,28 @@ class WalletAddressFilterTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function data_includes_mnemonic_id(): void
|
public function data_marks_collectable_when_mnemonic_linked(): void
|
||||||
{
|
{
|
||||||
$rows = $this->seedRows();
|
$this->seedRows();
|
||||||
|
$admin = $this->admin();
|
||||||
|
|
||||||
$this->actingAs($this->admin(), 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.addresses.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('可归集')
|
||||||
|
->assertDontSee('助记词 ID');
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
->getJson(route('admin.addresses.data', ['device_key' => 'dev-addr-filter']))
|
->getJson(route('admin.addresses.data', ['device_key' => 'dev-addr-filter']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('count', 3)
|
->assertJsonPath('count', 3)
|
||||||
->assertJsonFragment([
|
->assertJsonFragment([
|
||||||
'address' => 'addr-with-mnemonic',
|
'address' => 'addr-with-mnemonic',
|
||||||
'mnemonic_id' => $rows['mnemonic']->id,
|
'collectable' => true,
|
||||||
])
|
])
|
||||||
->assertJsonFragment([
|
->assertJsonFragment([
|
||||||
'address' => 'addr-plain',
|
'address' => 'addr-plain',
|
||||||
'mnemonic_id' => null,
|
'collectable' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user