Sfoglia il codice sorgente

无法显示开奖

doge 35 minuti fa
parent
commit
1ba5197013

+ 7 - 2
README.md

@@ -155,7 +155,13 @@ MAIL_EXP=600  # 邮件验证码有效期(秒)
     - php artisan queue:table
     - php artisan migrate
     
-    (1) 队列进程:php artisan queue:work --queue   #【此进程启动或重启后,都需手动执行一次命令:php artisan five_task】
+    (1) 队列进程:php artisan queue:work --queue=default --sleep=1 --timeout=85
+
+    队列连接需使用 database 或 redis,常驻 queue:work 需要 PHP CLI 的 pcntl 可用,并由宝塔/Supervisor 自动重启。
+    当前任务最大超时为 85 秒,必须小于队列连接的 retry_after(默认 90 秒)。
+    首次启动或确认循环任务已丢失时,执行一次 php artisan five_task;正常重启队列进程不需要再次执行。
+    不要同时定时访问 /admin/fake 并启动 five_task,两者都会生成虚拟投注。
+    耗时诊断与 queue:listen 超时处理见 [PC28队列与投注通知排查](docs/后端/PC28队列与投注通知排查.md)。
 
 
 ## 六、执行脚本(添加计划任务)
@@ -210,4 +216,3 @@ MAIL_EXP=600  # 邮件验证码有效期(秒)
     cd 开奖图片目录 && rm -rf ./*
 
 
-

+ 30 - 9
app/Jobs/FiveSecondTaskJob.php

@@ -8,7 +8,6 @@ use App\Services\LogService;
 use App\Services\PcIssueService;
 use Exception;
 use Illuminate\Bus\Queueable;
-use Illuminate\Contracts\Queue\ShouldBeUnique;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
@@ -26,9 +25,9 @@ class FiveSecondTaskJob implements ShouldQueue
     public $tries = 3;
 
     /**
-     * 任务超时时间(秒)
+     * 任务超时时间(秒),需小于队列的 retry_after。
      */
-    public $timeout = 30;
+    public $timeout = 85;
 
 
     /**
@@ -49,21 +48,20 @@ class FiveSecondTaskJob implements ShouldQueue
     public function handle()
     {
         try {
-//            Log::error('🚀 开始执行15秒任务: ' . now());
             //(极速28)自定义开奖
-            PcIssueService::index();
+            $this->runTimedStep('极速28开奖处理', fn () => PcIssueService::index());
 
             // 提前60秒秒提醒封盘倒数
-            IssueService::syncCountdownIssue();
+            $this->runTimedStep('加拿大28封盘倒计时', fn () => IssueService::syncCountdownIssue());
 
             // 停止下注  //封盘
-            IssueService::syncCloseIssue();
+            $this->runTimedStep('加拿大28封盘', fn () => IssueService::syncCloseIssue());
 
             // 获取最新的期号
-            IssueService::getLatestIssue();
+            $this->runTimedStep('加拿大28开奖同步', fn () => IssueService::getLatestIssue());
 
             //随机虚拟投注
-           BetService::randomVirtualBetting(3);
+            $this->runTimedStep('虚拟投注', fn () => BetService::randomVirtualBetting(3));
 
             // 重要:使用类名而不是 self(),避免递归
             FiveSecondTaskJob::dispatch()->delay(now()->addSeconds(15));
@@ -81,6 +79,29 @@ class FiveSecondTaskJob implements ShouldQueue
         }
     }
 
+    private function runTimedStep(string $step, callable $callback): void
+    {
+        $startedAt = microtime(true);
+        $completed = false;
+        $context = [
+            'step' => $step,
+            'job_id' => $this->job?->getJobId(),
+            'attempt' => $this->attempts(),
+            'process_id' => getmypid(),
+        ];
+        Log::channel('issue')->info('PC28任务步骤开始', $context);
+
+        try {
+            $callback();
+            $completed = true;
+        } finally {
+            Log::channel('issue')->info('PC28任务步骤结束', $context + [
+                'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 1),
+                'status' => $completed ? 'completed' : 'failed',
+            ]);
+        }
+    }
+
     // 可选:失败处理
     public function failed(\Throwable $exception)
     {

+ 74 - 25
app/Services/IssueService.php

@@ -14,6 +14,7 @@ use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
+use GuzzleHttp\TransferStats;
 
 use App\Services\GameplayRuleService;
 use App\Constants\GameplayRuleEnum;
@@ -897,34 +898,82 @@ class IssueService extends BaseService
     private static function fetchPlayNowKenoResult()
     {
         $url = self::getPlayNowConfig('keno_url', self::PLAYNOW_KENO_URL);
-        $response = Http::timeout(25)
-            ->connectTimeout(10)
-            ->withHeaders([
-                'Accept' => 'application/json, text/plain, */*',
-                'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
-            ])
-            ->withOptions([
-                'proxy' => self::getPlayNowProxyUrl(),
-                'curl' => [
-                    CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
-                ],
-            ])
-            ->get($url);
-
-        Log::channel('issue')->info('PlayNow接口响应', [
-            'status' => $response->status(),
-            'url' => $url,
-        ] + self::getPlayNowProxyLogContext());
+        $startedAt = microtime(true);
+        $networkTimings = [];
+        $response = null;
 
