Browse Source

新增定时任务脚本检测视频任务同类错误频繁出现的情况并作钉钉告警

lh 1 week ago
parent
commit
144bf27f30
2 changed files with 467 additions and 0 deletions
  1. 464 0
      app/Console/Commands/CheckVideoTaskFailuresCommand.php
  2. 3 0
      app/Console/Kernel.php

+ 464 - 0
app/Console/Commands/CheckVideoTaskFailuresCommand.php

@@ -0,0 +1,464 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redis;
+
+/**
+ * 视频任务特定错误频发检测 & 钉钉告警
+ *
+ * 背景:百度 seedance 通道历史上出现过几类"基础设施型"失败(网关 500 / 模型无可用通道 /
+ * AK 失效 / 任务态鉴权失败),这些不是内容审核问题,一旦在短时间内反复出现必须人工介入。
+ *
+ * 调度:app/Console/Kernel.php 每分钟一次(ai:check-video-failures)。
+ *
+ * 判定:统计最近 WINDOW 分钟内 status=failed 且命中关键词的视频任务数,
+ *       单个分类命中数 >= THRESHOLD 即触发钉钉告警;
+ *       同一分类告警后进入 COOLDOWN 冷却期,冷却期内不重复发送;
+ *       该分类恢复到阈值以下时发送一条"已恢复"通知。
+ *       状态(是否告警中 / 首次命中时间 / 上次告警时间)记录在 Redis。
+ *
+ * 说明:失败时间取 completed_at(失败任务都会写),避免被每日巡检改写的 updated_at 干扰;
+ *       内容审核类失败(sensitive / copyright)不在本命令的告警范围内。
+ */
+class CheckVideoTaskFailuresCommand extends Command
+{
+    /**
+     * 命令名与参数
+     *
+     * @var string
+     */
+    protected $signature = 'ai:check-video-failures
+        {--window= : 统计窗口(分钟),默认取 VIDEO_FAILURE_ALERT_WINDOW_MINUTES 或 10}
+        {--threshold= : 单类错误触发阈值(条),默认取 VIDEO_FAILURE_ALERT_THRESHOLD 或 2}
+        {--dry-run : 只输出判定结果,不发送钉钉、不写入告警状态}';
+
+    /**
+     * 命令说明
+     *
+     * @var string
+     */
+    protected $description = '检测视频任务特定错误(百度网关500/模型无通道/AK失效/任务态鉴权失败)的频发情况,达到阈值时钉钉告警';
+
+    /** 默认统计窗口(分钟):覆盖一次批量提交批次,同时避免窗口过长钝化计数 */
+    const DEFAULT_WINDOW_MINUTES = 10;
+
+    /** 默认单类触发阈值:同类错误窗口内出现 >= 3 次即告警 */
+    const DEFAULT_THRESHOLD = 3;
+
+    /** 同一分类告警后的冷却时间(分钟),冷却期内不重复发送 */
+    const COOLDOWN_MINUTES = 20;
+
+    /** 告警状态在 Redis 中的 key 前缀 */
+    const STATE_KEY_PREFIX = 'ai_video_failure_alert:';
+
+    /** 告警状态保留时间(秒),避免 Redis 中残留脏状态 */
+    const STATE_TTL = 604800; // 7 天
+
+    /** 使用的 Redis 连接,与队列保持一致 */
+    const REDIS_CONNECTION = 'default';
+
+    /** 告警中展示的失败样例条数 */
+    const SAMPLE_LIMIT = 5;
+
+    /** 样例错误信息截断长度 */
+    const ERROR_LIMIT = 120;
+
+    /**
+     * 需要告警的错误分类(按顺序匹配,先命中者为准)
+     *
+     * keywords 全部小写;匹配时对 error_message + result_json 整体转小写后查找。
+     * 顺序很关键:401 的响应体里同样含 AuthenticationError,必须先匹配 apikey_not_active。
+     */
+    const CATEGORIES = [
+        'baidu_gateway_500' => [
+            'name'     => '百度网关500(get_channel_failed)',
+            'keywords' => ['get_channel_failed', 'status_code=500'],
+            'suggest'  => '百度 AI 网关取通道失败,联系百度侧确认通道/配额;确认是否集中在 doubao-seedance-2-0-260128',
+        ],
+        'model_channel_unavailable' => [
+            'name'     => '模型无可用通道(model_not_found)',
+            'keywords' => ['model_not_found', 'no available channel for model'],
+            'suggest'  => '模型在百度分组下无可用通道,检查模型白名单/分组(如 BaiDuWx)配置与上游通道状态',
+        ],
+        'apikey_not_active' => [
+            'name'     => 'API Key 失效(401 API key status is not active)',
+            'keywords' => ['api key status is not active'],
+            'suggest'  => '百度网关 AK 状态异常,检查 AK 是否被禁用/过期(如 BAIDU_SEEDANCE_20_AK)',
+        ],
+        'task_authentication_error' => [
+            'name'     => '任务态鉴权失败(AuthenticationError)',
+            'keywords' => ['authenticationerror'],
+            'suggest'  => '异步视频任务返回鉴权失败,通常是上游 AK/通道问题,检查对应通道 AK 与配额',
+        ],
+    ];
+
+    /**
+     * 执行命令
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $window = $this->resolveIntOption('window', 'VIDEO_FAILURE_ALERT_WINDOW_MINUTES', self::DEFAULT_WINDOW_MINUTES);
+        $threshold = $this->resolveIntOption('threshold', 'VIDEO_FAILURE_ALERT_THRESHOLD', self::DEFAULT_THRESHOLD);
+        $dryRun = (bool) $this->option('dry-run');
+
+        $since = now()->subMinutes($window)->toDateTimeString();
+
+        $this->info('视频任务失败检测开始' . ($dryRun ? '(dry-run:不发送钉钉、不写状态)' : ''));
+        $this->line('环境:' . config('app.env') . ' 窗口:最近 ' . $window . ' 分钟 阈值:' . $threshold . ' 条 起点:' . $since);
+
+        // 失败时间优先取 completed_at;极少数没写 completed_at 的用 updated_at 兜底
+        $tasks = DB::table('mp_generate_video_tasks')
+            ->where('status', 'failed')
+            ->where(function ($query) use ($since) {
+                $query->where('completed_at', '>=', $since)
+                    ->orWhere(function ($sub) use ($since) {
+                        $sub->whereNull('completed_at')->where('updated_at', '>=', $since);
+                    });
+            })
+            ->orderByDesc('completed_at')
+            ->get(['id', 'task_id', 'api_type', 'error_message', 'result_json', 'completed_at', 'updated_at']);
+
+        // 按错误分类归桶
+        $buckets = [];
+        foreach ($tasks as $task) {
+            $category = $this->matchCategory((string) $task->error_message, (string) $task->result_json);
+            if ($category === null) {
+                continue;
+            }
+
+            $buckets[$category][] = $task;
+        }
+
+        foreach (self::CATEGORIES as $key => $category) {
+            $hitTasks = $buckets[$key] ?? [];
+            $count = count($hitTasks);
+
+            $this->line('【' . $category['name'] . '】命中 ' . $count . ' 条(阈值 ' . $threshold . ')');
+
+            if ($count < $threshold) {
+                $this->handleRecovered($key, $category, $dryRun);
+                continue;
+            }
+
+            $this->handleAlert($key, $category, $count, $threshold, $window, $hitTasks, $dryRun);
+        }
+
+        $this->info('视频任务失败检测结束');
+
+        return 0;
+    }
+
+    /**
+     * 读取带默认值的整数选项(命令行优先,其次 env,最后常量默认值)
+     *
+     * @param string $option
+     * @param string $envKey
+     * @param int $default
+     * @return int
+     */
+    private function resolveIntOption(string $option, string $envKey, int $default): int
+    {
+        $value = $this->option($option);
+        if ($value === null || $value === '') {
+            $value = env($envKey, $default);
+        }
+
+        $value = (int) $value;
+
+        return $value > 0 ? $value : $default;
+    }
+
+    /**
+     * 判断失败任务属于哪个告警分类,未命中返回 null
+     *
+     * @param string $errorMessage
+     * @param string $resultJson
+     * @return string|null
+     */
+    private function matchCategory(string $errorMessage, string $resultJson): ?string
+    {
+        $blob = mb_strtolower($errorMessage . ' ' . $resultJson);
+
+        foreach (self::CATEGORIES as $key => $category) {
+            foreach ($category['keywords'] as $keyword) {
+                if (mb_strpos($blob, $keyword) !== false) {
+                    return $key;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 命中阈值:写日志并在冷却期外发送钉钉告警
+     *
+     * @param string $key
+     * @param array $category
+     * @param int $count
+     * @param int $threshold
+     * @param int $window
+     * @param array $tasks
+     * @param bool $dryRun
+     * @return void
+     */
+    private function handleAlert(string $key, array $category, int $count, int $threshold, int $window, array $tasks, bool $dryRun): void
+    {
+        $state = $this->loadState($key);
+        $now = time();
+        $coolingDown = !empty($state['alerted_at'])
+            && ($now - (int) $state['alerted_at']) < self::COOLDOWN_MINUTES * 60;
+
+        $samples = $this->buildSamples($tasks);
+        $content = $this->buildAlertContent($category, $count, $threshold, $window, $samples, $state);
+
+        if ($dryRun) {
+            $this->warn($content);
+            return;
+        }
+
+        if ($coolingDown) {
+            $this->line('  - 冷却中(距上次告警 ' . (int) floor(($now - (int) $state['alerted_at']) / 60) . ' 分钟),跳过钉钉发送');
+            return;
+        }
+
+        dLog('generate')->error('视频任务失败告警:' . $category['name'], [
+            'category'  => $key,
+            'count'     => $count,
+            'window'    => $window,
+            'threshold' => $threshold,
+            'samples'   => $samples,
+        ]);
+        logDB('generate', 'error', '视频任务失败告警', [
+            'category'  => $key,
+            'name'      => $category['name'],
+            'count'     => $count,
+            'window'    => $window,
+            'threshold' => $threshold,
+        ]);
+
+        $this->notify($content);
+
+        $state['alerting'] = true;
+        $state['alerted_at'] = $now;
+        if (empty($state['first_hit_at'])) {
+            $state['first_hit_at'] = $now;
+        }
+        $this->saveState($key, $state);
+    }
+
+    /**
+     * 分类恢复正常:仅在上一轮处于告警状态时发送一次恢复通知
+     *
+     * @param string $key
+     * @param array $category
+     * @param bool $dryRun
+     * @return void
+     */
+    private function handleRecovered(string $key, array $category, bool $dryRun): void
+    {
+        $state = $this->loadState($key);
+        if (empty($state['alerting'])) {
+            return;
+        }
+
+        $content = '【视频任务恢复】' . $category['name'] . ' 已恢复正常';
+        if (!empty($state['first_hit_at'])) {
+            $content .= '(本次异常持续约 ' . (int) ceil((time() - (int) $state['first_hit_at']) / 60) . ' 分钟)';
+        }
+
+        if ($dryRun) {
+            $this->info($content);
+            return;
+        }
+
+        dLog('generate')->info('视频任务失败恢复:' . $category['name'], ['category' => $key]);
+        logDB('generate', 'info', '视频任务失败恢复', ['category' => $key]);
+
+        $this->notify($content);
+
+        $state['alerting'] = false;
+        $state['alerted_at'] = 0;
+        $state['first_hit_at'] = 0;
+        $this->saveState($key, $state);
+    }
+
+    /**
+     * 组装告警文案
+     *
+     * @param array $category
+     * @param int $count
+     * @param int $threshold
+     * @param int $window
+     * @param array $samples
+     * @param array $state
+     * @return string
+     */
+    private function buildAlertContent(array $category, int $count, int $threshold, int $window, array $samples, array $state): string
+    {
+        $lines = [
+            '【视频任务失败告警】' . $category['name'],
+            '环境:' . config('app.env'),
+            '规则:最近 ' . $window . ' 分钟内 status=failed 且命中该错误 >= ' . $threshold . ' 条',
+            '当前:命中 ' . $count . ' 条',
+        ];
+
+        if (!empty($state['first_hit_at'])) {
+            $lines[] = '首次命中:' . date('Y-m-d H:i:s', (int) $state['first_hit_at']);
+        }
+
+        $lines[] = '样例(最多 ' . self::SAMPLE_LIMIT . ' 条):';
+        foreach ($samples as $sample) {
+            $lines[] = '  - ' . $sample;
+        }
+
+        $lines[] = '建议:' . $category['suggest'];
+        $lines[] = '时间:' . now()->toDateTimeString();
+
+        return implode("\n", $lines);
+    }
+
+    /**
+     * 组装用于日志和通知的失败样例
+     *
+     * @param array $tasks
+     * @return array
+     */
+    private function buildSamples(array $tasks): array
+    {
+        $samples = [];
+        foreach (array_slice($tasks, 0, self::SAMPLE_LIMIT) as $task) {
+            $model = $this->extractModel((string) $task->result_json);
+            $error = mb_substr(trim((string) $task->error_message), 0, self::ERROR_LIMIT);
+
+            $samples[] = 'id=' . $task->id
+                . ' task_id=' . $task->task_id
+                . ' api_type=' . $task->api_type
+                . ($model !== '' ? ' model=' . $model : '')
+                . ' completed_at=' . ($task->completed_at ?: $task->updated_at)
+                . ' err=' . $error;
+        }
+
+        return $samples;
+    }
+
+    /**
+     * 从 result_json 中取模型名(部分失败结果带 model 字段)
+     *
+     * @param string $resultJson
+     * @return string
+     */
+    private function extractModel(string $resultJson): string
+    {
+        $decoded = json_decode($resultJson, true);
+        if (is_array($decoded) && !empty($decoded['model']) && is_string($decoded['model'])) {
+            return $decoded['model'];
+        }
+
+        return '';
+    }
+
+    /**
+     * 读取分类告警状态
+     *
+     * @param string $key
+     * @return array
+     */
+    private function loadState(string $key): array
+    {
+        $default = [
+            'alerting'     => false,
+            'alerted_at'   => 0,
+            'first_hit_at' => 0,
+        ];
+
+        try {
+            $raw = Redis::connection(self::REDIS_CONNECTION)->get($this->stateKey($key));
+            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('读取视频任务失败告警状态失败', [
+                'category' => $key,
+                'error'    => $e->getMessage(),
+            ]);
+
+            return $default;
+        }
+    }
+
+    /**
+     * 保存分类告警状态
+     *
+     * @param string $key
+     * @param array $state
+     * @return void
+     */
+    private function saveState(string $key, array $state): void
+    {
+        try {
+            Redis::connection(self::REDIS_CONNECTION)->set(
+                $this->stateKey($key),
+                json_encode($state, JSON_UNESCAPED_UNICODE),
+                'EX',
+                self::STATE_TTL
+            );
+        } catch (\Throwable $e) {
+            dLog('generate')->warning('保存视频任务失败告警状态失败', [
+                'category' => $key,
+                'error'    => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * 分类告警状态在 Redis 中的 key
+     *
+     * @param string $key
+     * @return string
+     */
+    private function stateKey(string $key): string
+    {
+        return self::STATE_KEY_PREFIX . $key;
+    }
+
+    /**
+     * 发送钉钉通知
+     *
+     * 复用项目已有的 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,
+            ]);
+        }
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -72,6 +72,9 @@ class Kernel extends ConsoleKernel
         // 图片任务健康检查与告警(队列积压/任务长时间未完成/失败突增,异常时钉钉通知)
         $schedule->command('ai:check-task-health')->everyMinute()->withoutOverlapping(5);
 
+        // 视频任务特定错误频发检测与告警(网关500/模型无通道/AK失效/任务态鉴权失败,异常时钉钉通知)
+        $schedule->command('ai:check-video-failures')->everyMinute()->withoutOverlapping(5);
+
         // 积分消耗与token用量日统计(聚合前一天明细)
         $schedule->command('stats:points-daily')->dailyAt('00:10')->withoutOverlapping();
     }