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.
192 lines
11 KiB
192 lines
11 KiB
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\CameraAlert;
|
|
use App\Models\CameraDevice;
|
|
use App\Models\CameraMonitorSetting;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class CameraMonitoringController extends Controller
|
|
{
|
|
public function devices(Request $request): JsonResponse
|
|
{
|
|
$q = CameraDevice::query()->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'));
|
|
}
|
|
}
|