with(['venue:id,name','health']); $this->scopeVenue($request, $q); if ($request->filled('venue_id')) $q->where('venue_id', (int) $request->input('venue_id')); if ($request->filled('status')) $q->whereHas('health', fn ($x) => $x->where('status', $request->input('status'))); if ($request->filled('keyword')) $q->where(fn ($x) => $x->where('name','like','%'.$request->input('keyword').'%')->orWhere('ip','like','%'.$request->input('keyword').'%')); return response()->json($q->orderBy('venue_id')->orderBy('id')->paginate(min(200, max(1, (int) $request->input('page_size', 100))))); } public function store(Request $request): JsonResponse { $this->ensurePlatformAdmin($request); $data = $this->validateDevice($request); $camera = new CameraDevice($data); $camera->venue_name = optional($camera->venue)->name; if ($request->filled('password')) $camera->setPlainPassword((string) $request->input('password')); $camera->save(); $camera->health()->create(['status' => 'UNKNOWN', 'observed_at' => now()]); return response()->json($camera->load(['venue:id,name','health']), 201); } public function update(Request $request, CameraDevice $cameraDevice): JsonResponse { $this->ensureCameraAccess($request, $cameraDevice, true); $data = $this->validateDevice($request, $cameraDevice->id); if ($request->has('password')) $cameraDevice->setPlainPassword((string) $request->input('password')); $cameraDevice->fill($data); if (array_key_exists('venue_id', $data)) $cameraDevice->venue_name = optional($cameraDevice->venue)->name; $cameraDevice->save(); return response()->json($cameraDevice->load(['venue:id,name','health'])); } public function destroy(Request $request, CameraDevice $cameraDevice): JsonResponse { $this->ensureCameraAccess($request, $cameraDevice, true); $cameraDevice->update(['monitor_enabled' => false, 'notification_enabled' => false]); $cameraDevice->delete(); return response()->json(['message' => '摄像头已停用']); } public function health(Request $request): JsonResponse { $q = CameraDevice::query()->with(['venue:id,name','health']); $this->scopeVenue($request, $q); $rows = $q->orderBy('venue_id')->orderBy('id')->get(); $base = rtrim((string) config('services.people_counting.url', env('PEOPLE_COUNTING_URL', '')), '/'); $base = preg_replace('#/api/people-counting$#i', '', $base); $remoteHealthy = false; if ($base) { try { $remote = Http::connectTimeout(5)->timeout(20)->acceptJson()->get($base.'/api/device-health'); if ($remote->successful()) { $remoteHealthy = true; $remoteByKey = collect($remote->json('devices', []))->keyBy(fn ($r) => trim((string) ($r['ip'] ?? '')).'#'.(int) ($r['channel'] ?? 1)); $rows->each(function ($camera) use ($remoteByKey) { $r = $remoteByKey->get(trim((string) $camera->ip).'#'.(int) $camera->channel); if (! $r) return; $health = $camera->health ?: $camera->health()->firstOrCreate([]); $health->fill([ 'status' => $r['healthStatus'] ?? ($r['pushStatus'] ?? ($r['sdkStatus'] ?? 'UNKNOWN')), 'sdk_status' => $r['sdkStatus'] ?? null, 'tcp_reachable' => array_key_exists('tcpReachable', $r) ? $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()]); } $health->setAttribute('last_pdc_at_source', $r['lastPdcAt'] ?? null); $camera->setRelation('health', $health); }); } } catch (\Throwable) { /* Java service may be temporarily unavailable; keep last state. */ } } if (! $remoteHealthy) { $rows->each(function ($camera) { $health = $camera->health ?: $camera->health()->firstOrCreate([]); $health->fill(['status' => 'SERVICE_UNAVAILABLE', 'sdk_status' => null, 'tcp_reachable' => null, 'push_age_seconds' => null, 'observed_at' => now()]); $camera->setRelation('health', $health); }); } $counts = $rows->groupBy(fn ($x) => $x->health?->status ?? 'UNKNOWN')->map->count(); return response()->json(['devices' => $rows, 'summary' => ['total' => $rows->count(), 'byStatus' => $counts]]); } public function alerts(Request $request): JsonResponse { $q = CameraAlert::query()->with(['camera:id,name,ip,channel','venue:id,name']); $this->scopeVenue($request, $q, 'venue_id'); if ($request->filled('status')) $q->where('status', $request->input('status')); if ($request->filled('alert_type')) $q->where('alert_type', $request->input('alert_type')); return response()->json($q->latest('last_occurred_at')->paginate(min(200, max(1, (int) $request->input('page_size', 50))))); } public function acknowledge(Request $request, CameraAlert $cameraAlert): JsonResponse { $this->ensureCameraAccess($request, $cameraAlert->camera, false); $cameraAlert->update(['status' => 'ACKNOWLEDGED', 'acknowledged_by' => $request->user()->id, 'acknowledged_at' => now()]); return response()->json($cameraAlert->fresh(['camera','venue'])); } public function settings(Request $request): JsonResponse { $this->ensurePlatformAdmin($request); return response()->json(CameraMonitorSetting::current()); } public function updateSettings(Request $request): JsonResponse { $this->ensurePlatformAdmin($request); $data = $request->validate([ 'timezone' => ['required','string','max:64'], 'notification_start_time' => ['required','date_format:H:i'], 'notification_end_time' => ['required','date_format:H:i'], 'default_push_interval_seconds' => ['required','integer','min:30','max:3600'], 'default_push_timeout_seconds' => ['required','integer','min:60','max:86400'], 'probe_interval_seconds' => ['required','integer','min:60','max:86400'], 'probe_failure_threshold' => ['required','integer','min:1','max:10'], 'auto_reconnect_max_attempts' => ['required','integer','min:0','max:20'], 'send_manual_action_email' => ['boolean'], 'send_recovery_email' => ['boolean'], 'recipient_emails' => ['array'], 'recipient_emails.*' => ['email','max:255'], ]); $s = CameraMonitorSetting::current(); $s->fill($data); $s->save(); return response()->json($s); } public function retry(Request $request, CameraDevice $cameraDevice): JsonResponse { $this->ensureCameraAccess($request, $cameraDevice, true); $base = rtrim((string) config('services.people_counting.url', env('PEOPLE_COUNTING_URL', '')), '/'); $base = preg_replace('#/api/people-counting$#i', '', $base); abort_unless($base, 503, '未配置客流服务地址'); $cameraDevice->health()->updateOrCreate([], ['status' => 'RECONNECTING', 'observed_at' => now()]); if ($cameraDevice->device_slot) { $res = Http::timeout(45)->withHeaders(['X-Camera-Monitor-Token' => (string) config('services.camera_monitor.internal_token')])->acceptJson()->post($base.'/api/device-links/retry', ['deviceIndices' => [(int) $cameraDevice->device_slot]]); } else { $next = ((int) CameraDevice::withTrashed()->max('device_slot')) + 1; $cameraDevice->update(['device_slot' => $next]); $res = Http::timeout(45)->withHeaders(['X-Camera-Monitor-Token' => (string) config('services.camera_monitor.internal_token')])->acceptJson()->post($base.'/api/device-links/register', [ 'deviceSlot' => $next, 'name' => $cameraDevice->name, 'ip' => $cameraDevice->ip, 'port' => $cameraDevice->sdk_port, 'username' => $cameraDevice->username ?: 'admin', 'password' => $cameraDevice->plainPassword(), ]); } return response()->json($res->json() ?: ['code' => $res->status(), 'message' => '海康服务返回空响应'], $res->status()); } private function validateDevice(Request $request, ?int $ignoreId = null): array { return $request->validate([ 'venue_id' => ['nullable','integer','exists:venues,id'], 'name' => ['required','string','max:191'], 'ip' => ['required','string','max:191'], 'sdk_port' => ['required','integer','min:1','max:65535'], 'http_port' => ['nullable','integer','min:1','max:65535'], 'channel' => ['required','integer','min:1','max:255'], 'device_slot' => ['nullable','integer','min:1','max:100000'], 'group_id' => ['nullable','string','max:191'], 'username' => ['nullable','string','max:191'], 'password' => ['nullable','string','max:255'], 'monitor_enabled' => ['boolean'], 'notification_enabled' => ['boolean'], 'include_in_venue_stats' => ['boolean'], 'maintenance_until' => ['nullable','date'], 'push_timeout_seconds' => ['nullable','integer','min:60','max:86400'], 'remark' => ['nullable','string'], ]); } private function ensurePlatformAdmin(Request $request): void { abort_unless($request->user()?->isSuperAdmin(), 403, '仅平台管理员可操作'); } private function ensureCameraAccess(Request $request, CameraDevice $camera, bool $write): void { $u = $request->user(); abort_unless($u, 403, '无权限'); if ($u->isSuperAdmin()) return; abort_unless($camera->venue_id && $u->venues()->whereKey($camera->venue_id)->exists(), 403, '无该场馆权限'); if ($write) abort(403, '场馆管理员不可修改摄像头配置'); } private function scopeVenue(Request $request, $query, string $column = 'venue_id'): void { $u = $request->user(); if ($u && ! $u->isSuperAdmin()) $query->whereIn($column, $u->venues()->pluck('venues.id')); } }