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

<?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'));
}
}