|
|
@@ -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,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|