数据写入

master
lion 1 day ago
parent aae326753b
commit ba96edc90f

@ -69,6 +69,9 @@ VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# 工作台 / H5 客流在馆实时人数enterexit与 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
# 天地图浏览器 KeyH5/后台底图 JS APIVITE_TIANDITU_TK
# TIANDITU_TK=

@ -0,0 +1,102 @@
<?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 PeopleCountingBackfillCommand extends Command
{
use PeopleCountingDateRange;
protected $signature = 'people-counting:backfill
{--from= : 起始 Y-m-d默认 2026-08-03}
{--to= : 结束 Y-m-d默认昨天}
{--skip-daily : 不回填日合计}
{--skip-hourly : 不回填小时}
{--include-empty : 小时全 0 且 dataSource=memory 也写入}';
protected $description = '按日回填客流日合计与小时桶(天与天之间 sleep覆盖 upsert';
public function handle(PeopleCountingSyncService $sync): int
{
$fromRaw = trim((string) $this->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;
}
}

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
trait PeopleCountingDateRange
{
/**
* @return list<Carbon>
*/
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));
}
}

@ -0,0 +1,79 @@
<?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 PeopleCountingSyncDailyCommand extends Command
{
use PeopleCountingDateRange;
protected $signature = 'people-counting:sync-daily
{--date= : 单日 Y-m-d默认今天}
{--from= : 区间起始 Y-m-d}
{--to= : 区间结束 Y-m-d缺省则到今天}
{--force : 关闭当天「已有非 0 被全 0 覆盖」保护}';
protected $description = '从客流 HTTP 拉取场馆日合计,按 (venue_id, 日期) 覆盖写入 people_counting_daily';
public function handle(PeopleCountingSyncService $sync): int
{
try {
$days = $this->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;
}
}

@ -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;
}
}

@ -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);
}
/**

@ -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,89 @@
<?php
namespace App\Services\PeopleCounting;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* 拉取海康客流 HTTP 接口PEOPLE_COUNTING_URL
*/
class HikPeopleCountingClient
{
public function peopleCountingUrl(): string
{
$url = trim((string) config('services.people_counting.url', ''));
if ($url === '') {
throw new RuntimeException('未配置 PEOPLE_COUNTING_URLservices.people_counting.url');
}
return rtrim($url, '/');
}
public function apiBase(): string
{
return (string) preg_replace('#/api/people-counting$#i', '', $this->peopleCountingUrl());
}
/**
* 单日快照:过去日期读归档 venues[],今天读内存。
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
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));
}
}

@ -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);
}
}

@ -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),
],
];

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('people_counting_daily', function (Blueprint $table) {
$table->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('023');
$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');
}
};
Loading…
Cancel
Save