CheckAiTaskHealthCommand.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Consts\BaseConst;
  4. use App\Models\MpGeneratePicTask;
  5. use Illuminate\Console\Command;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Redis;
  8. /**
  9. * 图片任务健康检查 & 钉钉告警
  10. *
  11. * 由 app/Console/Kernel.php 每分钟调度一次(ai:check-task-health)。
  12. * 每轮采集下面 4 项指标,命中后需要"连续命中 N 次"才真正发出告警,避免瞬时波动误报;
  13. * 同一规则告警后进入冷却期,冷却期内不重复发送;指标恢复正常后发送一条"已恢复"通知。
  14. *
  15. * =========================== 通知规则一览(全部参数见下方常量区) ===========================
  16. *
  17. * 【规则A】队列积压(QUEUE_BACKLOG_*)
  18. * 判定:监控队列的 Redis 长度 >= QUEUE_BACKLOG_THRESHOLD 条,
  19. * 且连续 QUEUE_BACKLOG_CONSECUTIVE 次命中(每分钟检查一次)。
  20. * 含义:job 已经派发进队列但没人消费——worker 进程挂了/没启动,
  21. * 或队列名、Redis 实例与派发方不一致。
  22. *
  23. * 【规则B】任务长时间未启动(PENDING_*)
  24. * 判定:队列派发型模型(GPT-Image2、火山系列)中 status=pending
  25. * 且 created_at 早于 PENDING_TIMEOUT_MINUTES 分钟的任务数 > 0,
  26. * 且连续 PENDING_CONSECUTIVE 次命中。
  27. * 含义:任务已创建却一直没被 worker 取走,用户端会一直显示 0 进度。
  28. * 说明:即梦(jimeng_4.0)、NanoBanana 走内部排队/同步提交,等待属于正常,不参与本规则。
  29. *
  30. * 【规则C】任务长时间处理中(PROCESSING_*)
  31. * 判定:status=processing 且 started_at 早于 PROCESSING_TIMEOUT_MINUTES 分钟的任务数 > 0,
  32. * 且连续 PROCESSING_CONSECUTIVE 次命中。
  33. * 含义:任务已被 worker 取走但卡在第三方接口,或 worker 被强杀导致状态悬挂。
  34. *
  35. * 【规则D】失败突增(FAILED_*)
  36. * 判定:最近 FAILED_WINDOW_MINUTES 分钟内 status=failed 的任务数 > FAILED_THRESHOLD,
  37. * 且连续 FAILED_CONSECUTIVE 次命中。
  38. * 含义:第三方账号/额度异常(例如历史出现过的 USER_INACTIVE)或内容审核批量拦截。
  39. *
  40. * 【通用行为】
  41. * 1. 同一规则告警后,ALERT_COOLDOWN_MINUTES 分钟内不重复发送;超过冷却期仍异常则重复提醒一次;
  42. * 2. 指标恢复正常时发送一条"已恢复"通知(含本次异常持续时长);
  43. * 3. 所有通知都带环境(APP_ENV)、命中详情与排查建议;
  44. * 4. 连续次数、冷却状态记录在 Redis(ALERT_KEY_PREFIX + 规则名),ALERT_STATE_TTL 后自动过期;
  45. * 5. 钉钉通知直接复用项目已有的 sendNotice()(读取 .env 的 DD_WEB_HOOK);
  46. * 未配置 webhook 或发送失败都只记录日志,不影响命令执行。
  47. * ===========================================================================================
  48. */
  49. class CheckAiTaskHealthCommand extends Command
  50. {
  51. /**
  52. * 命令名与参数
  53. *
  54. * @var string
  55. */
  56. protected $signature = 'ai:check-task-health {--dry-run : 只输出判定结果,不发送钉钉也不写入状态}';
  57. /**
  58. * 命令说明
  59. *
  60. * @var string
  61. */
  62. protected $description = '图片任务健康检查(队列积压/任务卡住/失败突增)并在异常时发送钉钉告警';
  63. // ============================== 规则A:队列积压 ==============================
  64. /** 监控的 Redis 队列:队列名 => 展示名(Laravel Redis 队列的 key 为 queues:{队列名}) */
  65. const MONITOR_QUEUES = [
  66. '{GenerateGptImage2Pics}' => 'GPT-Image2',
  67. '{GenerateVolcPics}' => '火山API',
  68. ];
  69. /** 队列积压条数阈值(达到即算命中) */
  70. const QUEUE_BACKLOG_THRESHOLD = 200;
  71. /** 队列积压需要连续命中的次数(每分钟检查一次) */
  72. const QUEUE_BACKLOG_CONSECUTIVE = 3;
  73. // ============================== 规则B:任务长时间未启动 ==============================
  74. /** pending 超时时间(分钟),超过即算命中 */
  75. const PENDING_TIMEOUT_MINUTES = 10;
  76. /** pending 超时需要连续命中的次数 */
  77. const PENDING_CONSECUTIVE = 2;
  78. // ============================== 规则C:任务长时间处理中 ==============================
  79. /** processing 超时时间(分钟),超过即算命中 */
  80. const PROCESSING_TIMEOUT_MINUTES = 15;
  81. /** processing 超时需要连续命中的次数 */
  82. const PROCESSING_CONSECUTIVE = 2;
  83. // ============================== 规则D:失败突增 ==============================
  84. /** 失败统计的滑动窗口(分钟) */
  85. const FAILED_WINDOW_MINUTES = 10;
  86. /** 窗口内失败条数阈值(超过才告警) */
  87. const FAILED_THRESHOLD = 20;
  88. /** 失败突增需要连续命中的次数 */
  89. const FAILED_CONSECUTIVE = 2;
  90. /** 失败样例在告警中展示的条数 */
  91. const FAILED_SAMPLE_LIMIT = 3;
  92. // ============================== 通用参数 ==============================
  93. /** 同一规则告警后的冷却时间(分钟),冷却期内不重复发送 */
  94. const ALERT_COOLDOWN_MINUTES = 30;
  95. /** 告警状态在 Redis 中的 key 前缀 */
  96. const ALERT_KEY_PREFIX = 'ai_task_alert:';
  97. /** 告警状态保留时间(秒),避免 Redis 中残留脏状态 */
  98. const ALERT_STATE_TTL = 604800; // 7 天
  99. /** 使用的 Redis 连接,必须与队列保持一致 */
  100. const ALERT_REDIS_CONNECTION = 'default';
  101. /** 展示错误信息时截断的字符数 */
  102. const ERROR_MESSAGE_LIMIT = 80;
  103. /**
  104. * 执行命令
  105. *
  106. * @return int
  107. */
  108. public function handle()
  109. {
  110. $dryRun = (bool) $this->option('dry-run');
  111. $this->info('图片任务健康检查开始' . ($dryRun ? '(dry-run:不发送钉钉、不写入状态)' : ''));
  112. $this->line('环境:' . config('app.env') . ' 时间:' . now()->toDateTimeString());
  113. try {
  114. // 采集 4 项规则
  115. $rules = [
  116. $this->buildQueueBacklogRule(),
  117. $this->buildPendingTimeoutRule(),
  118. $this->buildProcessingTimeoutRule(),
  119. $this->buildFailedSpikeRule(),
  120. ];
  121. foreach ($rules as $rule) {
  122. $this->processRule($rule, $dryRun);
  123. }
  124. } catch (\Throwable $e) {
  125. // 检查本身异常不影响其它定时任务,只记录并输出
  126. $this->error('图片任务健康检查执行失败:' . $e->getMessage());
  127. dLog('generate')->error('图片任务健康检查执行失败', ['error' => $e->getMessage()]);
  128. return 1;
  129. }
  130. $this->info('图片任务健康检查结束');
  131. return 0;
  132. }
  133. /**
  134. * 规则处理:连续计数、冷却、告警与恢复通知、状态落库
  135. *
  136. * @param array $rule 规则数据
  137. * @param bool $dryRun 是否演练模式(不发通知、不写状态)
  138. * @return void
  139. */
  140. private function processRule(array $rule, bool $dryRun): void
  141. {
  142. $state = $this->loadState($rule['key']);
  143. $cooldownSeconds = self::ALERT_COOLDOWN_MINUTES * 60;
  144. $this->outputRuleResult($rule, $state);
  145. if ($rule['triggered']) {
  146. $state['consecutive'] = (int) $state['consecutive'] + 1;
  147. if ($state['consecutive'] === 1 && empty($state['first_hit_at'])) {
  148. $state['first_hit_at'] = time();
  149. }
  150. if (!$state['alerting']) {
  151. // 未处于告警状态:连续命中达到配置次数才发送
  152. if ($state['consecutive'] >= (int) $rule['consecutive']) {
  153. if ($dryRun) {
  154. $this->line(' → [dry-run] 已达到连续命中次数,将发送告警');
  155. } else {
  156. $this->sendAlert($rule, false);
  157. $state['alerting'] = true;
  158. $state['alerted_at'] = time();
  159. }
  160. } else {
  161. $this->line(' → 命中 ' . $state['consecutive'] . '/' . $rule['consecutive'] . ' 次,未达连续次数,暂不告警');
  162. }
  163. } else {
  164. // 已在告警中:冷却结束后重复提醒
  165. $elapsed = time() - (int) $state['alerted_at'];
  166. if ($elapsed >= $cooldownSeconds) {
  167. if ($dryRun) {
  168. $this->line(' → [dry-run] 冷却期已过且异常仍在持续,将重复发送告警');
  169. } else {
  170. $this->sendAlert($rule, true);
  171. $state['alerted_at'] = time();
  172. }
  173. } else {
  174. $this->line(' → 告警中,冷却剩余约 ' . (int) ceil(($cooldownSeconds - $elapsed) / 60) . ' 分钟');
  175. }
  176. }
  177. } else {
  178. if ($state['alerting']) {
  179. if ($dryRun) {
  180. $this->line(' → [dry-run] 指标已恢复,将发送恢复通知');
  181. } else {
  182. $this->sendRecovery($rule, $state);
  183. }
  184. $state['alerting'] = false;
  185. }
  186. $state['consecutive'] = 0;
  187. $state['first_hit_at'] = 0;
  188. }
  189. if (!$dryRun) {
  190. $state['last_detail'] = implode(' | ', $rule['detail']);
  191. $state['last_check_at'] = time();
  192. $this->saveState($rule['key'], $state);
  193. }
  194. }
  195. /**
  196. * 规则A:队列积压
  197. *
  198. * @return array
  199. */
  200. private function buildQueueBacklogRule(): array
  201. {
  202. $detail = [];
  203. $hit = [];
  204. try {
  205. foreach (self::MONITOR_QUEUES as $queue => $label) {
  206. $length = (int) Redis::connection(self::ALERT_REDIS_CONNECTION)->llen('queues:' . $queue);
  207. $detail[] = $label . '队列(queues:' . $queue . '):' . $length . ' 条,阈值 ' . self::QUEUE_BACKLOG_THRESHOLD . ' 条';
  208. if ($length >= self::QUEUE_BACKLOG_THRESHOLD) {
  209. $hit[] = $label . '=' . $length;
  210. }
  211. }
  212. } catch (\Throwable $e) {
  213. // Redis 不可用时无法判定队列长度,只提示不告警(避免误报)
  214. $detail[] = 'Redis 队列长度读取失败:' . $e->getMessage();
  215. $hit = [];
  216. }
  217. return [
  218. 'key' => 'queue_backlog',
  219. 'title' => '队列积压',
  220. 'rule_text' => '队列长度 >= ' . self::QUEUE_BACKLOG_THRESHOLD . ' 条,连续 ' . self::QUEUE_BACKLOG_CONSECUTIVE . ' 次命中',
  221. 'consecutive' => self::QUEUE_BACKLOG_CONSECUTIVE,
  222. 'triggered' => !empty($hit),
  223. 'detail' => $detail,
  224. 'hit_text' => implode(',', $hit),
  225. 'suggest' => '检查 worker 进程是否存活(supervisorctl status generate_gpt_image2_pics:*),确认队列名与 Redis 实例和派发方一致',
  226. ];
  227. }
  228. /**
  229. * 规则B:任务长时间未启动
  230. *
  231. * @return array
  232. */
  233. private function buildPendingTimeoutRule(): array
  234. {
  235. $deadline = now()->subMinutes(self::PENDING_TIMEOUT_MINUTES);
  236. $query = DB::table('mp_generate_pic_tasks')
  237. ->where('status', MpGeneratePicTask::STATUS_PENDING)
  238. ->whereIn('model', $this->pendingWatchModels())
  239. ->where('created_at', '<', $deadline);
  240. $count = (clone $query)->count();
  241. $detail = ['pending 超过 ' . self::PENDING_TIMEOUT_MINUTES . ' 分钟的任务:' . $count . ' 条'];
  242. if ($count > 0) {
  243. $oldest = (clone $query)->orderBy('created_at', 'asc')->first();
  244. if ($oldest) {
  245. $detail[] = '最早一条:id=' . $oldest->id
  246. . ',模型=' . $oldest->model
  247. . ',外部任务号=' . $oldest->task_id
  248. . ',创建于 ' . $oldest->created_at
  249. . '(已等待 ' . $this->minutesSince($oldest->created_at) . ' 分钟)';
  250. }
  251. }
  252. return [
  253. 'key' => 'pending_timeout',
  254. 'title' => '任务长时间未启动',
  255. 'rule_text' => 'pending 超过 ' . self::PENDING_TIMEOUT_MINUTES . ' 分钟的任务数 > 0,连续 ' . self::PENDING_CONSECUTIVE . ' 次命中',
  256. 'consecutive' => self::PENDING_CONSECUTIVE,
  257. 'triggered' => $count > 0,
  258. 'detail' => $detail,
  259. 'hit_text' => '超时 pending ' . $count . ' 条',
  260. 'suggest' => '任务已入队但未被消费,优先检查对应队列的 worker 与队列积压情况',
  261. ];
  262. }
  263. /**
  264. * 规则C:任务长时间处理中
  265. *
  266. * @return array
  267. */
  268. private function buildProcessingTimeoutRule(): array
  269. {
  270. $deadline = now()->subMinutes(self::PROCESSING_TIMEOUT_MINUTES);
  271. $query = DB::table('mp_generate_pic_tasks')
  272. ->where('status', MpGeneratePicTask::STATUS_PROCESSING)
  273. ->whereNotNull('started_at')
  274. ->where('started_at', '<', $deadline);
  275. $count = (clone $query)->count();
  276. $detail = ['processing 超过 ' . self::PROCESSING_TIMEOUT_MINUTES . ' 分钟的任务:' . $count . ' 条'];
  277. if ($count > 0) {
  278. $oldest = (clone $query)->orderBy('started_at', 'asc')->first();
  279. if ($oldest) {
  280. $detail[] = '最早一条:id=' . $oldest->id
  281. . ',模型=' . $oldest->model
  282. . ',外部任务号=' . $oldest->task_id
  283. . ',开始于 ' . $oldest->started_at
  284. . '(已处理 ' . $this->minutesSince($oldest->started_at) . ' 分钟)';
  285. }
  286. }
  287. return [
  288. 'key' => 'processing_timeout',
  289. 'title' => '任务长时间处理中',
  290. 'rule_text' => 'processing 超过 ' . self::PROCESSING_TIMEOUT_MINUTES . ' 分钟的任务数 > 0,连续 ' . self::PROCESSING_CONSECUTIVE . ' 次命中',
  291. 'consecutive' => self::PROCESSING_CONSECUTIVE,
  292. 'triggered' => $count > 0,
  293. 'detail' => $detail,
  294. 'hit_text' => '超时 processing ' . $count . ' 条',
  295. 'suggest' => '检查第三方接口是否超时/限流,以及 worker 是否被强杀(supervisor 的 stopwaitsecs 是否小于任务超时时间)',
  296. ];
  297. }
  298. /**
  299. * 规则D:失败突增
  300. *
  301. * @return array
  302. */
  303. private function buildFailedSpikeRule(): array
  304. {
  305. $deadline = now()->subMinutes(self::FAILED_WINDOW_MINUTES);
  306. $query = DB::table('mp_generate_pic_tasks')
  307. ->where('status', MpGeneratePicTask::STATUS_FAILED)
  308. ->where('updated_at', '>=', $deadline);
  309. $count = (clone $query)->count();
  310. $detail = ['最近 ' . self::FAILED_WINDOW_MINUTES . ' 分钟内失败的任务:' . $count . ' 条,阈值 > ' . self::FAILED_THRESHOLD . ' 条'];
  311. if ($count > self::FAILED_THRESHOLD) {
  312. $samples = (clone $query)->orderBy('updated_at', 'desc')->limit(self::FAILED_SAMPLE_LIMIT)->get(['id', 'model', 'error_message', 'updated_at']);
  313. foreach ($samples as $sample) {
  314. $detail[] = '样例:id=' . $sample->id
  315. . ',模型=' . $sample->model
  316. . ',时间=' . $sample->updated_at
  317. . ',错误=' . mb_substr((string) $sample->error_message, 0, self::ERROR_MESSAGE_LIMIT);
  318. }
  319. }
  320. return [
  321. 'key' => 'failed_spike',
  322. 'title' => '失败任务突增',
  323. 'rule_text' => '最近 ' . self::FAILED_WINDOW_MINUTES . ' 分钟内失败数 > ' . self::FAILED_THRESHOLD . ' 条,连续 ' . self::FAILED_CONSECUTIVE . ' 次命中',
  324. 'consecutive' => self::FAILED_CONSECUTIVE,
  325. 'triggered' => $count > self::FAILED_THRESHOLD,
  326. 'detail' => $detail,
  327. 'hit_text' => '失败 ' . $count . ' 条',
  328. 'suggest' => '检查第三方账号/额度状态与内容审核拦截,确认接口返回的错误码',
  329. ];
  330. }
  331. /**
  332. * pending 规则监控的模型:仅"队列派发型"(GPT-Image2、火山系列)
  333. *
  334. * 即梦(jimeng_4.0)、NanoBanana 走内部排队/同步提交,长时间 pending 属正常,不参与告警。
  335. *
  336. * @return array
  337. */
  338. private function pendingWatchModels(): array
  339. {
  340. return array_merge(BaseConst::GPT_IMAGE2_MODELS, BaseConst::VOLC_PIC_MODELS);
  341. }
  342. /**
  343. * 控制台输出单条规则的判定结果
  344. *
  345. * @param array $rule
  346. * @param array $state
  347. * @return void
  348. */
  349. private function outputRuleResult(array $rule, array $state): void
  350. {
  351. $status = $rule['triggered']
  352. ? '命中(连续 ' . ((int) $state['consecutive'] + 1) . '/' . $rule['consecutive'] . ')'
  353. : '正常';
  354. $this->line('【' . $rule['title'] . '】' . $status);
  355. foreach ($rule['detail'] as $line) {
  356. $this->line(' - ' . $line);
  357. }
  358. }
  359. /**
  360. * 发送告警(钉钉 + 控制台 + 日志)
  361. *
  362. * @param array $rule
  363. * @param bool $repeat 是否为持续告警的重复提醒
  364. * @return void
  365. */
  366. private function sendAlert(array $rule, bool $repeat): void
  367. {
  368. $lines = [
  369. ($repeat ? '【图片任务告警·持续】' : '【图片任务告警】') . $rule['title'],
  370. '环境:' . config('app.env'),
  371. '规则:' . $rule['rule_text'],
  372. '当前:' . $rule['hit_text'],
  373. ];
  374. foreach ($rule['detail'] as $line) {
  375. $lines[] = ' - ' . $line;
  376. }
  377. if ($repeat) {
  378. $lines[] = '说明:该异常仍在持续,距上次通知已 ' . self::ALERT_COOLDOWN_MINUTES . ' 分钟';
  379. }
  380. $lines[] = '建议:' . $rule['suggest'];
  381. $lines[] = '时间:' . now()->toDateTimeString();
  382. $content = implode("\n", $lines);
  383. $this->error($content);
  384. dLog('generate')->error('图片任务健康告警:' . $rule['title'], [
  385. 'rule' => $rule['key'],
  386. 'detail' => $rule['detail'],
  387. ]);
  388. logDB('generate', 'error', '图片任务健康告警:' . $rule['title'], [
  389. 'rule' => $rule['key'],
  390. 'detail' => $rule['detail'],
  391. ]);
  392. $this->notify($content);
  393. }
  394. /**
  395. * 发送恢复通知(只在上一轮处于告警状态时发送)
  396. *
  397. * @param array $rule
  398. * @param array $state
  399. * @return void
  400. */
  401. private function sendRecovery(array $rule, array $state): void
  402. {
  403. $lines = [
  404. '【图片任务恢复】' . $rule['title'] . ' 已恢复正常',
  405. '环境:' . config('app.env'),
  406. ];
  407. if (!empty($state['first_hit_at'])) {
  408. $lines[] = '本次异常持续约 ' . (int) ceil((time() - (int) $state['first_hit_at']) / 60) . ' 分钟';
  409. }
  410. foreach ($rule['detail'] as $line) {
  411. $lines[] = ' - ' . $line;
  412. }
  413. $lines[] = '时间:' . now()->toDateTimeString();
  414. $content = implode("\n", $lines);
  415. $this->info($content);
  416. dLog('generate')->info('图片任务健康恢复:' . $rule['title'], ['rule' => $rule['key']]);
  417. logDB('generate', 'info', '图片任务健康恢复:' . $rule['title'], ['rule' => $rule['key']]);
  418. $this->notify($content);
  419. }
  420. /**
  421. * 发送钉钉通知
  422. *
  423. * 直接复用项目已有的 sendNotice()(读取线上 .env 的 DD_WEB_HOOK,默认 @所有人)。
  424. * webhook 未配置或发送失败都只记录日志,绝不影响检查命令本身。
  425. *
  426. * @param string $content
  427. * @return void
  428. */
  429. private function notify(string $content): void
  430. {
  431. if (empty(env('DD_WEB_HOOK'))) {
  432. $this->warn('DD_WEB_HOOK 未配置,跳过钉钉发送');
  433. dLog('generate')->warning('图片任务健康告警未发送:DD_WEB_HOOK 未配置', ['content' => $content]);
  434. return;
  435. }
  436. try {
  437. sendNotice($content);
  438. } catch (\Throwable $e) {
  439. $this->warn('钉钉发送失败:' . $e->getMessage());
  440. dLog('generate')->error('图片任务健康告警发送失败', [
  441. 'error' => $e->getMessage(),
  442. 'content' => $content,
  443. ]);
  444. }
  445. }
  446. /**
  447. * 读取规则状态(连续命中次数、是否告警中、首次命中时间等)
  448. *
  449. * @param string $ruleKey
  450. * @return array
  451. */
  452. private function loadState(string $ruleKey): array
  453. {
  454. $default = [
  455. 'consecutive' => 0,
  456. 'alerting' => false,
  457. 'alerted_at' => 0,
  458. 'first_hit_at'=> 0,
  459. ];
  460. try {
  461. $raw = Redis::connection(self::ALERT_REDIS_CONNECTION)->get($this->stateKey($ruleKey));
  462. if (empty($raw)) {
  463. return $default;
  464. }
  465. $state = json_decode($raw, true);
  466. if (!is_array($state)) {
  467. return $default;
  468. }
  469. return array_merge($default, $state);
  470. } catch (\Throwable $e) {
  471. dLog('generate')->warning('读取图片任务告警状态失败', [
  472. 'rule' => $ruleKey,
  473. 'error' => $e->getMessage(),
  474. ]);
  475. return $default;
  476. }
  477. }
  478. /**
  479. * 保存规则状态
  480. *
  481. * @param string $ruleKey
  482. * @param array $state
  483. * @return void
  484. */
  485. private function saveState(string $ruleKey, array $state): void
  486. {
  487. try {
  488. Redis::connection(self::ALERT_REDIS_CONNECTION)->set(
  489. $this->stateKey($ruleKey),
  490. json_encode($state, JSON_UNESCAPED_UNICODE),
  491. 'EX',
  492. self::ALERT_STATE_TTL
  493. );
  494. } catch (\Throwable $e) {
  495. dLog('generate')->warning('保存图片任务告警状态失败', [
  496. 'rule' => $ruleKey,
  497. 'error' => $e->getMessage(),
  498. ]);
  499. }
  500. }
  501. /**
  502. * 规则状态在 Redis 中的 key
  503. *
  504. * @param string $ruleKey
  505. * @return string
  506. */
  507. private function stateKey(string $ruleKey): string
  508. {
  509. return self::ALERT_KEY_PREFIX . 'state:' . $ruleKey;
  510. }
  511. /**
  512. * 计算某个时间点距今经过的分钟数
  513. *
  514. * @param string $dateTime
  515. * @return int
  516. */
  517. private function minutesSince(string $dateTime): int
  518. {
  519. $timestamp = strtotime($dateTime);
  520. return $timestamp ? (int) floor((time() - $timestamp) / 60) : 0;
  521. }
  522. }