-        if (!$response->successful()) {
-            Log::channel('issue')->info('PlayNow接口请求失败', [
+        try {
+            $response = Http::timeout(25)
+                ->connectTimeout(10)
+                ->withHeaders([
+                    'Accept' => 'application/json, text/plain, */*',
+                    'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
+                ])
+                ->withOptions([
+                    'proxy' => self::getPlayNowProxyUrl(),
+                    'curl' => [
+                        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
+                    ],
+                ])
+                ->withMiddleware(self::playNowTimingMiddleware($networkTimings))
+                ->get($url);
+
+            Log::channel('issue')->info('PlayNow接口响应', [
                 'status' => $response->status(),
-                'body' => substr($response->body(), 0, 500),
-            ]);
-            return null;
+                'url' => $url,
+            ] + self::getPlayNowProxyLogContext());
+
+            if (!$response->successful()) {
+                Log::channel('issue')->info('PlayNow接口请求失败', [
+                    'status' => $response->status(),
+                    'body' => substr($response->body(), 0, 500),
+                ]);
+                return null;
+            }
+
+            return $response->json();
+        } finally {
+            // cURL 时间为自请求开始的累计值;通过代理时包含代理连接的耗时。
+            Log::channel('issue')->info('PlayNow请求耗时', [
+                'target_host' => parse_url($url, PHP_URL_HOST),
+                'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 1),
+                'http_status' => $response?->status(),
+                'network_timings_ms' => $networkTimings,
+            ] + self::getPlayNowProxyLogContext());
         }
+    }
 
-        return $response->json();
+    private static function playNowTimingMiddleware(array &$networkTimings): callable
+    {
+        return function (callable $handler) use (&$networkTimings) {
+            return function ($request, array $options) use ($handler, &$networkTimings) {
+                // Laravel 会设置自己的 on_stats;在最终请求选项中保留并追加记录。
+                $previousOnStats = $options['on_stats'] ?? null;
+                $options['on_stats'] = function (TransferStats $stats) use ($previousOnStats, &$networkTimings) {
+                    if (is_callable($previousOnStats)) {
+                        $previousOnStats($stats);
+                    }
+                    $handlerStats = $stats->getHandlerStats();
+                    foreach ([
+                        'namelookup_time' => 'dns_ms',
+                        'connect_time' => 'connect_ms',
+                        'appconnect_time' => 'tls_ms',
+                        'starttransfer_time' => 'first_byte_ms',
+                        'total_time' => 'total_ms',
+                    ] as $source => $target) {
+                        if (isset($handlerStats[$source])) {
+                            $networkTimings[$target] = round($handlerStats[$source] * 1000, 1);
+                        }
+                    }
+                    $errorCode = $stats->getHandlerErrorData();
+                    if (is_int($errorCode)) {
+                        $networkTimings['curl_error_code'] = $errorCode;
+                    }
+                };
+
+                return $handler($request, $options);
+            };
+        };
     }
 
     private static function convertPlayNowDrawDateToBeijing($drawDate)
@@ -1446,4 +1495,4 @@ class IssueService extends BaseService
         self::sendMessage($chatId, '', [], url($recordImage));
         // dispatch(new SendTelegramMessageJob('', [], url($recordImage)));
     }
-}
+}

+ 89 - 30
app/Services/LotteryImageService.php

@@ -4,9 +4,11 @@ namespace App\Services;
 
 use App\Constants\Util;
 use App\Models\Config;
