You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

74 lines
6.0 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Console\Commands;
use App\Models\CameraAlert;
use App\Models\CameraDevice;
use App\Models\CameraMonitorSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
class CameraMonitorSyncCommand extends Command
{
protected $signature = 'camera-monitor:sync';
protected $description = '同步海康摄像头健康状态,仅在需要人工处理时发送邮件';
public function handle(): int
{
$base = rtrim((string) config('services.people_counting.url', env('PEOPLE_COUNTING_URL', '')), '/');
$base = preg_replace('#/api/people-counting$#i', '', $base);
if (! $base) { $this->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]);
}
}
}
}