From ba96edc90f3ff6bd40b2e61dec9ae6c60c569311 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Tue, 15 Sep 2026 15:20:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../PeopleCountingBackfillCommand.php | 102 +++++++++ .../Commands/PeopleCountingDateRange.php | 62 +++++ .../PeopleCountingSyncDailyCommand.php | 79 +++++++ .../PeopleCountingSyncHourlyCommand.php | 77 +++++++ app/Console/Kernel.php | 10 + app/Models/PeopleCountingDaily.php | 37 +++ app/Models/PeopleCountingHourly.php | 36 +++ .../HikPeopleCountingClient.php | 89 ++++++++ .../PeopleCountingSyncService.php | 213 ++++++++++++++++++ config/services.php | 2 + ...00_create_people_counting_stats_tables.php | 49 ++++ 12 files changed, 759 insertions(+) create mode 100644 app/Console/Commands/PeopleCountingBackfillCommand.php create mode 100644 app/Console/Commands/PeopleCountingDateRange.php create mode 100644 app/Console/Commands/PeopleCountingSyncDailyCommand.php create mode 100644 app/Console/Commands/PeopleCountingSyncHourlyCommand.php create mode 100644 app/Models/PeopleCountingDaily.php create mode 100644 app/Models/PeopleCountingHourly.php create mode 100644 app/Services/PeopleCounting/HikPeopleCountingClient.php create mode 100644 app/Services/PeopleCounting/PeopleCountingSyncService.php create mode 100644 database/migrations/2026_09_15_140000_create_people_counting_stats_tables.php diff --git a/.env.example b/.env.example index 6a9fc00..cbb0ce2 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,9 @@ VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # 工作台 / H5 客流:在馆实时人数(enter−exit),与 H5 VITE_PEOPLE_COUNTING_URL 一致 PEOPLE_COUNTING_URL=https://hik.pdc.langye.net:18080/api/people-counting +# 客流入库任务(people-counting:sync-*)HTTP 超时秒、按日回填间隔毫秒 +# PEOPLE_COUNTING_TIMEOUT=20 +# PEOPLE_COUNTING_SLEEP_MS=200 # 天地图浏览器 Key(H5/后台底图 JS API,VITE_TIANDITU_TK) # TIANDITU_TK= diff --git a/app/Console/Commands/PeopleCountingBackfillCommand.php b/app/Console/Commands/PeopleCountingBackfillCommand.php new file mode 100644 index 0000000..15ba45e --- /dev/null +++ b/app/Console/Commands/PeopleCountingBackfillCommand.php @@ -0,0 +1,102 @@ +option('from')); + $toRaw = trim((string) $this->option('to')); + + try { + $start = $this->parsePeopleCountingDay($fromRaw !== '' ? $fromRaw : '2026-08-03'); + $end = $toRaw !== '' + ? $this->parsePeopleCountingDay($toRaw) + : Carbon::yesterday()->startOfDay(); + if ($start->gt($end)) { + throw new InvalidArgumentException('--from 不能晚于 --to'); + } + } catch (InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $doDaily = ! (bool) $this->option('skip-daily'); + $doHourly = ! (bool) $this->option('skip-hourly'); + $skipIfAllZero = ! (bool) $this->option('include-empty'); + $sleepMs = $this->peopleCountingSleepMs(); + $today = Carbon::today()->toDateString(); + $fail = 0; + + $this->info(sprintf( + 'backfill %s ~ %s daily=%s hourly=%s', + $start->toDateString(), + $end->toDateString(), + $doDaily ? 'yes' : 'no', + $doHourly ? 'yes' : 'no' + )); + + for ($day = $start->copy(); $day->lte($end); $day->addDay()) { + $d = $day->toDateString(); + if ($d > $today) { + $this->warn("跳过未来日期 {$d}"); + continue; + } + if ($doDaily) { + try { + $r = $sync->syncDailyForDate($day, true); + $this->line(sprintf(' daily %s written=%d skipped=%d', $r['date'], $r['written'], $r['skipped'])); + Log::info('people-counting.backfill.daily', $r); + } catch (Throwable $e) { + $fail++; + $this->error(" daily {$d} 失败: ".$e->getMessage()); + Log::warning('people-counting.backfill.daily.failed', [ + 'date' => $d, + 'error' => $e->getMessage(), + ]); + } + } + if ($doHourly) { + try { + $r = $sync->syncHourlyForDate($day, $skipIfAllZero); + $this->line($r['skipped'] + ? sprintf(' hourly %s skipped empty memory dataSource=%s', $r['date'], $r['data_source'] ?? 'null') + : sprintf(' hourly %s written=%d dataSource=%s', $r['date'], $r['written'], $r['data_source'] ?? 'null')); + Log::info('people-counting.backfill.hourly', $r); + } catch (Throwable $e) { + $fail++; + $this->error(" hourly {$d} 失败: ".$e->getMessage()); + Log::warning('people-counting.backfill.hourly.failed', [ + 'date' => $d, + 'error' => $e->getMessage(), + ]); + } + } + if ($day->copy()->addDay()->lte($end) && $sleepMs > 0) { + usleep($sleepMs * 1000); + } + } + + return $fail > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Commands/PeopleCountingDateRange.php b/app/Console/Commands/PeopleCountingDateRange.php new file mode 100644 index 0000000..888eaf7 --- /dev/null +++ b/app/Console/Commands/PeopleCountingDateRange.php @@ -0,0 +1,62 @@ + + */ + protected function peopleCountingDays(?string $date, ?string $from, ?string $to, Carbon $default): array + { + $date = is_string($date) ? trim($date) : ''; + $from = is_string($from) ? trim($from) : ''; + $to = is_string($to) ? trim($to) : ''; + + if ($date !== '') { + return [$this->parsePeopleCountingDay($date)]; + } + if ($from !== '' || $to !== '') { + if ($from === '') { + throw new InvalidArgumentException('指定 --to 时必须同时提供 --from'); + } + $start = $this->parsePeopleCountingDay($from); + $end = $to !== '' ? $this->parsePeopleCountingDay($to) : $default->copy()->startOfDay(); + if ($start->gt($end)) { + throw new InvalidArgumentException('--from 不能晚于 --to'); + } + $days = []; + for ($d = $start->copy(); $d->lte($end); $d->addDay()) { + $days[] = $d->copy(); + } + + return $days; + } + + return [$default->copy()->startOfDay()]; + } + + protected function parsePeopleCountingDay(string $raw): Carbon + { + try { + $dt = Carbon::createFromFormat('Y-m-d', $raw); + } catch (\Throwable $e) { + throw new InvalidArgumentException("日期须为 Y-m-d,收到: {$raw}"); + } + if ($dt === false || $dt->format('Y-m-d') !== $raw) { + throw new InvalidArgumentException("日期须为 Y-m-d,收到: {$raw}"); + } + + return $dt->startOfDay(); + } + + protected function peopleCountingSleepMs(): int + { + $n = (int) config('services.people_counting.sleep_ms', 200); + + return max(0, min($n, 5000)); + } +} diff --git a/app/Console/Commands/PeopleCountingSyncDailyCommand.php b/app/Console/Commands/PeopleCountingSyncDailyCommand.php new file mode 100644 index 0000000..5220256 --- /dev/null +++ b/app/Console/Commands/PeopleCountingSyncDailyCommand.php @@ -0,0 +1,79 @@ +peopleCountingDays( + $this->option('date'), + $this->option('from'), + $this->option('to'), + Carbon::today() + ); + } catch (InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $force = (bool) $this->option('force'); + $protect = ! $force; + $sleepMs = $this->peopleCountingSleepMs(); + $today = Carbon::today()->toDateString(); + $ok = 0; + $fail = 0; + + foreach ($days as $i => $day) { + $d = $day->toDateString(); + if ($d > $today) { + $this->warn("跳过未来日期 {$d}"); + continue; + } + try { + $r = $sync->syncDailyForDate($day, $protect); + $ok++; + $this->info(sprintf( + '%s written=%d skipped=%d venues=%d%s', + $r['date'], + $r['written'], + $r['skipped'], + $r['venues'], + $force ? ' (force)' : '' + )); + Log::info('people-counting.sync-daily', $r); + } catch (Throwable $e) { + $fail++; + $this->error("{$d} 失败: ".$e->getMessage()); + Log::warning('people-counting.sync-daily.failed', [ + 'date' => $d, + 'error' => $e->getMessage(), + ]); + } + if ($i < count($days) - 1 && $sleepMs > 0) { + usleep($sleepMs * 1000); + } + } + + return $fail > 0 && $ok === 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Commands/PeopleCountingSyncHourlyCommand.php b/app/Console/Commands/PeopleCountingSyncHourlyCommand.php new file mode 100644 index 0000000..0f22348 --- /dev/null +++ b/app/Console/Commands/PeopleCountingSyncHourlyCommand.php @@ -0,0 +1,77 @@ +option('today') ? Carbon::today() : Carbon::yesterday(); + + try { + $days = $this->peopleCountingDays( + $this->option('date'), + $this->option('from'), + $this->option('to'), + $default + ); + } catch (InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $skipIfAllZero = ! (bool) $this->option('include-empty'); + $sleepMs = $this->peopleCountingSleepMs(); + $today = Carbon::today()->toDateString(); + $ok = 0; + $fail = 0; + + foreach ($days as $i => $day) { + $d = $day->toDateString(); + if ($d > $today) { + $this->warn("跳过未来日期 {$d}"); + continue; + } + try { + $r = $sync->syncHourlyForDate($day, $skipIfAllZero); + $ok++; + $msg = $r['skipped'] + ? sprintf('%s skipped empty memory dataSource=%s venues=%d', $r['date'], $r['data_source'] ?? 'null', $r['venues']) + : sprintf('%s written=%d dataSource=%s venues=%d', $r['date'], $r['written'], $r['data_source'] ?? 'null', $r['venues']); + $this->info($msg); + Log::info('people-counting.sync-hourly', $r); + } catch (Throwable $e) { + $fail++; + $this->error("{$d} 失败: ".$e->getMessage()); + Log::warning('people-counting.sync-hourly.failed', [ + 'date' => $d, + 'error' => $e->getMessage(), + ]); + } + if ($i < count($days) - 1 && $sleepMs > 0) { + usleep($sleepMs * 1000); + } + } + + return $fail > 0 && $ok === 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3f9e13b..a4e0065 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -15,6 +15,16 @@ class Kernel extends ConsoleKernel $schedule->command('reservations:sync-no-show-blacklist')->dailyAt('01:00'); $schedule->command('ticket-grab:sync-carry')->dailyAt('00:10'); $schedule->command('activities:clear-hot-ended')->everyMinute(); + + $schedule->command('people-counting:sync-daily') + ->everyFiveMinutes() + ->withoutOverlapping(10); + $schedule->command('people-counting:sync-hourly --today') + ->hourlyAt(8) + ->withoutOverlapping(15); + $schedule->command('people-counting:sync-hourly') + ->dailyAt('00:25') + ->withoutOverlapping(30); } /** diff --git a/app/Models/PeopleCountingDaily.php b/app/Models/PeopleCountingDaily.php new file mode 100644 index 0000000..136eeb3 --- /dev/null +++ b/app/Models/PeopleCountingDaily.php @@ -0,0 +1,37 @@ + 'integer', + 'stat_date' => 'date', + 'enter' => 'integer', + 'exit' => 'integer', + 'passing' => 'integer', + 'update_count' => 'integer', + 'last_update' => 'datetime', + ]; + + public function venue(): BelongsTo + { + return $this->belongsTo(Venue::class, 'venue_id'); + } +} diff --git a/app/Models/PeopleCountingHourly.php b/app/Models/PeopleCountingHourly.php new file mode 100644 index 0000000..996a9c5 --- /dev/null +++ b/app/Models/PeopleCountingHourly.php @@ -0,0 +1,36 @@ + 'integer', + 'stat_date' => 'date', + 'hour' => 'integer', + 'enter' => 'integer', + 'exit' => 'integer', + 'passing' => 'integer', + ]; + + public function venue(): BelongsTo + { + return $this->belongsTo(Venue::class, 'venue_id'); + } +} diff --git a/app/Services/PeopleCounting/HikPeopleCountingClient.php b/app/Services/PeopleCounting/HikPeopleCountingClient.php new file mode 100644 index 0000000..95ec004 --- /dev/null +++ b/app/Services/PeopleCounting/HikPeopleCountingClient.php @@ -0,0 +1,89 @@ +peopleCountingUrl()); + } + + /** + * 单日快照:过去日期读归档 venues[],今天读内存。 + * + * @return array + */ + public function fetchDailySnapshot(Carbon $day): array + { + $d = $day->toDateString(); + $url = $this->peopleCountingUrl(); + $res = Http::timeout($this->timeout()) + ->acceptJson() + ->get($url, [ + 'date' => $d, + 'end_date' => $d, + ]); + + if (! $res->successful()) { + throw new RuntimeException("客流日接口 HTTP {$res->status()} {$url}: ".mb_substr($res->body(), 0, 400)); + } + + $json = $res->json(); + if (! is_array($json) || (int) ($json['code'] ?? 0) !== 200) { + throw new RuntimeException('客流日接口业务失败: '.mb_substr((string) $res->body(), 0, 500)); + } + + return $json; + } + + /** + * 全场馆 24 小时桶。 + * + * @return array + */ + public function fetchHourly(Carbon $day): array + { + $url = $this->apiBase().'/api/venues/hourly'; + $res = Http::timeout($this->timeout()) + ->acceptJson() + ->get($url, [ + 'date' => $day->toDateString(), + ]); + + if (! $res->successful()) { + throw new RuntimeException("客流小时接口 HTTP {$res->status()} {$url}: ".mb_substr($res->body(), 0, 400)); + } + + $json = $res->json(); + if (! is_array($json) || (int) ($json['code'] ?? 0) !== 200) { + throw new RuntimeException('客流小时接口业务失败: '.mb_substr((string) $res->body(), 0, 500)); + } + + return $json; + } + + private function timeout(): int + { + $n = (int) config('services.people_counting.timeout', 20); + + return max(5, min($n, 120)); + } +} diff --git a/app/Services/PeopleCounting/PeopleCountingSyncService.php b/app/Services/PeopleCounting/PeopleCountingSyncService.php new file mode 100644 index 0000000..3afb1fa --- /dev/null +++ b/app/Services/PeopleCounting/PeopleCountingSyncService.php @@ -0,0 +1,213 @@ +client->fetchDailySnapshot($day); + $venues = is_array($json['venues'] ?? null) ? $json['venues'] : []; + $statDate = $day->toDateString(); + $written = 0; + $skipped = 0; + + foreach ($venues as $row) { + if (! is_array($row)) { + continue; + } + $venueId = (int) ($row['venueId'] ?? 0); + if ($venueId <= 0) { + continue; + } + $enter = max(0, (int) ($row['enter'] ?? 0)); + $exit = max(0, (int) ($row['exit'] ?? 0)); + $passing = max(0, (int) ($row['passing'] ?? 0)); + $updateCount = max(0, (int) ($row['updateCount'] ?? 0)); + $lastUpdate = $this->parseLastUpdate($row['lastUpdate'] ?? null); + + if ($protectZeroOverwrite && $this->shouldSkipZeroOverwrite($venueId, $statDate, $enter, $exit, $passing, $updateCount)) { + $skipped++; + continue; + } + + PeopleCountingDaily::query()->updateOrCreate( + [ + 'venue_id' => $venueId, + 'stat_date' => $statDate, + ], + [ + 'venue_name' => $this->nullableName($row['venueName'] ?? null), + 'enter' => $enter, + 'exit' => $exit, + 'passing' => $passing, + 'update_count' => $updateCount, + 'last_update' => $lastUpdate, + ] + ); + $written++; + } + + return [ + 'date' => $statDate, + 'written' => $written, + 'skipped' => $skipped, + 'venues' => count($venues), + ]; + } + + /** + * @return array{date: string, written: int, skipped: bool, data_source: string|null, venues: int} + */ + public function syncHourlyForDate(Carbon $day, bool $skipIfAllZero = true): array + { + $json = $this->client->fetchHourly($day); + $venues = is_array($json['venues'] ?? null) ? $json['venues'] : []; + $statDate = $day->toDateString(); + $rootSource = is_string($json['dataSource'] ?? null) ? (string) $json['dataSource'] : null; + + $allZero = true; + foreach ($venues as $row) { + if (! is_array($row)) { + continue; + } + foreach (is_array($row['hourly'] ?? null) ? $row['hourly'] : [] as $bucket) { + if (! is_array($bucket)) { + continue; + } + if (((int) ($bucket['enter'] ?? 0)) !== 0 + || ((int) ($bucket['exit'] ?? 0)) !== 0 + || ((int) ($bucket['passing'] ?? 0)) !== 0) { + $allZero = false; + break 2; + } + } + } + + // 全 0 且仍是内存口径:昨日归档还没生成 / Java 小时回退未上线,写入会用假 0 盖掉已有小时 + if ($skipIfAllZero && $allZero && ($rootSource === null || $rootSource === 'memory')) { + return [ + 'date' => $statDate, + 'written' => 0, + 'skipped' => true, + 'data_source' => $rootSource, + 'venues' => count($venues), + ]; + } + + $written = 0; + foreach ($venues as $row) { + if (! is_array($row)) { + continue; + } + $venueId = (int) ($row['venueId'] ?? 0); + if ($venueId <= 0) { + continue; + } + $name = $this->nullableName($row['venueName'] ?? null); + $source = is_string($row['dataSource'] ?? null) ? (string) $row['dataSource'] : $rootSource; + $hours = is_array($row['hourly'] ?? null) ? $row['hourly'] : []; + $byHour = []; + foreach ($hours as $bucket) { + if (! is_array($bucket)) { + continue; + } + $h = (int) ($bucket['hour'] ?? -1); + if ($h < 0 || $h > 23) { + continue; + } + $byHour[$h] = [ + 'enter' => max(0, (int) ($bucket['enter'] ?? 0)), + 'exit' => max(0, (int) ($bucket['exit'] ?? 0)), + 'passing' => max(0, (int) ($bucket['passing'] ?? 0)), + ]; + } + for ($h = 0; $h <= 23; $h++) { + $vals = $byHour[$h] ?? ['enter' => 0, 'exit' => 0, 'passing' => 0]; + PeopleCountingHourly::query()->updateOrCreate( + [ + 'venue_id' => $venueId, + 'stat_date' => $statDate, + 'hour' => $h, + ], + [ + 'venue_name' => $name, + 'enter' => $vals['enter'], + 'exit' => $vals['exit'], + 'passing' => $vals['passing'], + 'data_source' => $source, + ] + ); + $written++; + } + } + + return [ + 'date' => $statDate, + 'written' => $written, + 'skipped' => false, + 'data_source' => $rootSource, + 'venues' => count($venues), + ]; + } + + private function shouldSkipZeroOverwrite( + int $venueId, + string $statDate, + int $enter, + int $exit, + int $passing, + int $updateCount + ): bool { + if ($enter > 0 || $exit > 0 || $passing > 0 || $updateCount > 0) { + return false; + } + $existing = PeopleCountingDaily::query() + ->where('venue_id', $venueId) + ->whereDate('stat_date', $statDate) + ->first(); + if ($existing === null) { + return false; + } + $prev = (int) $existing->enter + (int) $existing->exit + (int) $existing->passing; + + return $prev > 0; + } + + private function parseLastUpdate(mixed $raw): ?Carbon + { + if (! is_string($raw) || trim($raw) === '') { + return null; + } + try { + return Carbon::parse($raw); + } catch (\Throwable $e) { + Log::debug('people counting lastUpdate parse failed', ['raw' => $raw, 'e' => $e->getMessage()]); + + return null; + } + } + + private function nullableName(mixed $name): ?string + { + if (! is_string($name)) { + return null; + } + $t = trim($name); + + return $t === '' ? null : mb_substr($t, 0, 191); + } +} diff --git a/config/services.php b/config/services.php index 2d93196..4c15580 100644 --- a/config/services.php +++ b/config/services.php @@ -36,6 +36,8 @@ return [ */ 'people_counting' => [ 'url' => env('PEOPLE_COUNTING_URL', ''), + 'timeout' => (int) env('PEOPLE_COUNTING_TIMEOUT', 20), + 'sleep_ms' => (int) env('PEOPLE_COUNTING_SLEEP_MS', 200), ], ]; diff --git a/database/migrations/2026_09_15_140000_create_people_counting_stats_tables.php b/database/migrations/2026_09_15_140000_create_people_counting_stats_tables.php new file mode 100644 index 0000000..cc4a1a3 --- /dev/null +++ b/database/migrations/2026_09_15_140000_create_people_counting_stats_tables.php @@ -0,0 +1,49 @@ +id(); + $table->unsignedBigInteger('venue_id')->comment('与客流 venueId / venues.id 对齐'); + $table->string('venue_name', 191)->nullable(); + $table->date('stat_date'); + $table->unsignedInteger('enter')->default(0); + $table->unsignedInteger('exit')->default(0); + $table->unsignedInteger('passing')->default(0); + $table->unsignedInteger('update_count')->default(0); + $table->dateTime('last_update')->nullable(); + $table->timestamps(); + + $table->unique(['venue_id', 'stat_date'], 'pc_daily_venue_date_unique'); + $table->index('stat_date'); + }); + + Schema::create('people_counting_hourly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('venue_id')->comment('与客流 venueId / venues.id 对齐'); + $table->string('venue_name', 191)->nullable(); + $table->date('stat_date'); + $table->unsignedTinyInteger('hour')->comment('0–23'); + $table->unsignedInteger('enter')->default(0); + $table->unsignedInteger('exit')->default(0); + $table->unsignedInteger('passing')->default(0); + $table->string('data_source', 16)->nullable()->comment('memory / archive / mixed'); + $table->timestamps(); + + $table->unique(['venue_id', 'stat_date', 'hour'], 'pc_hourly_venue_date_hour_unique'); + $table->index(['stat_date', 'hour']); + }); + } + + public function down(): void + { + Schema::dropIfExists('people_counting_hourly'); + Schema::dropIfExists('people_counting_daily'); + } +};