LotteryImageService.php 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\Util;
  4. use App\Models\Config;
  5. use Illuminate\Support\Facades\App;
  6. use Illuminate\Support\Facades\Log;
  7. use Illuminate\Support\Facades\Storage;
  8. use Symfony\Component\Process\Exception\ProcessTimedOutException;
  9. use Symfony\Component\Process\Process;
  10. class LotteryImageService
  11. {
  12. /**
  13. * 生成开奖记录图片
  14. * @param array $records 开奖记录数组
  15. * @return string 图片的访问URL
  16. * @throws \Exception
  17. */
  18. public function generate(array $records): string
  19. {
  20. $html = $this->buildLocalizedHtml($records);
  21. $fileName = 'lottery_' . bin2hex(random_bytes(16)) . '.png';
  22. $outputPath = storage_path('app/public/lottery/' . $fileName);
  23. $htmlPath = null;
  24. $process = null;
  25. $completed = false;
  26. $timeout = max(1, (float) config('services.lottery_image.timeout', 20));
  27. $startedAt = microtime(true);
  28. try {
  29. if (!is_dir(dirname($outputPath)) && !mkdir(dirname($outputPath), 0755, true) && !is_dir(dirname($outputPath))) {
  30. throw new \RuntimeException('无法创建开奖图片目录');
  31. }
  32. $htmlPath = tempnam(storage_path('app'), 'lottery_');
  33. if ($htmlPath === false) {
  34. throw new \RuntimeException('无法创建开奖图片临时HTML');
  35. }
  36. $temporaryHtmlPath = $htmlPath . '.html';
  37. if (!rename($htmlPath, $temporaryHtmlPath)) {
  38. throw new \RuntimeException('无法创建开奖图片临时HTML文件');
  39. }
  40. $htmlPath = $temporaryHtmlPath;
  41. if (file_put_contents($htmlPath, $html) === false) {
  42. throw new \RuntimeException('无法保存开奖图片临时HTML');
  43. }
  44. $process = new Process([
  45. (string) config('services.lottery_image.binary', 'wkhtmltoimage'),
  46. '--quality',
  47. '100',
  48. $htmlPath,
  49. $outputPath,
  50. ]);
  51. $process->setTimeout($timeout);
  52. $process->run();
  53. if (!$process->isSuccessful() || !is_file($outputPath) || filesize($outputPath) === 0) {
  54. throw new \RuntimeException('渲染进程未生成有效图片');
  55. }
  56. $url = Util::replacePartInUrl(Storage::url('lottery/' . $fileName));
  57. Log::channel('issue')->info('开奖记录图片生成成功', $this->renderLogContext($process, $startedAt, $timeout));
  58. $completed = true;
  59. return $url;
  60. } catch (\Throwable $exception) {
  61. $timedOut = $exception instanceof ProcessTimedOutException;
  62. Log::channel('issue')->error('开奖记录图片生成失败', $this->renderLogContext($process, $startedAt, $timeout) + [
  63. 'reason' => $timedOut ? 'timeout' : 'render_failed',
  64. 'error_type' => get_class($exception),
  65. ]);
  66. $message = $timedOut
  67. ? '图片生成超时:渲染超过 ' . $timeout . ' 秒'
  68. : '图片生成失败,请检查 wkhtmltoimage、存储目录权限和开奖记录图片日志';
  69. throw new \RuntimeException($message, 0, $exception);
  70. } finally {
  71. if ($htmlPath && is_file($htmlPath)) {
  72. unlink($htmlPath);
  73. }
  74. if (!$completed && is_file($outputPath)) {
  75. unlink($outputPath);
  76. }
  77. }
  78. }
  79. protected function buildLocalizedHtml(array $records): string
  80. {
  81. $lang = App::getLocale();
  82. $group_language = Config::where('field', 'group_language')->first()->val;
  83. try {
  84. App::setLocale($group_language);
  85. return $this->buildHtml($records);
  86. } finally {
  87. App::setLocale($lang);
  88. }
  89. }
  90. private function renderLogContext(?Process $process, float $startedAt, float $timeout): array
  91. {
  92. $output = '';
  93. if ($process && $process->isStarted()) {
  94. $output = trim($process->getErrorOutput() . "\n" . $process->getOutput());
  95. }
  96. return [
  97. 'duration_seconds' => round(microtime(true) - $startedAt, 3),
  98. 'timeout_seconds' => $timeout,
  99. 'exit_code' => $process ? $process->getExitCode() : null,
  100. 'renderer_output' => substr($output, 0, 2000),
  101. ];
  102. }
  103. /**
  104. * 生成HTML内容
  105. */
  106. protected function buildHtml($records): string
  107. {
  108. $time = now()->format('Y-m-d H:i:s');
  109. $rows = '';
  110. foreach ($records as $row) {
  111. $rows .= '<tr>';
  112. $rows .= '<td>' . htmlspecialchars($row['issue_no']) . " " . lang("期") . '</td>';
  113. $rows .= '<td>';
  114. foreach ($row['winning_numbers'] as $i => $num) {
  115. $color = match ($i) {
  116. 0 => '#FF8A8A',
  117. 1 => '#8AB8FF',
  118. 2 => '#FFDF80',
  119. default => '#000',
  120. };
  121. $rows .= "<span style='color: {$color};'>{$num}</span>";
  122. if ($i < count($row['winning_numbers']) - 1) $rows .= ' + ';
  123. }
  124. $rows .= ' = ' . array_sum($row['winning_numbers']) . '</td>';
  125. $rows .= '<td>' . $row['combo'] . '</td>';
  126. $rows .= '<td>' . $row['extreme'] . '</td>';
  127. $rows .= '<td>' . $row['tail'] . '</td>';
  128. $rows .= '</tr>';
  129. }
  130. $Canada28LotteryResults = lang("加拿大28开奖记录");
  131. $recentLotteryResults = lang("最近开奖记录");
  132. $th1 = lang("回合");
  133. $th2 = lang("结果");
  134. $th3 = lang("组合");
  135. $th4 = lang("极值");
  136. $th5 = lang("尾数");
  137. $createdTime = lang("生成时间");
  138. $dataForReferenceOnly = lang("数据仅供参考");
  139. return <<<HTML
  140. <!DOCTYPE html>
  141. <html>
  142. <head>
  143. <meta charset="UTF-8">
  144. <title>{$Canada28LotteryResults}</title>
  145. <style>
  146. body {
  147. font-family: "Microsoft YaHei", Arial, sans-serif;
  148. /* background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); */
  149. /* background: #667eea 0%; */
  150. margin: 0;
  151. padding: 5px;
  152. min-height: 100vh;
  153. }
  154. .container {
  155. max-width: 800px;
  156. margin: 0 auto;
  157. background: white;
  158. border-radius: 15px;
  159. box-shadow: 0 10px 30px rgba(0,0,0,0.3);
  160. overflow: hidden;
  161. }
  162. .header {
  163. /* background: linear-gradient(45deg, #e74c3c, #c0392b); */
  164. background: #e74c3c;
  165. color: white;
  166. padding: 20px;
  167. text-align: center;
  168. }
  169. .header h1 {
  170. margin: 0;
  171. font-size: 28px;
  172. text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
  173. }
  174. .results-table {
  175. width: 100%;
  176. border-collapse: collapse;
  177. }
  178. .results-table th {
  179. background: #34495e;
  180. color: white;
  181. padding: 15px 10px;
  182. text-align: center;
  183. }
  184. .results-table td {
  185. padding: 12px 10px;
  186. text-align: center;
  187. border-bottom: 1px solid #e0e0e0;
  188. font-weight: 900;
  189. }
  190. .footer {
  191. background: #ecf0f1;
  192. padding: 15px;
  193. text-align: center;
  194. color: #7f8c8d;
  195. font-size: 12px;
  196. }
  197. </style>
  198. </head>
  199. <body>
  200. <div class="container">
  201. <div class="header">
  202. <h1>{$Canada28LotteryResults}</h1>
  203. <div class="subtitle">{$recentLotteryResults}</div>
  204. </div>
  205. <table class="results-table">
  206. <thead>
  207. <tr>
  208. <th>{$th1}</th>
  209. <th>{$th2}</th>
  210. <th>{$th3}</th>
  211. <th>{$th4}</th>
  212. <th>{$th5}</th>
  213. </tr>
  214. </thead>
  215. <tbody>
  216. {$rows}
  217. </tbody>
  218. </table>
  219. <div class="footer">
  220. {$createdTime}: {$time} | {$dataForReferenceOnly}
  221. </div>
  222. </div>
  223. </body>
  224. </html>
  225. HTML;
  226. }
  227. }