warn('未配置 PEOPLE_COUNTING_URL'); return self::SUCCESS; } try { $remote = Http::connectTimeout(5)->timeout(20)->acceptJson()->get($base.'/api/device-health'); } catch (\Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; } if (! $remote->successful()) { $this->error('海康健康接口返回 '.$remote->status()); return self::FAILURE; } $byKey = collect($remote->json('devices', []))->keyBy(fn ($r) => trim((string) ($r['ip'] ?? '')).'#'.(int) ($r['channel'] ?? 1)); $settings = CameraMonitorSetting::current(); CameraDevice::query()->where('monitor_enabled', true)->with('health')->each(function (CameraDevice $camera) use ($byKey, $settings) { if ($camera->isInMaintenance()) return; $r = $byKey->get(trim($camera->ip).'#'.(int) $camera->channel); if (! $r) return; $health = $camera->health ?: $camera->health()->firstOrCreate([]); $status = (string) ($r['healthStatus'] ?? ($r['pushStatus'] ?? ($r['sdkStatus'] ?? 'UNKNOWN'))); $health->fill(['status' => $status, 'sdk_status' => $r['sdkStatus'] ?? null, 'tcp_reachable' => $r['tcpReachable'] ?? null, 'last_pdc_at' => $r['lastPdcAt'] ?? null, 'pdc_count' => $r['pdcCount'] ?? 0, 'push_age_seconds' => (($r['pushAgeSeconds'] ?? -1) >= 0 ? $r['pushAgeSeconds'] : null), 'last_tcp_error' => $r['networkReason'] ?? null, 'reconnect_attempts' => $r['autoReconnectFailures'] ?? 0, 'observed_at' => now()])->save(); if (($r['sdkStatus'] ?? null) === 'ONLINE' && $camera->venue_id) { CameraAlert::query()->where('venue_id', $camera->venue_id)->whereIn('status', ['OPEN', 'ACKNOWLEDGED'])->update(['status' => 'RESOLVED', 'recovered_at' => now()]); } $abnormal = in_array($status, ['NETWORK_DOWN', 'LOGIN_FAILED', 'ARM_FAILED', 'PUSH_STALE', 'NO_PUSH_YET', 'PUSH_SUSPECT'], true); if (! $abnormal) return; $needsManual = (bool) ($r['manualActionRequired'] ?? false); $alertType = $needsManual ? 'MANUAL_ACTION_REQUIRED' : 'CAMERA_ABNORMAL'; $reason = $r['networkReason'] ?? match ($status) { 'NETWORK_DOWN' => '服务器无法连接摄像头网络或 SDK 端口,请检查设备电源、网络和路由。', 'LOGIN_FAILED' => '网络可连接,但摄像头 SDK 登录失败,请检查账号、密码和设备权限。', 'ARM_FAILED' => '摄像头登录成功,但布防失败,请检查告警通道和布防参数。', 'PUSH_STALE' => '超过设定时间没有收到客流推送。', 'NO_PUSH_YET' => 'Java 服务启动后尚未收到该摄像头的客流推送。', default => '摄像头客流推送状态异常。', }; $alert = CameraAlert::query()->where('camera_device_id', $camera->id)->where('alert_type', $alertType)->whereIn('status', ['OPEN','ACKNOWLEDGED'])->latest('id')->first(); if (! $alert) { $alert = CameraAlert::create(['camera_device_id' => $camera->id, 'venue_id' => $camera->venue_id, 'alert_type' => $alertType, 'status' => 'OPEN', 'severity' => $needsManual ? 'critical' : 'warning', 'title' => $needsManual ? '摄像头需要人工处理' : '摄像头状态异常', 'reason' => $reason, 'first_occurred_at' => now(), 'last_occurred_at' => now()]); } else { $alert->update(['last_occurred_at' => now(), 'reason' => $reason]); } $alreadySent = $alert->notifications()->where('channel', 'email')->where('send_status', 'sent')->exists(); if ($needsManual && $camera->notification_enabled && $settings->send_manual_action_email && ! $alreadySent) $this->notify($alert, $camera, $settings); }); return self::SUCCESS; } private function notify(CameraAlert $alert, CameraDevice $camera, CameraMonitorSetting $settings): void { $tz = $settings->timezone ?: 'Asia/Shanghai'; $now = Carbon::now($tz); $start = Carbon::parse($now->format('Y-m-d').' '.$settings->notification_start_time, $tz); $end = Carbon::parse($now->format('Y-m-d').' '.$settings->notification_end_time, $tz); if (! $now->between($start, $end, true)) return; foreach (array_values(array_filter($settings->recipient_emails ?? [])) as $to) { try { Mail::raw("场馆:".($camera->venue_name ?: '未绑定')."\n摄像头:{$camera->name}\nIP:{$camera->ip}\n通道:{$camera->channel}\n状态:需要人工处理\n最后推送:".($camera->health?->last_pdc_at?->toDateTimeString() ?: '无')."\n原因:自动重连多次失败,请检查设备电源、网络或重启摄像头后到后台手动重试。", function ($m) use ($to) { $m->to($to)->subject('海康摄像头需要人工处理'); }); $alert->increment('notification_count'); $alert->update(['last_notified_at' => now()]); $alert->notifications()->create(['channel' => 'email', 'receiver' => $to, 'send_status' => 'sent', 'sent_at' => now(), 'attempts' => 1]); } catch (\Throwable $e) { $this->error($to.': '.$e->getMessage()); $alert->notifications()->create(['channel' => 'email', 'receiver' => $to, 'send_status' => 'failed', 'error_message' => $e->getMessage(), 'attempts' => 1]); } } } }