| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- <?php
- namespace App\Console\Commands;
- use App\Consts\BaseConst;
- use App\Models\MpGeneratePicTask;
- use Illuminate\Console\Command;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Redis;
- /**
- * 图片任务健康检查 & 钉钉告警
- *
- * 由 app/Console/Kernel.php 每分钟调度一次(ai:check-task-health)。
- * 每轮采集下面 4 项指标,命中后需要"连续命中 N 次"才真正发出告警,避免瞬时波动误报;
- * 同一规则告警后进入冷却期,冷却期内不重复发送;指标恢复正常后发送一条"已恢复"通知。
- *
- * =========================== 通知规则一览(全部参数见下方常量区) ===========================
- *
- * 【规则A】队列积压(QUEUE_BACKLOG_*)
- * 判定:监控队列的 Redis 长度 >= QUEUE_BACKLOG_THRESHOLD 条,
- * 且连续 QUEUE_BACKLOG_CONSECUTIVE 次命中(每分钟检查一次)。
- * 含义:job 已经派发进队列但没人消费——worker 进程挂了/没启动,
- * 或队列名、Redis 实例与派发方不一致。
- *
- * 【规则B】任务长时间未启动(PENDING_*)
- * 判定:队列派发型模型(GPT-Image2、火山系列)中 status=pending
- * 且 created_at 早于 PENDING_TIMEOUT_MINUTES 分钟的任务数 > 0,
- * 且连续 PENDING_CONSECUTIVE 次命中。
- * 含义:任务已创建却一直没被 worker 取走,用户端会一直显示 0 进度。
- * 说明:即梦(jimeng_4.0)、NanoBanana 走内部排队/同步提交,等待属于正常,不参与本规则。
- *
- * 【规则C】任务长时间处理中(PROCESSING_*)
- * 判定:status=processing 且 started_at 早于 PROCESSING_TIMEOUT_MINUTES 分钟的任务数 > 0,
- * 且连续 PROCESSING_CONSECUTIVE 次命中。
- * 含义:任务已被 worker 取走但卡在第三方接口,或 worker 被强杀导致状态悬挂。
- *
- * 【规则D】失败突增(FAILED_*)
- * 判定:最近 FAILED_WINDOW_MINUTES 分钟内 status=failed 的任务数 > FAILED_THRESHOLD,
- * 且连续 FAILED_CONSECUTIVE 次命中。
- * 含义:第三方账号/额度异常(例如历史出现过的 USER_INACTIVE)或内容审核批量拦截。
- *
- * 【通用行为】
- * 1. 同一规则告警后,ALERT_COOLDOWN_MINUTES 分钟内不重复发送;超过冷却期仍异常则重复提醒一次;
- * 2. 指标恢复正常时发送一条"已恢复"通知(含本次异常持续时长);
- * 3. 所有通知都带环境(APP_ENV)、命中详情与排查建议;
- * 4. 连续次数、冷却状态记录在 Redis(ALERT_KEY_PREFIX + 规则名),ALERT_STATE_TTL 后自动过期;
- * 5. 钉钉通知直接复用项目已有的 sendNotice()(读取 .env 的 DD_WEB_HOOK);
- * 未配置 webhook 或发送失败都只记录日志,不影响命令执行。
- * ===========================================================================================
- */
- class CheckAiTaskHealthCommand extends Command
- {
- /**
- * 命令名与参数
- *
- * @var string
- */
- protected $signature = 'ai:check-task-health {--dry-run : 只输出判定结果,不发送钉钉也不写入状态}';
- /**
- * 命令说明
- *
- * @var string
- */
- protected $description = '图片任务健康检查(队列积压/任务卡住/失败突增)并在异常时发送钉钉告警';
- // ============================== 规则A:队列积压 ==============================
- /** 监控的 Redis 队列:队列名 => 展示名(Laravel Redis 队列的 key 为 queues:{队列名}) */
- const MONITOR_QUEUES = [
- '{GenerateGptImage2Pics}' => 'GPT-Image2',
- '{GenerateVolcPics}' => '火山API',
- ];
- /** 队列积压条数阈值(达到即算命中) */
- const QUEUE_BACKLOG_THRESHOLD = 200;
- /** 队列积压需要连续命中的次数(每分钟检查一次) */
- const QUEUE_BACKLOG_CONSECUTIVE = 3;
- // ============================== 规则B:任务长时间未启动 ==============================
- /** pending 超时时间(分钟),超过即算命中 */
- const PENDING_TIMEOUT_MINUTES = 10;
- /** pending 超时需要连续命中的次数 */
- const PENDING_CONSECUTIVE = 2;
- // ============================== 规则C:任务长时间处理中 ==============================
- /** processing 超时时间(分钟),超过即算命中 */
- const PROCESSING_TIMEOUT_MINUTES = 15;
- /** processing 超时需要连续命中的次数 */
- const PROCESSING_CONSECUTIVE = 2;
- // ============================== 规则D:失败突增 ==============================
- /** 失败统计的滑动窗口(分钟) */
- const FAILED_WINDOW_MINUTES = 10;
- /** 窗口内失败条数阈值(超过才告警) */
- const FAILED_THRESHOLD = 20;
- /** 失败突增需要连续命中的次数 */
- const FAILED_CONSECUTIVE = 2;
- /** 失败样例在告警中展示的条数 */
- const FAILED_SAMPLE_LIMIT = 3;
- // ============================== 通用参数 ==============================
- /** 同一规则告警后的冷却时间(分钟),冷却期内不重复发送 */
- const ALERT_COOLDOWN_MINUTES = 30;
- /** 告警状态在 Redis 中的 key 前缀 */
- const ALERT_KEY_PREFIX = 'ai_task_alert:';
- /** 告警状态保留时间(秒),避免 Redis 中残留脏状态 */
- const ALERT_STATE_TTL = 604800; // 7 天
- /** 使用的 Redis 连接,必须与队列保持一致 */
- const ALERT_REDIS_CONNECTION = 'default';
- /** 展示错误信息时截断的字符数 */
- const ERROR_MESSAGE_LIMIT = 80;
- /**
- * 执行命令
- *
- * @return int
- */
- public function handle()
- {
- $dryRun = (bool) $this->option('dry-run');
- $this->info('图片任务健康检查开始' . ($dryRun ? '(dry-run:不发送钉钉、不写入状态)' : ''));
- $this->line('环境:' . config('app.env') . ' 时间:' . now()->toDateTimeString());
- try {
- // 采集 4 项规则
- $rules = [
- $this->buildQueueBacklogRule(),
- $this->buildPendingTimeoutRule(),
- $this->buildProcessingTimeoutRule(),
- $this->buildFailedSpikeRule(),
- ];
- foreach ($rules as $rule) {
- $this->processRule($rule, $dryRun);
- }
- } catch (\Throwable $e) {
- // 检查本身异常不影响其它定时任务,只记录并输出
- $this->error('图片任务健康检查执行失败:' . $e->getMessage());
- dLog('generate')->error('图片任务健康检查执行失败', ['error' => $e->getMessage()]);
- return 1;
- }
- $this->info('图片任务健康检查结束');
- return 0;
- }
- /**
- * 规则处理:连续计数、冷却、告警与恢复通知、状态落库
- *
- * @param array $rule 规则数据
- * @param bool $dryRun 是否演练模式(不发通知、不写状态)
- * @return void
- */
- private function processRule(array $rule, bool $dryRun): void
- {
- $state = $this->loadState($rule['key']);
- $cooldownSeconds = self::ALERT_COOLDOWN_MINUTES * 60;
- $this->outputRuleResult($rule, $state);
- if ($rule['triggered']) {
- $state['consecutive'] = (int) $state['consecutive'] + 1;
- if ($state['consecutive'] === 1 && empty($state['first_hit_at'])) {
- $state['first_hit_at'] = time();
- }
- if (!$state['alerting']) {
- // 未处于告警状态:连续命中达到配置次数才发送
- if ($state['consecutive'] >= (int) $rule['consecutive']) {
- if ($dryRun) {
- $this->line(' → [dry-run] 已达到连续命中次数,将发送告警');
- } else {
- $this->sendAlert($rule, false);
- $state['alerting'] = true;
- $state['alerted_at'] = time();
- }
- } else {
- $this->line(' → 命中 ' . $state['consecutive'] . '/' . $rule['consecutive'] . ' 次,未达连续次数,暂不告警');
- }
- } else {
- // 已在告警中:冷却结束后重复提醒
- $elapsed = time() - (int) $state['alerted_at'];
- if ($elapsed >= $cooldownSeconds) {
- if ($dryRun) {
- $this->line(' → [dry-run] 冷却期已过且异常仍在持续,将重复发送告警');
- } else {
- $this->sendAlert($rule, true);
- $state['alerted_at'] = time();
- }
- } else {
- $this->line(' → 告警中,冷却剩余约 ' . (int) ceil(($cooldownSeconds - $elapsed) / 60) . ' 分钟');
- }
- }
- } else {
- if ($state['alerting']) {
- if ($dryRun) {
- $this->line(' → [dry-run] 指标已恢复,将发送恢复通知');
- } else {
- $this->sendRecovery($rule, $state);
- }
- $state['alerting'] = false;
- }
- $state['consecutive'] = 0;
- $state['first_hit_at'] = 0;
- }
- if (!$dryRun) {
- $state['last_detail'] = implode(' | ', $rule['detail']);
- $state['last_check_at'] = time();
- $this->saveState($rule['key'], $state);
- }
- }
- /**
- * 规则A:队列积压
- *
- * @return array
- */
- private function buildQueueBacklogRule(): array
- {
- $detail = [];
- $hit = [];
- try {
- foreach (self::MONITOR_QUEUES as $queue => $label) {
- $length = (int) Redis::connection(self::ALERT_REDIS_CONNECTION)->llen('queues:' . $queue);
- $detail[] = $label . '队列(queues:' . $queue . '):' . $length . ' 条,阈值 ' . self::QUEUE_BACKLOG_THRESHOLD . ' 条';
- if ($length >= self::QUEUE_BACKLOG_THRESHOLD) {
- $hit[] = $label . '=' . $length;
- }
- }
- } catch (\Throwable $e) {
- // Redis 不可用时无法判定队列长度,只提示不告警(避免误报)
- $detail[] = 'Redis 队列长度读取失败:' . $e->getMessage();
- $hit = [];
- }
- return [
- 'key' => 'queue_backlog',
- 'title' => '队列积压',
- 'rule_text' => '队列长度 >= ' . self::QUEUE_BACKLOG_THRESHOLD . ' 条,连续 ' . self::QUEUE_BACKLOG_CONSECUTIVE . ' 次命中',
- 'consecutive' => self::QUEUE_BACKLOG_CONSECUTIVE,
- 'triggered' => !empty($hit),
- 'detail' => $detail,
- 'hit_text' => implode(',', $hit),
- 'suggest' => '检查 worker 进程是否存活(supervisorctl status generate_gpt_image2_pics:*),确认队列名与 Redis 实例和派发方一致',
- ];
- }
- /**
- * 规则B:任务长时间未启动
- *
- * @return array
- */
- private function buildPendingTimeoutRule(): array
- {
- $deadline = now()->subMinutes(self::PENDING_TIMEOUT_MINUTES);
- $query = DB::table('mp_generate_pic_tasks')
- ->where('status', MpGeneratePicTask::STATUS_PENDING)
- ->whereIn('model', $this->pendingWatchModels())
- ->where('created_at', '<', $deadline);
- $count = (clone $query)->count();
- $detail = ['pending 超过 ' . self::PENDING_TIMEOUT_MINUTES . ' 分钟的任务:' . $count . ' 条'];
- if ($count > 0) {
- $oldest = (clone $query)->orderBy('created_at', 'asc')->first();
- if ($oldest) {
- $detail[] = '最早一条:id=' . $oldest->id
- . ',模型=' . $oldest->model
- . ',外部任务号=' . $oldest->task_id
- . ',创建于 ' . $oldest->created_at
- . '(已等待 ' . $this->minutesSince($oldest->created_at) . ' 分钟)';
- }
- }
- return [
- 'key' => 'pending_timeout',
- 'title' => '任务长时间未启动',
- 'rule_text' => 'pending 超过 ' . self::PENDING_TIMEOUT_MINUTES . ' 分钟的任务数 > 0,连续 ' . self::PENDING_CONSECUTIVE . ' 次命中',
- 'consecutive' => self::PENDING_CONSECUTIVE,
- 'triggered' => $count > 0,
- 'detail' => $detail,
- 'hit_text' => '超时 pending ' . $count . ' 条',
- 'suggest' => '任务已入队但未被消费,优先检查对应队列的 worker 与队列积压情况',
- ];
- }
- /**
- * 规则C:任务长时间处理中
- *
- * @return array
- */
- private function buildProcessingTimeoutRule(): array
- {
- $deadline = now()->subMinutes(self::PROCESSING_TIMEOUT_MINUTES);
- $query = DB::table('mp_generate_pic_tasks')
- ->where('status', MpGeneratePicTask::STATUS_PROCESSING)
- ->whereNotNull('started_at')
- ->where('started_at', '<', $deadline);
- $count = (clone $query)->count();
- $detail = ['processing 超过 ' . self::PROCESSING_TIMEOUT_MINUTES . ' 分钟的任务:' . $count . ' 条'];
- if ($count > 0) {
- $oldest = (clone $query)->orderBy('started_at', 'asc')->first();
- if ($oldest) {
- $detail[] = '最早一条:id=' . $oldest->id
- . ',模型=' . $oldest->model
- . ',外部任务号=' . $oldest->task_id
- . ',开始于 ' . $oldest->started_at
- . '(已处理 ' . $this->minutesSince($oldest->started_at) . ' 分钟)';
- }
- }
- return [
- 'key' => 'processing_timeout',
- 'title' => '任务长时间处理中',
- 'rule_text' => 'processing 超过 ' . self::PROCESSING_TIMEOUT_MINUTES . ' 分钟的任务数 > 0,连续 ' . self::PROCESSING_CONSECUTIVE . ' 次命中',
- 'consecutive' => self::PROCESSING_CONSECUTIVE,
- 'triggered' => $count > 0,
- 'detail' => $detail,
- 'hit_text' => '超时 processing ' . $count . ' 条',
- 'suggest' => '检查第三方接口是否超时/限流,以及 worker 是否被强杀(supervisor 的 stopwaitsecs 是否小于任务超时时间)',
- ];
- }
- /**
- * 规则D:失败突增
- *
- * @return array
- */
- private function buildFailedSpikeRule(): array
- {
- $deadline = now()->subMinutes(self::FAILED_WINDOW_MINUTES);
- $query = DB::table('mp_generate_pic_tasks')
- ->where('status', MpGeneratePicTask::STATUS_FAILED)
- ->where('updated_at', '>=', $deadline);
- $count = (clone $query)->count();
- $detail = ['最近 ' . self::FAILED_WINDOW_MINUTES . ' 分钟内失败的任务:' . $count . ' 条,阈值 > ' . self::FAILED_THRESHOLD . ' 条'];
- if ($count > self::FAILED_THRESHOLD) {
- $samples = (clone $query)->orderBy('updated_at', 'desc')->limit(self::FAILED_SAMPLE_LIMIT)->get(['id', 'model', 'error_message', 'updated_at']);
- foreach ($samples as $sample) {
- $detail[] = '样例:id=' . $sample->id
- . ',模型=' . $sample->model
- . ',时间=' . $sample->updated_at
- . ',错误=' . mb_substr((string) $sample->error_message, 0, self::ERROR_MESSAGE_LIMIT);
- }
- }
- return [
- 'key' => 'failed_spike',
- 'title' => '失败任务突增',
- 'rule_text' => '最近 ' . self::FAILED_WINDOW_MINUTES . ' 分钟内失败数 > ' . self::FAILED_THRESHOLD . ' 条,连续 ' . self::FAILED_CONSECUTIVE . ' 次命中',
- 'consecutive' => self::FAILED_CONSECUTIVE,
- 'triggered' => $count > self::FAILED_THRESHOLD,
- 'detail' => $detail,
- 'hit_text' => '失败 ' . $count . ' 条',
- 'suggest' => '检查第三方账号/额度状态与内容审核拦截,确认接口返回的错误码',
- ];
- }
- /**
- * pending 规则监控的模型:仅"队列派发型"(GPT-Image2、火山系列)
- *
- * 即梦(jimeng_4.0)、NanoBanana 走内部排队/同步提交,长时间 pending 属正常,不参与告警。
- *
- * @return array
- */
- private function pendingWatchModels(): array
- {
- return array_merge(BaseConst::GPT_IMAGE2_MODELS, BaseConst::VOLC_PIC_MODELS);
- }
- /**
- * 控制台输出单条规则的判定结果
- *
- * @param array $rule
- * @param array $state
- * @return void
- */
- private function outputRuleResult(array $rule, array $state): void
- {
- $status = $rule['triggered']
- ? '命中(连续 ' . ((int) $state['consecutive'] + 1) . '/' . $rule['consecutive'] . ')'
- : '正常';
- $this->line('【' . $rule['title'] . '】' . $status);
- foreach ($rule['detail'] as $line) {
- $this->line(' - ' . $line);
- }
- }
- /**
- * 发送告警(钉钉 + 控制台 + 日志)
- *
- * @param array $rule
- * @param bool $repeat 是否为持续告警的重复提醒
- * @return void
- */
- private function sendAlert(array $rule, bool $repeat): void
- {
- $lines = [
- ($repeat ? '【图片任务告警·持续】' : '【图片任务告警】') . $rule['title'],
- '环境:' . config('app.env'),
- '规则:' . $rule['rule_text'],
- '当前:' . $rule['hit_text'],
- ];
- foreach ($rule['detail'] as $line) {
- $lines[] = ' - ' . $line;
- }
- if ($repeat) {
- $lines[] = '说明:该异常仍在持续,距上次通知已 ' . self::ALERT_COOLDOWN_MINUTES . ' 分钟';
- }
- $lines[] = '建议:' . $rule['suggest'];
- $lines[] = '时间:' . now()->toDateTimeString();
- $content = implode("\n", $lines);
- $this->error($content);
- dLog('generate')->error('图片任务健康告警:' . $rule['title'], [
- 'rule' => $rule['key'],
- 'detail' => $rule['detail'],
- ]);
- logDB('generate', 'error', '图片任务健康告警:' . $rule['title'], [
- 'rule' => $rule['key'],
- 'detail' => $rule['detail'],
- ]);
- $this->notify($content);
- }
- /**
- * 发送恢复通知(只在上一轮处于告警状态时发送)
- *
- * @param array $rule
- * @param array $state
- * @return void
- */
- private function sendRecovery(array $rule, array $state): void
- {
- $lines = [
- '【图片任务恢复】' . $rule['title'] . ' 已恢复正常',
- '环境:' . config('app.env'),
- ];
- if (!empty($state['first_hit_at'])) {
- $lines[] = '本次异常持续约 ' . (int) ceil((time() - (int) $state['first_hit_at']) / 60) . ' 分钟';
- }
- foreach ($rule['detail'] as $line) {
- $lines[] = ' - ' . $line;
- }
- $lines[] = '时间:' . now()->toDateTimeString();
- $content = implode("\n", $lines);
- $this->info($content);
- dLog('generate')->info('图片任务健康恢复:' . $rule['title'], ['rule' => $rule['key']]);
- logDB('generate', 'info', '图片任务健康恢复:' . $rule['title'], ['rule' => $rule['key']]);
- $this->notify($content);
- }
- /**
- * 发送钉钉通知
- *
- * 直接复用项目已有的 sendNotice()(读取线上 .env 的 DD_WEB_HOOK,默认 @所有人)。
- * webhook 未配置或发送失败都只记录日志,绝不影响检查命令本身。
- *
- * @param string $content
- * @return void
- */
- private function notify(string $content): void
- {
- if (empty(env('DD_WEB_HOOK'))) {
- $this->warn('DD_WEB_HOOK 未配置,跳过钉钉发送');
- dLog('generate')->warning('图片任务健康告警未发送:DD_WEB_HOOK 未配置', ['content' => $content]);
- return;
- }
- try {
- sendNotice($content);
- } catch (\Throwable $e) {
- $this->warn('钉钉发送失败:' . $e->getMessage());
- dLog('generate')->error('图片任务健康告警发送失败', [
- 'error' => $e->getMessage(),
- 'content' => $content,
- ]);
- }
- }
- /**
- * 读取规则状态(连续命中次数、是否告警中、首次命中时间等)
- *
- * @param string $ruleKey
- * @return array
- */
- private function loadState(string $ruleKey): array
- {
- $default = [
- 'consecutive' => 0,
- 'alerting' => false,
- 'alerted_at' => 0,
- 'first_hit_at'=> 0,
- ];
- try {
- $raw = Redis::connection(self::ALERT_REDIS_CONNECTION)->get($this->stateKey($ruleKey));
- if (empty($raw)) {
- return $default;
- }
- $state = json_decode($raw, true);
- if (!is_array($state)) {
- return $default;
- }
- return array_merge($default, $state);
- } catch (\Throwable $e) {
- dLog('generate')->warning('读取图片任务告警状态失败', [
- 'rule' => $ruleKey,
- 'error' => $e->getMessage(),
- ]);
- return $default;
- }
- }
- /**
- * 保存规则状态
- *
- * @param string $ruleKey
- * @param array $state
- * @return void
- */
- private function saveState(string $ruleKey, array $state): void
- {
- try {
- Redis::connection(self::ALERT_REDIS_CONNECTION)->set(
- $this->stateKey($ruleKey),
- json_encode($state, JSON_UNESCAPED_UNICODE),
- 'EX',
- self::ALERT_STATE_TTL
- );
- } catch (\Throwable $e) {
- dLog('generate')->warning('保存图片任务告警状态失败', [
- 'rule' => $ruleKey,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- /**
- * 规则状态在 Redis 中的 key
- *
- * @param string $ruleKey
- * @return string
- */
- private function stateKey(string $ruleKey): string
- {
- return self::ALERT_KEY_PREFIX . 'state:' . $ruleKey;
- }
- /**
- * 计算某个时间点距今经过的分钟数
- *
- * @param string $dateTime
- * @return int
- */
- private function minutesSince(string $dateTime): int
- {
- $timestamp = strtotime($dateTime);
- return $timestamp ? (int) floor((time() - $timestamp) / 60) : 0;
- }
- }
|