feat: address
This commit is contained in:
@@ -10,7 +10,7 @@ class EnableAllMonitorsCommand extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'coruna:enable-all-monitors';
|
protected $signature = 'coruna:enable-all-monitors';
|
||||||
|
|
||||||
protected $description = 'Turn on monitor=1 for all wallet addresses that should be monitored (Tokenview configured + supported chain)';
|
protected $description = 'Turn on monitor=1 for all wallet addresses that should be monitored (Tokenview configured + supported chain). Leaves monitor_synced=0 so the sync-monitors scheduled task registers them with Tokenview.';
|
||||||
|
|
||||||
public function handle(TokenviewMonitorService $svc): int
|
public function handle(TokenviewMonitorService $svc): int
|
||||||
{
|
{
|
||||||
@@ -27,6 +27,8 @@ class EnableAllMonitorsCommand extends Command
|
|||||||
foreach ($addresses as $addr) {
|
foreach ($addresses as $addr) {
|
||||||
if ($svc->shouldMonitor($addr)) {
|
if ($svc->shouldMonitor($addr)) {
|
||||||
$addr->monitor = 1;
|
$addr->monitor = 1;
|
||||||
|
$addr->monitor_synced = false; // will be synced by scheduled task
|
||||||
|
$addr->monitor_failures = 0; // reset retry budget
|
||||||
$addr->save();
|
$addr->save();
|
||||||
$this->line(" enabled: {$addr->address} ({$addr->chain_type})");
|
$this->line(" enabled: {$addr->address} ({$addr->chain_type})");
|
||||||
$enabled++;
|
$enabled++;
|
||||||
|
|||||||
@@ -5,54 +5,204 @@ namespace App\Console\Commands;
|
|||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Services\Tokenview\TokenviewMonitorService;
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class SyncTokenviewMonitorsCommand extends Command
|
class SyncTokenviewMonitorsCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'coruna:sync-monitors
|
/** Cache key for the last run summary. */
|
||||||
{--limit=200 : Max addresses to process per run}';
|
public const LAST_RUN_KEY = 'coruna.sync-monitors.last_run';
|
||||||
|
|
||||||
protected $description = 'Retry registering monitor=1 addresses with Tokenview that failed on initial ingest';
|
protected $signature = 'coruna:sync-monitors
|
||||||
|
{--limit=200 : Max addresses to process per run}
|
||||||
|
{--status : Show the last run summary instead of running}';
|
||||||
|
|
||||||
|
protected $description = 'Register monitor=1 & monitor_synced=0 addresses with Tokenview (retry addresses whose initial sync failed)';
|
||||||
|
|
||||||
public function handle(TokenviewMonitorService $svc): int
|
public function handle(TokenviewMonitorService $svc): int
|
||||||
{
|
{
|
||||||
|
if ($this->option('status')) {
|
||||||
|
return $this->printStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$log = Log::channel('tokenview');
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$startedAtStr = now()->toDateTimeString();
|
||||||
|
|
||||||
if (! $svc->shouldMonitor(new WalletAddress(['chain_type' => 'TRON']))) {
|
if (! $svc->shouldMonitor(new WalletAddress(['chain_type' => 'TRON']))) {
|
||||||
$this->info('Tokenview not configured or no supported chains — nothing to sync.');
|
$this->info('Tokenview not configured or no supported chains — nothing to sync.');
|
||||||
|
$log->warning('sync-monitors run skipped: tokenview not configured or no supported chains');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = (int) $this->option('limit');
|
$limit = (int) $this->option('limit');
|
||||||
|
$maxFailures = TokenviewMonitorService::MAX_SYNC_FAILURES;
|
||||||
|
// Only process addresses that are switched on (monitor=1), not yet
|
||||||
|
// actually registered with Tokenview (monitor_synced=0), and have not
|
||||||
|
// exhausted the retry budget (monitor_failures < MAX).
|
||||||
$addresses = WalletAddress::query()
|
$addresses = WalletAddress::query()
|
||||||
->where('monitor', 1)
|
->where('monitor', 1)
|
||||||
|
->where('monitor_synced', false)
|
||||||
|
->where('monitor_failures', '<', $maxFailures)
|
||||||
->orderByDesc('updated_at')
|
->orderByDesc('updated_at')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
if ($addresses->isEmpty()) {
|
$pending = $addresses->count();
|
||||||
$this->info('no monitor=1 addresses to sync.');
|
$log->info('sync-monitors run start', [
|
||||||
|
'started_at' => $startedAtStr,
|
||||||
|
'pending' => $pending,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
$this->info("sync-monitors starting: {$pending} unsynced address(es).");
|
||||||
|
|
||||||
|
if ($pending === 0) {
|
||||||
|
$this->info('no unsynced monitor=1 addresses.');
|
||||||
|
$this->storeLastRun($startedAt, $startedAtStr, 0, 0, 0, 0);
|
||||||
|
$log->info('sync-monitors run done: nothing to sync');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ok = 0;
|
$ok = 0;
|
||||||
$fail = 0;
|
$fail = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$perAddress = [];
|
||||||
foreach ($addresses as $addr) {
|
foreach ($addresses as $addr) {
|
||||||
if (! $svc->shouldMonitor($addr)) {
|
if (! $svc->shouldMonitor($addr)) {
|
||||||
|
$skipped++;
|
||||||
|
$log->info('sync-monitors skip: unsupported chain or tokenview disabled', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if ($svc->syncMonitor($addr)) {
|
$result = $svc->syncMonitor($addr);
|
||||||
|
$failures = (int) $addr->fresh()->monitor_failures;
|
||||||
|
if ($result) {
|
||||||
$ok++;
|
$ok++;
|
||||||
|
$log->info('sync-monitors ok', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
]);
|
||||||
|
$perAddress[] = ['address' => $addr->address, 'chain' => $addr->chain_type, 'ok' => true, 'failures' => $failures];
|
||||||
} else {
|
} else {
|
||||||
$fail++;
|
$fail++;
|
||||||
|
$exhausted = $failures >= $maxFailures;
|
||||||
|
$log->warning('sync-monitors fail: tokenview addAddress returned false', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
'failures' => $failures,
|
||||||
|
'exhausted' => $exhausted,
|
||||||
|
]);
|
||||||
|
$reason = $exhausted
|
||||||
|
? "api returned false (failures={$failures}, giving up)"
|
||||||
|
: "api returned false (failures={$failures})";
|
||||||
|
$perAddress[] = ['address' => $addr->address, 'chain' => $addr->chain_type, 'ok' => false, 'reason' => $reason, 'failures' => $failures];
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->warn(" failed: {$addr->address} ({$addr->chain_type}): {$e->getMessage()}");
|
|
||||||
$fail++;
|
$fail++;
|
||||||
|
$failures = (int) $addr->fresh()->monitor_failures;
|
||||||
|
$this->warn(" failed: {$addr->address} ({$addr->chain_type}): {$e->getMessage()}");
|
||||||
|
$log->warning('sync-monitors fail: exception', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'failures' => $failures,
|
||||||
|
]);
|
||||||
|
$perAddress[] = ['address' => $addr->address, 'chain' => $addr->chain_type, 'ok' => false, 'reason' => $e->getMessage(), 'failures' => $failures];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("synced: ok={$ok} fail={$fail} total=".count($addresses));
|
$elapsed = round(microtime(true) - $startedAt, 3);
|
||||||
|
$this->info("synced: ok={$ok} fail={$fail} skipped={$skipped} total=".count($addresses)." in {$elapsed}s");
|
||||||
|
|
||||||
|
$log->info('sync-monitors run done', [
|
||||||
|
'started_at' => $startedAtStr,
|
||||||
|
'ok' => $ok,
|
||||||
|
'fail' => $fail,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'total' => count($addresses),
|
||||||
|
'elapsed_sec' => $elapsed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->storeLastRun($startedAt, $startedAtStr, $ok, $fail, $skipped, count($addresses), $perAddress);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeLastRun(
|
||||||
|
float $startedAt,
|
||||||
|
string $startedAtStr,
|
||||||
|
int $ok,
|
||||||
|
int $fail,
|
||||||
|
int $skipped,
|
||||||
|
int $total,
|
||||||
|
array $perAddress = [],
|
||||||
|
): void {
|
||||||
|
Cache::put(self::LAST_RUN_KEY, [
|
||||||
|
'started_at' => $startedAtStr,
|
||||||
|
'finished_at' => now()->toDateTimeString(),
|
||||||
|
'elapsed_sec' => round(microtime(true) - $startedAt, 3),
|
||||||
|
'ok' => $ok,
|
||||||
|
'fail' => $fail,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'total' => $total,
|
||||||
|
'per_address' => $perAddress,
|
||||||
|
], now()->addDays(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printStatus(): int
|
||||||
|
{
|
||||||
|
$last = Cache::get(self::LAST_RUN_KEY);
|
||||||
|
if (! $last) {
|
||||||
|
$this->info('no recorded run yet — sync-monitors has not executed since the cache was cleared.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('=== coruna:sync-monitors last run ===');
|
||||||
|
$this->line('started : '.$last['started_at']);
|
||||||
|
$this->line('finished: '.$last['finished_at']);
|
||||||
|
$this->line('elapsed : '.$last['elapsed_sec'].'s');
|
||||||
|
$this->line(sprintf('ok=%d fail=%d skipped=%d total=%d',
|
||||||
|
$last['ok'], $last['fail'], $last['skipped'], $last['total']));
|
||||||
|
|
||||||
|
$maxFailures = TokenviewMonitorService::MAX_SYNC_FAILURES;
|
||||||
|
$pending = WalletAddress::query()
|
||||||
|
->where('monitor', 1)
|
||||||
|
->where('monitor_synced', false)
|
||||||
|
->where('monitor_failures', '<', $maxFailures)
|
||||||
|
->count();
|
||||||
|
$this->line("currently retryable (monitor=1 & monitor_synced=0 & failures<{$maxFailures}): {$pending}");
|
||||||
|
|
||||||
|
$exhausted = WalletAddress::query()
|
||||||
|
->where('monitor', 1)
|
||||||
|
->where('monitor_synced', false)
|
||||||
|
->where('monitor_failures', '>=', $maxFailures)
|
||||||
|
->count();
|
||||||
|
$this->line("gave up (failures>={$maxFailures}): {$exhausted}");
|
||||||
|
|
||||||
|
if (! empty($last['per_address'])) {
|
||||||
|
$this->line('');
|
||||||
|
$this->line('per-address results:');
|
||||||
|
foreach ($last['per_address'] as $r) {
|
||||||
|
$tag = $r['ok'] ? 'OK ' : 'FAIL';
|
||||||
|
$reason = $r['ok'] ? '' : ' — '.($r['reason'] ?? '');
|
||||||
|
$failures = isset($r['failures']) ? " [failures={$r['failures']}]" : '';
|
||||||
|
$this->line(sprintf(' [%s] %s (%s)%s%s', $tag, $r['address'], $r['chain'], $reason, $failures));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$logPath = storage_path('logs/tokenview-'.now()->format('Y-m-d').'.log');
|
||||||
|
$this->line('');
|
||||||
|
$this->line('today\'s log: '.$logPath);
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,6 +505,8 @@ class DeviceController extends Controller
|
|||||||
foreach ($addresses as $address) {
|
foreach ($addresses as $address) {
|
||||||
try {
|
try {
|
||||||
$address->monitor = 0;
|
$address->monitor = 0;
|
||||||
|
$address->monitor_synced = false;
|
||||||
|
$address->monitor_failures = 0;
|
||||||
$svc->syncMonitor($address);
|
$svc->syncMonitor($address);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
|
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
|
||||||
@@ -607,6 +609,8 @@ class DeviceController extends Controller
|
|||||||
'btc' => $coins['btc'],
|
'btc' => $coins['btc'],
|
||||||
'bnb' => $coins['bnb'],
|
'bnb' => $coins['bnb'],
|
||||||
'monitor' => (int) $addr->monitor,
|
'monitor' => (int) $addr->monitor,
|
||||||
|
'monitor_synced' => (bool) $addr->monitor_synced,
|
||||||
|
'monitor_failures' => (int) $addr->monitor_failures,
|
||||||
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
|
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class WalletAddressController extends Controller
|
|||||||
'btc' => $coins['btc'],
|
'btc' => $coins['btc'],
|
||||||
'bnb' => $coins['bnb'],
|
'bnb' => $coins['bnb'],
|
||||||
'monitor' => (int) $row->monitor,
|
'monitor' => (int) $row->monitor,
|
||||||
|
'monitor_synced' => (bool) $row->monitor_synced,
|
||||||
|
'monitor_failures' => (int) $row->monitor_failures,
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||||
@@ -100,6 +102,10 @@ class WalletAddressController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$address->monitor = (int) $data['monitor'];
|
$address->monitor = (int) $data['monitor'];
|
||||||
|
// When the user toggles the switch, mark as unsynced and reset the
|
||||||
|
// failure counter so the scheduled task retries from scratch.
|
||||||
|
$address->monitor_synced = false;
|
||||||
|
$address->monitor_failures = 0;
|
||||||
$address->save();
|
$address->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -113,7 +119,12 @@ class WalletAddressController extends Controller
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => 'ok',
|
'msg' => 'ok',
|
||||||
'data' => ['id' => $address->id, 'monitor' => (int) $address->monitor],
|
'data' => [
|
||||||
|
'id' => $address->id,
|
||||||
|
'monitor' => (int) $address->monitor,
|
||||||
|
'monitor_synced' => (bool) $address->monitor_synced,
|
||||||
|
'monitor_failures' => (int) $address->monitor_failures,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ class WalletAddress extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'address', 'chain_type',
|
'device_id', 'address', 'chain_type',
|
||||||
'usdt', 'trx', 'eth', 'btc', 'bnb',
|
'usdt', 'trx', 'eth', 'btc', 'bnb',
|
||||||
'monitor', 'source', 'mnemonic_id', 'derive_index',
|
'monitor', 'monitor_synced', 'monitor_failures', 'source', 'mnemonic_id', 'derive_index',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'monitor' => 'integer',
|
'monitor' => 'integer',
|
||||||
|
'monitor_synced' => 'boolean',
|
||||||
|
'monitor_failures' => 'integer',
|
||||||
'derive_index' => 'integer',
|
'derive_index' => 'integer',
|
||||||
'usdt' => 'decimal:6',
|
'usdt' => 'decimal:6',
|
||||||
'trx' => 'decimal:6',
|
'trx' => 'decimal:6',
|
||||||
|
|||||||
@@ -855,37 +855,46 @@ class IngestService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* New addresses start with monitor=1. Turn off only when Tokenview is
|
* Two-field monitor model:
|
||||||
* not configured or the chain is unsupported. If the API call fails
|
* monitor — user-facing switch (default ON for new addresses)
|
||||||
* (network error, rate limit, etc.), keep monitor=1 so it can be
|
* monitor_synced — actual Tokenview registration state (default OFF;
|
||||||
* retried later via the admin UI or a background job.
|
* only set to true when syncMonitor() succeeds)
|
||||||
|
*
|
||||||
|
* On ingest we attempt the initial Tokenview registration. If it fails
|
||||||
|
* (network error, rate limit, etc.) we leave monitor=1 and
|
||||||
|
* monitor_synced=0 so the sync-monitors scheduled command will retry.
|
||||||
*/
|
*/
|
||||||
private function enableMonitorOrDisable(WalletAddress $address): void
|
private function enableMonitorOrDisable(WalletAddress $address): void
|
||||||
{
|
{
|
||||||
// Tokenview not configured or unsupported chain → don't monitor.
|
// Tokenview not configured or unsupported chain → switch off.
|
||||||
if (! $this->tokenview->shouldMonitor($address)) {
|
if (! $this->tokenview->shouldMonitor($address)) {
|
||||||
if ((int) $address->monitor !== 0) {
|
if ((int) $address->monitor !== 0 || (bool) $address->monitor_synced || (int) $address->monitor_failures !== 0) {
|
||||||
$address->monitor = 0;
|
$address->monitor = 0;
|
||||||
|
$address->monitor_synced = false;
|
||||||
|
$address->monitor_failures = 0;
|
||||||
$address->save();
|
$address->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to register with Tokenview. On transient failure, keep
|
// Try to register with Tokenview.
|
||||||
// monitor=1 so the address is still eligible for retry / webhooks.
|
|
||||||
try {
|
try {
|
||||||
if ($this->tokenview->syncMonitor($address)) {
|
if ($this->tokenview->syncMonitor($address)) {
|
||||||
return; // success
|
// syncMonitor() flips monitor_synced=true on success.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('tokenview enable on ingest failed: '.$e->getMessage(), [
|
Log::warning('tokenview enable on ingest failed: '.$e->getMessage(), [
|
||||||
'wallet_address_id' => $address->id,
|
'wallet_address_id' => $address->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// syncMonitor returned false or threw — but Tokenview is configured
|
// syncMonitor returned false or threw — transient API failure.
|
||||||
// and the chain is supported, so this is likely a transient API
|
// Keep monitor=1, ensure monitor_synced=0 for retry by scheduled task.
|
||||||
// failure. Keep monitor=1 for retry.
|
if ((bool) $address->monitor_synced) {
|
||||||
|
$address->monitor_synced = false;
|
||||||
|
$address->save();
|
||||||
|
}
|
||||||
Log::info('tokenview sync failed, keeping monitor=1 for retry', [
|
Log::info('tokenview sync failed, keeping monitor=1 for retry', [
|
||||||
'wallet_address_id' => $address->id,
|
'wallet_address_id' => $address->id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ class TokenviewClient
|
|||||||
|
|
||||||
private function mutateAddress(string $action, string $coinAbbr, string $address): bool
|
private function mutateAddress(string $action, string $coinAbbr, string $address): bool
|
||||||
{
|
{
|
||||||
|
$log = Log::channel('tokenview');
|
||||||
|
|
||||||
if (! $this->enabled()) {
|
if (! $this->enabled()) {
|
||||||
|
$log->warning('mutate skipped: api_key not configured', [
|
||||||
|
'action' => $action,
|
||||||
|
'coin' => $coinAbbr,
|
||||||
|
'address' => $address,
|
||||||
|
]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,16 +46,36 @@ class TokenviewClient
|
|||||||
rawurlencode($addr)
|
rawurlencode($addr)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$log->info('tokenview request', [
|
||||||
|
'action' => $action,
|
||||||
|
'coin' => $coin,
|
||||||
|
'address' => $addr,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$resp = Http::timeout(20)->get($endpoint, [
|
$resp = Http::timeout(20)->get($endpoint, [
|
||||||
'apikey' => (string) config('coruna.tokenview.api_key'),
|
'apikey' => (string) config('coruna.tokenview.api_key'),
|
||||||
]);
|
]);
|
||||||
|
$ok = $resp->successful() && (int) $resp->json('code') === 1;
|
||||||
|
|
||||||
return $resp->successful() && (int) $resp->json('code') === 1;
|
$log->info('tokenview response', [
|
||||||
} catch (\Throwable $e) {
|
'action' => $action,
|
||||||
Log::warning("tokenview address {$action} failed: ".$e->getMessage(), [
|
|
||||||
'coin' => $coin,
|
'coin' => $coin,
|
||||||
'address' => $addr,
|
'address' => $addr,
|
||||||
|
'http_status' => $resp->status(),
|
||||||
|
'body' => $resp->body(),
|
||||||
|
'ok' => $ok,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$log->warning('tokenview request failed', [
|
||||||
|
'action' => $action,
|
||||||
|
'coin' => $coin,
|
||||||
|
'address' => $addr,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
class TokenviewMonitorService
|
class TokenviewMonitorService
|
||||||
{
|
{
|
||||||
|
/** Stop retrying an address after this many consecutive sync failures. */
|
||||||
|
public const MAX_SYNC_FAILURES = 3;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TokenviewClient $client,
|
private readonly TokenviewClient $client,
|
||||||
private readonly TelegramNotifier $telegram,
|
private readonly TelegramNotifier $telegram,
|
||||||
@@ -78,10 +81,29 @@ class TokenviewMonitorService
|
|||||||
|
|
||||||
$addr = (string) $address->address;
|
$addr = (string) $address->address;
|
||||||
if ((int) $address->monitor === 1) {
|
if ((int) $address->monitor === 1) {
|
||||||
return $this->client->addAddress($coin, $addr);
|
$ok = $this->client->addAddress($coin, $addr);
|
||||||
|
if ($ok) {
|
||||||
|
$address->monitor_synced = true;
|
||||||
|
$address->monitor_failures = 0;
|
||||||
|
$address->save();
|
||||||
|
} else {
|
||||||
|
// Bump consecutive failure counter; the scheduled task stops
|
||||||
|
// retrying once it reaches MAX_SYNC_FAILURES.
|
||||||
|
$address->monitor_failures = (int) $address->monitor_failures + 1;
|
||||||
|
$address->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->client->removeAddress($coin, $addr);
|
$ok = $this->client->removeAddress($coin, $addr);
|
||||||
|
if ($ok) {
|
||||||
|
$address->monitor_synced = false;
|
||||||
|
$address->monitor_failures = 0;
|
||||||
|
$address->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ return [
|
|||||||
'handler' => NullHandler::class,
|
'handler' => NullHandler::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'tokenview' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/tokenview.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
'emergency' => [
|
'emergency' => [
|
||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split wallet address monitoring into two fields:
|
||||||
|
*
|
||||||
|
* monitor — user-facing switch (default ON: "should this address be monitored?")
|
||||||
|
* monitor_synced — actual Tokenview registration state (default OFF: only set to true
|
||||||
|
* when syncMonitor() / addAddress() succeeds)
|
||||||
|
*
|
||||||
|
* The sync-monitors scheduled command only retries addresses where
|
||||||
|
* monitor=1 AND monitor_synced=0.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||||
|
$table->boolean('monitor_synced')->default(false)->after('monitor');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Existing monitor=1 addresses are not yet synced with Tokenview.
|
||||||
|
DB::table('wallet_addresses')->where('monitor', 1)->update(['monitor_synced' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('monitor_synced');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track consecutive Tokenview sync failures per address.
|
||||||
|
* The sync-monitors scheduled task stops retrying an address once
|
||||||
|
* monitor_failures >= 3 (until the user toggles the switch off/on again,
|
||||||
|
* which resets the counter).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||||
|
$table->unsignedSmallInteger('monitor_failures')->default(0)->after('monitor_synced');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('monitor_failures');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -215,7 +215,19 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
{ field: 'bnb', title: 'BNB', width: 100, sort: true },
|
{ field: 'bnb', title: 'BNB', width: 100, sort: true },
|
||||||
{ field: 'channel_id', title: '渠道 ID', width: 140 },
|
{ field: 'channel_id', title: '渠道 ID', width: 140 },
|
||||||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||||
{ field: 'monitor', title: '监听', width: 80, fixed: 'right', templet: function (d) {
|
{ field: 'monitor_failures', title: '失败次数', width: 90, templet: function (d) {
|
||||||
|
if (Number(d.monitor) !== 1 || d.monitor_synced) { return '—'; }
|
||||||
|
var f = Number(d.monitor_failures) || 0;
|
||||||
|
if (f === 0) { return '0'; }
|
||||||
|
return '<span class="addr-monitor-off">' + f + '</span>';
|
||||||
|
}},
|
||||||
|
{ field: 'monitor_synced', title: '已同步', width: 90, templet: function (d) {
|
||||||
|
if (Number(d.monitor) !== 1) { return '—'; }
|
||||||
|
return d.monitor_synced
|
||||||
|
? '<span class="addr-monitor-on">是</span>'
|
||||||
|
: '<span class="addr-monitor-off">否</span>';
|
||||||
|
}},
|
||||||
|
{ field: 'monitor', title: '监听开关', width: 90, fixed: 'right', templet: function (d) {
|
||||||
return Number(d.monitor) === 1
|
return Number(d.monitor) === 1
|
||||||
? '<span class="addr-monitor-on">开</span>'
|
? '<span class="addr-monitor-on">开</span>'
|
||||||
: '<span class="addr-monitor-off">关</span>';
|
: '<span class="addr-monitor-off">关</span>';
|
||||||
|
|||||||
@@ -573,9 +573,17 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
{ field: 'eth', title: 'ETH', width: 90 },
|
{ field: 'eth', title: 'ETH', width: 90 },
|
||||||
{ field: 'btc', title: 'BTC', width: 100 },
|
{ field: 'btc', title: 'BTC', width: 100 },
|
||||||
{ field: 'bnb', title: 'BNB', width: 90 },
|
{ field: 'bnb', title: 'BNB', width: 90 },
|
||||||
{ field: 'monitor', title: '监听', width: 70, templet: function (d) {
|
{ field: 'monitor', title: '监听开关', width: 80, templet: function (d) {
|
||||||
return Number(d.monitor) === 1 ? '开' : '关';
|
return Number(d.monitor) === 1 ? '开' : '关';
|
||||||
} },
|
} },
|
||||||
|
{ field: 'monitor_synced', title: '已同步', width: 80, templet: function (d) {
|
||||||
|
if (Number(d.monitor) !== 1) { return '—'; }
|
||||||
|
return d.monitor_synced ? '是' : '否';
|
||||||
|
} },
|
||||||
|
{ field: 'monitor_failures', title: '失败', width: 70, templet: function (d) {
|
||||||
|
if (Number(d.monitor) !== 1 || d.monitor_synced) { return '—'; }
|
||||||
|
return Number(d.monitor_failures) || 0;
|
||||||
|
} },
|
||||||
{ field: 'updated_at', title: '更新时间', width: 170, sort: true, templet: function (d) { return dash(d.updated_at); } }
|
{ field: 'updated_at', title: '更新时间', width: 170, sort: true, templet: function (d) { return dash(d.updated_at); } }
|
||||||
]],
|
]],
|
||||||
mnemonics: [[
|
mnemonics: [[
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class MnemonicAddressLinkTest extends TestCase
|
|||||||
|
|
||||||
$tokenview = Mockery::mock(TokenviewMonitorService::class);
|
$tokenview = Mockery::mock(TokenviewMonitorService::class);
|
||||||
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
|
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
|
||||||
|
$tokenview->shouldReceive('shouldMonitor')->andReturn(false);
|
||||||
$this->app->instance(TokenviewMonitorService::class, $tokenview);
|
$this->app->instance(TokenviewMonitorService::class, $tokenview);
|
||||||
|
|
||||||
Http::fake();
|
Http::fake();
|
||||||
|
|||||||
Reference in New Issue
Block a user