-use App\Models\Message;
 use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
+use Symfony\Component\Process\Exception\ProcessTimedOutException;
+use Symfony\Component\Process\Process;
 
 class LotteryImageService
 {
@@ -18,41 +20,98 @@ class LotteryImageService
      */
     public function generate(array $records): string
     {
-        // ========= 1. 生成HTML内容 =========
-        $lang = App::getLocale();
-        $group_language = Config::where('field', 'group_language')->first()->val;
-        App::setLocale($group_language);
-        $html = $this->buildHtml($records);
-        App::setLocale($lang);
-        // ========= 2. 保存HTML到临时文件 =========
-        $htmlPath = storage_path("app/lottery_temp.html");
-//        $htmlPath = base_path() . "/public/static/html/lottery_temp.html";
-//        $htmlPath = base_path() . "/public/static/html/lottery_temp_{$group_language}.html";
-        file_put_contents($htmlPath, $html);
-
-        // ========= 3. 输出图片路径 =========
-        $fileName = 'lottery_' . time() . '.png';
+        $html = $this->buildLocalizedHtml($records);
+        $fileName = 'lottery_' . bin2hex(random_bytes(16)) . '.png';
         $outputPath = storage_path('app/public/lottery/' . $fileName);
-        if (!is_dir(dirname($outputPath))) {
-            mkdir(dirname($outputPath), 0755, true);
-        }
+        $htmlPath = null;
+        $process = null;
+        $completed = false;
+        $timeout = max(1, (float) config('services.lottery_image.timeout', 20));
+        $startedAt = microtime(true);
+
+        try {
+            if (!is_dir(dirname($outputPath)) && !mkdir(dirname($outputPath), 0755, true) && !is_dir(dirname($outputPath))) {
+                throw new \RuntimeException('无法创建开奖图片目录');
+            }
+
+            $htmlPath = tempnam(storage_path('app'), 'lottery_');
+            if ($htmlPath === false) {
+                throw new \RuntimeException('无法创建开奖图片临时HTML');
+            }
+            $temporaryHtmlPath = $htmlPath . '.html';
+            if (!rename($htmlPath, $temporaryHtmlPath)) {
+                throw new \RuntimeException('无法创建开奖图片临时HTML文件');
+            }
+            $htmlPath = $temporaryHtmlPath;
+            if (file_put_contents($htmlPath, $html) === false) {
+                throw new \RuntimeException('无法保存开奖图片临时HTML');
+            }
+
+            $process = new Process([
+                (string) config('services.lottery_image.binary', 'wkhtmltoimage'),
+                '--quality',
+                '100',
+                $htmlPath,
+                $outputPath,
+            ]);
+            $process->setTimeout($timeout);
+            $process->run();
+
+            if (!$process->isSuccessful() || !is_file($outputPath) || filesize($outputPath) === 0) {
+                throw new \RuntimeException('渲染进程未生成有效图片');
+            }
 
-        // ========= 4. 调用 wkhtmltoimage =========
-        // --quality 100 提高图片质量,可选
-        $cmd = sprintf(
-            'wkhtmltoimage --quality 100 %s %s',
-            escapeshellarg($htmlPath),
-            escapeshellarg($outputPath)
-        );
+            $url = Util::replacePartInUrl(Storage::url('lottery/' . $fileName));
+            Log::channel('issue')->info('开奖记录图片生成成功', $this->renderLogContext($process, $startedAt, $timeout));
+            $completed = true;
 
-        exec($cmd, $output, $code);
+            return $url;
+        } catch (\Throwable $exception) {
+            $timedOut = $exception instanceof ProcessTimedOutException;
+            Log::channel('issue')->error('开奖记录图片生成失败', $this->renderLogContext($process, $startedAt, $timeout) + [
+                'reason' => $timedOut ? 'timeout' : 'render_failed',
+                'error_type' => get_class($exception),
+            ]);
 
-        if ($code !== 0 || !file_exists($outputPath)) {
-            throw new \Exception('图片生成失败,请检查是否已安装 wkhtmltoimage 并可在命令行执行');
+            $message = $timedOut
+                ? '图片生成超时:渲染超过 ' . $timeout . ' 秒'
+                : '图片生成失败,请检查 wkhtmltoimage、存储目录权限和开奖记录图片日志';
+            throw new \RuntimeException($message, 0, $exception);
+        } finally {
+            if ($htmlPath && is_file($htmlPath)) {
+                unlink($htmlPath);
+            }
+            if (!$completed && is_file($outputPath)) {
+                unlink($outputPath);
+            }
+        }
+    }
+
+    protected function buildLocalizedHtml(array $records): string
+    {
+        $lang = App::getLocale();
+        $group_language = Config::where('field', 'group_language')->first()->val;
+        try {
+            App::setLocale($group_language);
+            return $this->buildHtml($records);
+        } finally {
+            App::setLocale($lang);
+        }
+    }
+
+    private function renderLogContext(?Process $process, float $startedAt, float $timeout): array
+    {
+        $output = '';
+        if ($process && $process->isStarted()) {
+            $output = trim($process->getErrorOutput() . "\n" . $process->getOutput());
         }
 
-        // ========= 5. 返回访问URL =========
-        return Util::replacePartInUrl(Storage::url('lottery/' . $fileName));
+        return [
+            'duration_seconds' => round(microtime(true) - $startedAt, 3),
+            'timeout_seconds' => $timeout,
+            'exit_code' => $process ? $process->getExitCode() : null,
+            'renderer_output' => substr($output, 0, 2000),
+        ];
     }
 
     /**

+ 5 - 0
config/services.php

@@ -71,6 +71,11 @@ return [
         'verify_receipt_uri' => env('APPLE_VERIFY_RECEIPT_URI', ''),
     ],
 
+    'lottery_image' => [
+        'binary' => env('LOTTERY_IMAGE_BINARY', 'wkhtmltoimage'),
+        'timeout' => env('LOTTERY_IMAGE_TIMEOUT', 20),
+    ],
+
     'playnow' => [
         'keno_url' => env('PLAYNOW_KENO_URL', 'https://www.playnow.com/services2/keno/nextdraw'),
         'timezone' => env('PLAYNOW_TIMEZONE', 'America/Vancouver'),

+ 56 - 0
docs/后端/PC28队列与投注通知排查.md

@@ -0,0 +1,56 @@
+# PC28 队列与投注通知排查
+
+`five_task` 只投递第一个循环任务,随后 `FiveSecondTaskJob` 每轮处理完成后延迟 15 秒投递下一轮。`queue:listen` / `queue:work` 负责消费任务。重启队列进程时,数据库或 Redis 中已有的延迟任务会保留;只有确认循环任务缺失时才补执行一次 `five_task`。
+
+## 耗时日志
+
+同步代码到服务器后观察当天的日志:
+
+```bash
+tail -f storage/logs/issue-$(date +%F).log
+```
+
+| 日志 | 判断依据 |
+| --- | --- |
+| `PC28任务步骤开始` / `PC28任务步骤结束` | `step` 对应极速开奖、加拿大倒计时/封盘/开奖同步、虚拟投注;`elapsed_ms` 是该步骤耗时,`status` 表示是否正常返回。正常返回仍可能因业务条件跳过投注,不等于已发出群消息。 |
+| `开奖记录图片生成成功` / `开奖记录图片生成失败` | `duration_seconds` 为渲染耗时;失败时记录退出码、`reason` 和截断的渲染输出。默认渲染上限为 20 秒。 |
+| `PlayNow请求耗时` | `elapsed_ms` 为请求总耗时,`http_status` 为 HTTP 状态;连接失败时状态为空,仍会保留耗时。 |
+| `Telegram群消息发送成功` / `Telegram群消息发送失败` | 区分投注循环已执行与消息是否发送成功。失败可能只写日志,不进入 `failed_jobs`。 |
+
+若同一 `job_id`、`attempt` 的步骤最后只出现“开始”,没有对应“结束”,则说明终止发生在该步骤内;任务被再次消费时要区分重试次数。图片渲染的 `Done` 仅表示一次渲染结束;整个队列任务完成需要看到 `FiveSecondTaskJob ... DONE`。
+
+`PlayNow请求耗时` 中的 `network_timings_ms` 记录 cURL 自请求开始的累计时间:`dns_ms`、`connect_ms`、`tls_ms`、`first_byte_ms`、`total_ms`。它们不是可以相加的独立阶段耗时。启用代理时,DNS、连接等统计包含代理连接过程,不能单凭该字段判断目标域名的 DNS 故障。`curl_error_code` 可辅助判断失败类型;日志不记录代理用户名和密码。
+
+## 60 秒超时
+
+`queue:listen` 会为每个任务创建 `queue:work --once` 子进程,监听器自身默认只允许该子进程运行 60 秒。这个外层上限不会被 Job 的 `$timeout` 覆盖。
+
+保留现有 `queue:listen` 的情况下,可在宝塔中将原守护命令改为:
+
+```bash
+/www/server/php/82/bin/php artisan queue:listen --queue=default --sleep=1 --timeout=85
+```
+
+这是缓解外层 60 秒超时的设置,仍需用耗时日志确认慢步骤;不要把超时提高到超过 `retry_after`,否则仍在运行的任务可能被其他进程再次消费。
+
+队列中已经保存的旧任务可能仍带有 `timeout=30`,不会随类默认值调整为 85。部署时先保持 `queue:listen --timeout=85`,让现有任务正常完成一轮并安排新版任务,确认连续两轮都有步骤结束与 `FiveSecondTaskJob ... DONE` 后,再考虑切换到常驻 worker。不要通过清空队列或重复启动 `five_task` 替换旧任务。
+
+长期运行可使用常驻 worker。先确认同一个 PHP CLI 的信号功能可用:
+
+```bash
+/www/server/php/82/bin/php -r 'var_dump(function_exists("pcntl_async_signals"), function_exists("pcntl_alarm"), function_exists("pcntl_signal"));'
+```
+
+三个结果都应为 `true`。随后在宝塔中替换原守护命令,不要同时保留旧的 listener:
+
+```bash
+/www/server/php/82/bin/php artisan queue:work --queue=default --sleep=1 --timeout=85
+```
+
+工作目录为服务器实际项目目录。启用自动重启,并让停止等待时间覆盖任务的 85 秒上限。当前 PC28 和 Telegram Job 最大超时均为 85 秒,队列连接默认 `retry_after` 为 90 秒;部署时也要确认缓存配置中的实际值。常驻 worker 更新代码或配置后需重启。
+
+## 虚拟投注跳过条件
+
+确认数据库配置中 `bet_fake=1`、`maintenance_switch=0`。当前游戏对应的期号还需处于可投注状态,且距离开奖超过 38 秒;新期开盘缓存也可能暂时跳过虚拟投注。`/admin/fake` 返回 `success` 不表示实际生成或发送了消息。
+
+图片生成失败仍按照现有开奖事务异常流程回滚。本次修改未改变开奖、派奖或结算逻辑,也不会清理队列或自动重试历史任务。

+ 217 - 0
tests/Unit/LotteryImageServiceTest.php

@@ -0,0 +1,217 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\LotteryImageService;
+use Illuminate\Config\Repository;
+use Illuminate\Container\Container;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Process\Exception\ProcessTimedOutException;
+
+class LotteryImageServiceTest extends TestCase
+{
+    private Application $app;
+    private string $temporaryRoot;
+    private object $logger;
+    private $previousContainer;
+    private $previousFacadeApplication;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->previousContainer = Container::getInstance();
+        $this->previousFacadeApplication = Facade::getFacadeApplication();
+        Facade::clearResolvedInstances();
+        $this->temporaryRoot = sys_get_temp_dir() . '/lottery image test ' . bin2hex(random_bytes(8));
+        mkdir($this->temporaryRoot, 0700, true);
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->useStoragePath($this->temporaryRoot . '/storage');
+        $this->app->instance('config', new Repository(['services' => ['lottery_image' => []]]));
+        $this->app->instance('filesystem', new class {
+            public function url(string $path): string
+            {
+                return 'https://media.example.test/storage/' . $path;
+            }
+        });
+        $this->logger = new class {
+            public array $entries = [];
+
+            public function channel(string $name): self
+            {
+                if ($name !== 'issue') {
+                    throw new \RuntimeException('Unexpected log channel');
+                }
+                return $this;
+            }
+
+            public function info(string $message, array $context): void
+            {
+                $this->entries[] = ['level' => 'info', 'message' => $message, 'context' => $context];
+            }
+
+            public function error(string $message, array $context): void
+            {
+                $this->entries[] = ['level' => 'error', 'message' => $message, 'context' => $context];
+            }
+        };
+        $this->app->instance('log', $this->logger);
+        Facade::setFacadeApplication($this->app);
+    }
+
+    protected function tearDown(): void
+    {
+        Facade::clearResolvedInstances();
+        Facade::setFacadeApplication($this->previousFacadeApplication);
+        Container::setInstance($this->previousContainer);
+        $files = new \RecursiveIteratorIterator(
+            new \RecursiveDirectoryIterator($this->temporaryRoot, \FilesystemIterator::SKIP_DOTS),
+            \RecursiveIteratorIterator::CHILD_FIRST
+        );
+        foreach ($files as $file) {
+            $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
+        }
+        rmdir($this->temporaryRoot);
+        parent::tearDown();
+    }
+
+    public function test_success_preserves_images_with_unique_paths_and_removes_temporary_html(): void
+    {
+        $binary = $this->fakeRenderer(<<<'SH'
+printf 'fake-png' > "$4"
+printf 'rendered'
+printf 'renderer diagnostic' >&2
+SH);
+        $service = new FixtureLotteryImageService();
+
+        $firstUrl = $service->generate([]);
+        $secondUrl = $service->generate([]);
+
+        $this->assertNotSame($firstUrl, $secondUrl);
+        $this->assertStringStartsWith('/storage/lottery/lottery_', $firstUrl);
+        $invocations = $this->invocations();
+        $this->assertCount(2, $invocations);
+        $this->assertNotSame($invocations[0][0], $invocations[1][0]);
+        $this->assertNotSame($invocations[0][1], $invocations[1][1]);
+        foreach ($invocations as [$inputPath, $outputPath]) {
+            $this->assertStringEndsWith('.html', $inputPath);
+            $this->assertFileDoesNotExist($inputPath);
+            $this->assertSame('fake-png', file_get_contents($outputPath));
+        }
+        $this->assertCount(2, $this->logger->entries);
+        foreach ($this->logger->entries as $entry) {
+            $this->assertSame('info', $entry['level']);
+            $this->assertSame(20.0, $entry['context']['timeout_seconds']);
+            $this->assertGreaterThanOrEqual(0, $entry['context']['duration_seconds']);
+            $this->assertSame(0, $entry['context']['exit_code']);
+            $this->assertStringContainsString('renderer diagnostic', $entry['context']['renderer_output']);
+            $this->assertStringContainsString('rendered', $entry['context']['renderer_output']);
+            $this->assertStringNotContainsString($binary, json_encode($entry));
+            $this->assertStringNotContainsString('private record content', json_encode($entry));
+        }
+    }
+
+    public function test_nonzero_exit_reports_failure_and_removes_partial_image_and_html(): void
+    {
+        $this->fakeRenderer(<<<'SH'
+printf 'partial-png' > "$4"
+printf 'renderer failed' >&2
+exit 7
+SH);
+
+        try {
+            (new FixtureLotteryImageService())->generate([]);
+            $this->fail('A failed renderer must throw');
+        } catch (\RuntimeException $exception) {
+            $this->assertStringContainsString('图片生成失败', $exception->getMessage());
+        }
+
+        $this->assertInvocationFilesRemoved();
+        $entry = $this->logger->entries[0];
+        $this->assertSame('error', $entry['level']);
+        $this->assertSame(7, $entry['context']['exit_code']);
+        $this->assertStringContainsString('renderer failed', $entry['context']['renderer_output']);
+    }
+
+    public function test_timeout_is_bounded_to_at_least_one_second_and_cleans_partial_files(): void
+    {
+        $this->fakeRenderer(<<<'SH'
+printf 'partial-png' > "$4"
+exec sleep 5
+SH);
+        $this->app['config']->set('services.lottery_image.timeout', 0);
+        $startedAt = microtime(true);
+
+        try {
+            (new FixtureLotteryImageService())->generate([]);
+            $this->fail('A stalled renderer must time out');
+        } catch (\RuntimeException $exception) {
+            $this->assertStringContainsString('图片生成超时', $exception->getMessage());
+            $this->assertInstanceOf(ProcessTimedOutException::class, $exception->getPrevious());
+        }
+
+        $duration = microtime(true) - $startedAt;
+        $this->assertGreaterThanOrEqual(1, $duration);
+        $this->assertLessThan(4, $duration);
+        $this->assertInvocationFilesRemoved();
+        $entry = $this->logger->entries[0];
+        $this->assertSame('error', $entry['level']);
+        $this->assertSame('timeout', $entry['context']['reason']);
+        $this->assertSame(1.0, $entry['context']['timeout_seconds']);
+        $this->assertGreaterThanOrEqual(1, $entry['context']['duration_seconds']);
+    }
+
+    public function test_success_exit_without_output_image_is_a_failure(): void
+    {
+        $this->fakeRenderer('exit 0');
+
+        try {
+            (new FixtureLotteryImageService())->generate([]);
+            $this->fail('A renderer must create an image');
+        } catch (\RuntimeException $exception) {
+            $this->assertStringContainsString('图片生成失败', $exception->getMessage());
+        }
+
+        $this->assertInvocationFilesRemoved();
+        $this->assertSame('error', $this->logger->entries[0]['level']);
+        $this->assertSame(0, $this->logger->entries[0]['context']['exit_code']);
+    }
+
+    private function fakeRenderer(string $body): string
+    {
+        $binary = $this->temporaryRoot . '/fake renderer';
+        $script = "#!/bin/sh\nset -eu\n"
+            . 'test -s "$3"' . "\n"
+            . 'printf \'%s\t%s\n\' "$3" "$4" >> ' . escapeshellarg($this->temporaryRoot . '/invocations.log') . "\n"
+            . $body . "\n";
+        file_put_contents($binary, $script);
+        chmod($binary, 0700);
+        $this->app['config']->set('services.lottery_image.binary', $binary);
+
+        return $binary;
+    }
+
+    private function invocations(): array
+    {
+        $lines = file($this->temporaryRoot . '/invocations.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+        return array_map(fn (string $line): array => explode("\t", $line, 2), $lines);
+    }
+
+    private function assertInvocationFilesRemoved(): void
+    {
+        $invocations = $this->invocations();
+        $this->assertCount(1, $invocations);
+        foreach ($invocations[0] as $path) {
+            $this->assertFileDoesNotExist($path);
+        }
+    }
+}
+
+class FixtureLotteryImageService extends LotteryImageService
+{
+    protected function buildLocalizedHtml(array $records): string
+    {
+        return '<html><body>private record content</body></html>';
+    }
+}

+ 183 - 0
tests/Unit/PlayNowRequestTimingTest.php

@@ -0,0 +1,183 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\IssueService;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Psr7\Response as PsrResponse;
+use GuzzleHttp\TransferStats;
+use Illuminate\Config\Repository;
+use Illuminate\Container\Container;
+use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Http\Client\Factory;
+use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Http;
+use Mockery;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+
+class PlayNowRequestTimingTest extends TestCase
+{
+    private $previousContainer;
+    private $previousFacadeApplication;
+    private array $logs = [];
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->previousContainer = Container::getInstance();
+        $this->previousFacadeApplication = Facade::getFacadeApplication();
+        $this->logs = [];
+        $app = new Container();
+        $app->instance('config', new Repository([
+            'services' => [
+                'playnow' => [
+                    'keno_url' => 'https://draw.example.test/keno',
+                    'proxy' => [
+                        'scheme' => 'http',
+                        'host' => 'proxy.example.test',
+                        'port' => '3128',
+                        'username' => 'timing-test-user',
+                        'password' => 'timing-test-secret/@',
+                    ],
+                ],
+            ],
+        ]));
+        $app->instance(Factory::class, new Factory());
+        $logger = Mockery::mock();
+        $logger->shouldReceive('info')->andReturnUsing(function (string $message, array $context = []) {
+            $this->logs[] = ['message' => $message, 'context' => $context];
+        });
+        $logManager = Mockery::mock();
+        $logManager->shouldReceive('channel')->with('issue')->andReturn($logger);
+        $app->instance('log', $logManager);
+        Container::setInstance($app);
+        Facade::clearResolvedInstances();
+        Facade::setFacadeApplication($app);
+    }
+
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        Facade::clearResolvedInstances();
+        Facade::setFacadeApplication($this->previousFacadeApplication);
+        Container::setInstance($this->previousContainer);
+        parent::tearDown();
+    }
+
+    public function test_success_logs_elapsed_time_and_cumulative_network_timings_without_proxy_credentials(): void
+    {
+        Http::fake(function (Request $request, array $options) {
+            $this->assertRequestOptions($request, $options);
+            $response = new PsrResponse(200, [], '{"drawNumber":123}');
+            $options['on_stats'](new TransferStats($request->toPsrRequest(), $response, 1.8, 0, [
+                'namelookup_time' => 0.1,
+                'connect_time' => 0.4,
+                'appconnect_time' => 0.7,
+                'starttransfer_time' => 1.2,
+                'total_time' => 1.8,
+                'url' => $options['proxy'],
+            ]));
+
+            return Http::response(['drawNumber' => 123]);
+        });
+
+        $this->assertSame(['drawNumber' => 123], $this->fetch());
+        $timing = $this->timingLog();
+        $this->assertSame(200, $timing['http_status']);
+        // These values are cumulative from request start, not per-phase differences.
+        $this->assertSame([
+            'dns_ms' => 100.0,
+            'connect_ms' => 400.0,
+            'tls_ms' => 700.0,
+            'first_byte_ms' => 1200.0,
+            'total_ms' => 1800.0,
+            'curl_error_code' => 0,
+        ], $timing['network_timings_ms']);
+        $this->assertSafeTimingContext($timing);
+        Http::assertSentCount(1);
+    }
+
+    public function test_connection_failure_still_logs_elapsed_time_partial_network_timings_and_error_code(): void
+    {
+        Http::fake(function (Request $request, array $options) {
+            $this->assertRequestOptions($request, $options);
+            $options['on_stats'](new TransferStats($request->toPsrRequest(), null, 25.0, 28, [
+                'namelookup_time' => 0.05,
+                'connect_time' => 0.06,
+                'total_time' => 25.0,
+                'url' => $options['proxy'],
+            ]));
+
+            throw new ConnectException('Synthetic connection timeout', $request->toPsrRequest());
+        });
+
+        try {
+            $this->fetch();
+            $this->fail('Expected the network error to propagate');
+        } catch (ConnectionException $exception) {
+            $this->assertSame('Synthetic connection timeout', $exception->getMessage());
+        }
+
+        $timing = $this->timingLog();
+        $this->assertNull($timing['http_status']);
+        $this->assertSame([
+            'dns_ms' => 50.0,
+            'connect_ms' => 60.0,
+            'total_ms' => 25000.0,
+            'curl_error_code' => 28,
+        ], $timing['network_timings_ms']);
+        $this->assertSafeTimingContext($timing);
+    }
+
+    public function test_http_failure_returns_null_and_still_logs_elapsed_time(): void
+    {
+        Http::fake(['*' => Http::response('Synthetic upstream failure', 503)]);
+
+        $this->assertNull($this->fetch());
+        $timing = $this->timingLog();
+        $this->assertSame(503, $timing['http_status']);
+        $this->assertSame([], $timing['network_timings_ms']);
+        $this->assertSafeTimingContext($timing);
+        Http::assertSentCount(1);
+    }
+
+    private function fetch()
+    {
+        $method = new ReflectionMethod(IssueService::class, 'fetchPlayNowKenoResult');
+        $method->setAccessible(true);
+
+        return $method->invoke(null);
+    }
+
+    private function assertRequestOptions(Request $request, array $options): void
+    {
+        $this->assertSame('https://draw.example.test/keno', $request->url());
+        $this->assertSame(25, $options['timeout']);
+        $this->assertSame(10, $options['connect_timeout']);
+        $this->assertSame('http://timing-test-user:timing-test-secret%2F%40@proxy.example.test:3128', $options['proxy']);
+        $this->assertIsCallable($options['on_stats']);
+    }
+
+    private function timingLog(): array
+    {
+        $timingLogs = array_values(array_filter($this->logs, fn (array $entry) => $entry['message'] === 'PlayNow请求耗时'));
+        $this->assertCount(1, $timingLogs);
+
+        return $timingLogs[0]['context'];
+    }
+
+    private function assertSafeTimingContext(array $context): void
+    {
+        $this->assertSame('draw.example.test', $context['target_host']);
+        $this->assertIsFloat($context['elapsed_ms']);
+        $this->assertGreaterThanOrEqual(0, $context['elapsed_ms']);
+        $this->assertSame('configured', $context['proxy']);
+        $this->assertSame('configured', $context['proxy_auth']);
+        $serialized = json_encode($this->logs, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+        foreach (['timing-test-user', 'timing-test-secret', 'timing-test-secret%2F%40', 'proxy.example.test:3128'] as $sensitiveValue) {
+            $this->assertStringNotContainsString($sensitiveValue, $serialized);
+        }
+    }
+}