parent
aae326753b
commit
ba96edc90f
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PeopleCounting\PeopleCountingSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
class PeopleCountingSyncHourlyCommand extends Command
|
||||
{
|
||||
use PeopleCountingDateRange;
|
||||
|
||||
protected $signature = 'people-counting:sync-hourly
|
||||
{--date= : 单日 Y-m-d}
|
||||
{--from= : 区间起始 Y-m-d}
|
||||
{--to= : 区间结束 Y-m-d}
|
||||
{--today : 拉今天(整点覆盖用);默认昨天}
|
||||
{--include-empty : 即使全 0 且 dataSource=memory 也写入}';
|
||||
|
||||
protected $description = '从客流 HTTP 拉取场馆 24 小时桶,按 (venue_id, 日期, hour) 覆盖写入 people_counting_hourly';
|
||||
|
||||
public function handle(PeopleCountingSyncService $sync): int
|
||||
{
|
||||
$default = (bool) $this->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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PeopleCountingDaily extends Model
|
||||
{
|
||||
protected $table = 'people_counting_daily';
|
||||
|
||||
protected $fillable = [
|
||||
'venue_id',
|
||||
'venue_name',
|
||||
'stat_date',
|
||||
'enter',
|
||||
'exit',
|
||||
'passing',
|
||||
'update_count',
|
||||
'last_update',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'venue_id' => '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');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PeopleCountingHourly extends Model
|
||||
{
|
||||
protected $table = 'people_counting_hourly';
|
||||
|
||||
protected $fillable = [
|
||||
'venue_id',
|
||||
'venue_name',
|
||||
'stat_date',
|
||||
'hour',
|
||||
'enter',
|
||||
'exit',
|
||||
'passing',
|
||||
'data_source',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'venue_id' => 'integer',
|
||||
'stat_date' => 'date',
|
||||
'hour' => 'integer',
|
||||
'enter' => 'integer',
|
||||
'exit' => 'integer',
|
||||
'passing' => 'integer',
|
||||
];
|
||||
|
||||
public function venue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Venue::class, 'venue_id');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\PeopleCounting;
|
||||
|
||||
use App\Models\PeopleCountingDaily;
|
||||
use App\Models\PeopleCountingHourly;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PeopleCountingSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HikPeopleCountingClient $client,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{date: string, written: int, skipped: int, venues: int}
|
||||
*/
|
||||
public function syncDailyForDate(Carbon $day, bool $protectZeroOverwrite = true): array
|
||||
{
|
||||
$json = $this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue