39853-vm/db/visit_counter.php
2026-05-02 09:04:12 +00:00

279 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
const VISIT_COUNTER_LIVE_WINDOW_MINUTES = 5;
const VISIT_COUNTER_RETENTION_MONTHS = 13;
const VISIT_COUNTER_TOTAL_KEY = 'lifetime_total';
function visit_counter_timezone(): DateTimeZone
{
static $timezone = null;
if ($timezone instanceof DateTimeZone) {
return $timezone;
}
$timezone = new DateTimeZone('Europe/Paris');
return $timezone;
}
function visit_counter_now(): DateTimeImmutable
{
return new DateTimeImmutable('now', visit_counter_timezone());
}
function visit_counter_normalize_token(mixed $token): ?string
{
if (!is_string($token)) {
return null;
}
$normalized = strtolower(trim($token));
if ($normalized === '') {
return null;
}
return preg_match('/\A[a-f0-9]{48}\z/', $normalized) === 1 ? $normalized : null;
}
function visit_counter_generate_token(): string
{
return bin2hex(random_bytes(24));
}
function visit_counter_ensure_schema(): void
{
static $initialized = false;
if ($initialized) {
return;
}
$pdo = db();
$pdo->exec(
'CREATE TABLE IF NOT EXISTS visit_counter_sessions ('
. ' visit_token CHAR(48) NOT NULL,'
. ' first_seen_at DATETIME NOT NULL,'
. ' last_seen_at DATETIME NOT NULL,'
. ' created_at DATETIME NOT NULL,'
. ' updated_at DATETIME NOT NULL,'
. ' PRIMARY KEY (visit_token),'
. ' KEY idx_last_seen_at (last_seen_at)'
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS visit_counter_daily ('
. ' visit_token CHAR(48) NOT NULL,'
. ' visit_date DATE NOT NULL,'
. ' created_at DATETIME NOT NULL,'
. ' PRIMARY KEY (visit_token, visit_date),'
. ' KEY idx_visit_date (visit_date)'
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS visit_counter_meta ('
. ' meta_key VARCHAR(64) NOT NULL,'
. ' meta_value BIGINT UNSIGNED NOT NULL DEFAULT 0,'
. ' updated_at DATETIME NOT NULL,'
. ' PRIMARY KEY (meta_key)'
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$initialized = true;
}
function visit_counter_prune_stale_data(?DateTimeImmutable $now = null): void
{
static $pruned = false;
if ($pruned) {
return;
}
visit_counter_ensure_schema();
$pdo = db();
$now = $now ?? visit_counter_now();
$sessionCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d H:i:s');
$dailyCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d');
$deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_date < :cutoff');
$deleteDaily->bindValue(':cutoff', $dailyCutoff);
$deleteDaily->execute();
$deleteSessions = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE last_seen_at < :cutoff');
$deleteSessions->bindValue(':cutoff', $sessionCutoff);
$deleteSessions->execute();
$pruned = true;
}
function visit_counter_snapshot(?DateTimeImmutable $now = null): array
{
visit_counter_ensure_schema();
visit_counter_prune_stale_data($now);
$pdo = db();
$now = $now ?? visit_counter_now();
$threshold = $now->modify('-' . VISIT_COUNTER_LIVE_WINDOW_MINUTES . ' minutes')->format('Y-m-d H:i:s');
$today = $now->format('Y-m-d');
$liveStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_sessions WHERE last_seen_at >= :threshold');
$liveStmt->bindValue(':threshold', $threshold);
$liveStmt->execute();
$live = (int) $liveStmt->fetchColumn();
$dailyStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_daily WHERE visit_date = :visit_date');
$dailyStmt->bindValue(':visit_date', $today);
$dailyStmt->execute();
$daily = (int) $dailyStmt->fetchColumn();
$totalStmt = $pdo->prepare('SELECT meta_value FROM visit_counter_meta WHERE meta_key = :meta_key');
$totalStmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY);
$totalStmt->execute();
$totalValue = $totalStmt->fetchColumn();
$total = $totalValue === false ? 0 : (int) $totalValue;
if ($total === 0) {
$fallbackStmt = $pdo->query('SELECT COUNT(*) FROM visit_counter_sessions');
$total = (int) $fallbackStmt->fetchColumn();
}
return [
'live' => $live,
'daily' => $daily,
'total' => $total,
'live_window_minutes' => VISIT_COUNTER_LIVE_WINDOW_MINUTES,
'updated_at' => $now->format(DATE_ATOM),
'updated_label' => $now->format('H:i:s'),
];
}
function visit_counter_increment_total(string $timestamp): void
{
$stmt = db()->prepare(
'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) '
. 'ON DUPLICATE KEY UPDATE meta_value = meta_value + 1, updated_at = :updated_at_refresh'
);
$stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY);
$stmt->bindValue(':meta_value', 1, PDO::PARAM_INT);
$stmt->bindValue(':updated_at', $timestamp);
$stmt->bindValue(':updated_at_refresh', $timestamp);
$stmt->execute();
}
function visit_counter_decrement_total(string $timestamp): void
{
$stmt = db()->prepare(
'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) '
. 'ON DUPLICATE KEY UPDATE meta_value = CASE WHEN meta_value > 0 THEN meta_value - 1 ELSE 0 END, updated_at = :updated_at_refresh'
);
$stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY);
$stmt->bindValue(':meta_value', 0, PDO::PARAM_INT);
$stmt->bindValue(':updated_at', $timestamp);
$stmt->bindValue(':updated_at_refresh', $timestamp);
$stmt->execute();
}
function visit_counter_track(?string $token = null): array
{
visit_counter_ensure_schema();
$pdo = db();
$now = visit_counter_now();
visit_counter_prune_stale_data($now);
$visitToken = visit_counter_normalize_token($token) ?? visit_counter_generate_token();
$timestamp = $now->format('Y-m-d H:i:s');
$visitDate = $now->format('Y-m-d');
$pdo->beginTransaction();
try {
$insertSession = $pdo->prepare(
'INSERT IGNORE INTO visit_counter_sessions (visit_token, first_seen_at, last_seen_at, created_at, updated_at) '
. 'VALUES (:visit_token, :first_seen_at, :last_seen_at, :created_at, :updated_at)'
);
$insertSession->bindValue(':visit_token', $visitToken);
$insertSession->bindValue(':first_seen_at', $timestamp);
$insertSession->bindValue(':last_seen_at', $timestamp);
$insertSession->bindValue(':created_at', $timestamp);
$insertSession->bindValue(':updated_at', $timestamp);
$insertSession->execute();
$isNewVisitor = $insertSession->rowCount() === 1;
if (!$isNewVisitor) {
$updateSession = $pdo->prepare(
'UPDATE visit_counter_sessions SET last_seen_at = :last_seen_at, updated_at = :updated_at WHERE visit_token = :visit_token'
);
$updateSession->bindValue(':last_seen_at', $timestamp);
$updateSession->bindValue(':updated_at', $timestamp);
$updateSession->bindValue(':visit_token', $visitToken);
$updateSession->execute();
}
$insertDaily = $pdo->prepare(
'INSERT IGNORE INTO visit_counter_daily (visit_token, visit_date, created_at) VALUES (:visit_token, :visit_date, :created_at)'
);
$insertDaily->bindValue(':visit_token', $visitToken);
$insertDaily->bindValue(':visit_date', $visitDate);
$insertDaily->bindValue(':created_at', $timestamp);
$insertDaily->execute();
if ($isNewVisitor) {
visit_counter_increment_total($timestamp);
}
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
return [
'token' => $visitToken,
'counts' => visit_counter_snapshot($now),
];
}
function visit_counter_forget(?string $token = null): array
{
visit_counter_ensure_schema();
$pdo = db();
$now = visit_counter_now();
visit_counter_prune_stale_data($now);
$visitToken = visit_counter_normalize_token($token);
if ($visitToken === null) {
return visit_counter_snapshot($now);
}
$timestamp = $now->format('Y-m-d H:i:s');
$pdo->beginTransaction();
try {
$deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_token = :visit_token');
$deleteDaily->bindValue(':visit_token', $visitToken);
$deleteDaily->execute();
$deleteSession = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE visit_token = :visit_token');
$deleteSession->bindValue(':visit_token', $visitToken);
$deleteSession->execute();
if ($deleteSession->rowCount() > 0) {
visit_counter_decrement_total($timestamp);
}
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
return visit_counter_snapshot($now);
}