SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트). 전체 코드리뷰에서 도출된 보안 하드닝 10건 반영: - 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시) - 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php) - 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점) - CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php) - 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화 _backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
378 lines
19 KiB
PHP
378 lines
19 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// monthly_report.php - 월간 운영 보고서 (SHT30 온습도 전용)
|
|
//
|
|
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구,
|
|
// SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
|
|
// 데이터 출처: sms_log(message 태그 prefix 기준), sensor_metric(온습도 측정 이력).
|
|
// =============================================================================
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/admin_security.php';
|
|
|
|
ini_set('session.cookie_httponly', 1);
|
|
ini_set('session.cookie_secure', 1);
|
|
ini_set('session.cookie_samesite', 'Strict');
|
|
ini_set('session.use_strict_mode', 1);
|
|
session_start();
|
|
|
|
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
|
header('Location: login.php');
|
|
exit;
|
|
}
|
|
|
|
function month_range(string $month): array {
|
|
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
|
$month = date('Y-m');
|
|
}
|
|
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
|
|
if (!$start) {
|
|
$start = new DateTime('first day of this month 00:00:00');
|
|
}
|
|
$end = clone $start;
|
|
$end->modify('first day of next month');
|
|
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
|
|
}
|
|
|
|
// SMS 메시지 태그(prefix) → 집계 키 매핑. sms_send.php 가 발송 시 붙이는 태그와 일치.
|
|
function report_sms_tags(): array {
|
|
return [
|
|
'high_temp' => '[고온경보]',
|
|
'low_temp' => '[저온경보]',
|
|
'high_humidity' => '[고습경보]',
|
|
'low_humidity' => '[저습경보]',
|
|
'recovery' => '[정상복귀]',
|
|
'device_offline' => '[장비 오프라인]',
|
|
'device_online' => '[장비 복구]',
|
|
];
|
|
}
|
|
|
|
function round_or_null($value, int $precision = 1): ?float {
|
|
return $value !== null ? round((float)$value, $precision) : null;
|
|
}
|
|
|
|
function fmt_num(?float $value, string $unit = ''): string {
|
|
if ($value === null) {
|
|
return '-';
|
|
}
|
|
return htmlspecialchars(rtrim(rtrim(number_format($value, 1), '0'), '.')) . $unit;
|
|
}
|
|
|
|
// CSV 수식 주입(formula injection) 방지: 자유 텍스트 셀이 =,+,-,@,탭,CR 로 시작하면
|
|
// 선행 작은따옴표로 무력화한다(Excel/Sheets 가 수식으로 실행하지 않도록).
|
|
// 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록.
|
|
function csv_cell($value): string {
|
|
$s = (string)$value;
|
|
if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
|
|
return "'" . $s;
|
|
}
|
|
return $s;
|
|
}
|
|
|
|
$db = get_db();
|
|
[$month, $start_at, $end_at] = month_range($_GET['month'] ?? date('Y-m'));
|
|
$report_generated_at = date('Y-m-d H:i:s');
|
|
$report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
|
|
|
|
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
|
|
$stmt = $db->prepare("
|
|
SELECT
|
|
SUM(message LIKE '[고온경보]%') AS high_temp,
|
|
SUM(message LIKE '[저온경보]%') AS low_temp,
|
|
SUM(message LIKE '[고습경보]%') AS high_humidity,
|
|
SUM(message LIKE '[저습경보]%') AS low_humidity,
|
|
SUM(message LIKE '[정상복귀]%') AS recovery,
|
|
SUM(message LIKE '[장비 오프라인]%') AS device_offline,
|
|
SUM(message LIKE '[장비 복구]%') AS device_online,
|
|
SUM(status = 'fail') AS sms_fail
|
|
FROM sms_log
|
|
WHERE sent_at >= :start_at AND sent_at < :end_at
|
|
");
|
|
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
|
$row = $stmt->fetch() ?: [];
|
|
|
|
$summary = [
|
|
'high_temp' => (int)($row['high_temp'] ?? 0),
|
|
'low_temp' => (int)($row['low_temp'] ?? 0),
|
|
'high_humidity' => (int)($row['high_humidity'] ?? 0),
|
|
'low_humidity' => (int)($row['low_humidity'] ?? 0),
|
|
'recovery' => (int)($row['recovery'] ?? 0),
|
|
'device_offline' => (int)($row['device_offline'] ?? 0),
|
|
'device_online' => (int)($row['device_online'] ?? 0),
|
|
'sms_fail' => (int)($row['sms_fail'] ?? 0),
|
|
'metrics' => 0,
|
|
'avg_temp' => null,
|
|
'min_temp' => null,
|
|
'max_temp' => null,
|
|
'avg_humidity' => null,
|
|
'min_humidity' => null,
|
|
'max_humidity' => null,
|
|
];
|
|
$summary['threshold_alerts'] = $summary['high_temp'] + $summary['low_temp']
|
|
+ $summary['high_humidity'] + $summary['low_humidity'];
|
|
|
|
// ── 월간 요약: 측정 건수 + 평균/최저/최고 온도·습도 ───────────────────────────
|
|
$stmt = $db->prepare("
|
|
SELECT
|
|
COUNT(*) AS metrics,
|
|
AVG(temperature_c) AS avg_temp,
|
|
MIN(temperature_c) AS min_temp,
|
|
MAX(temperature_c) AS max_temp,
|
|
AVG(humidity_percent) AS avg_humidity,
|
|
MIN(humidity_percent) AS min_humidity,
|
|
MAX(humidity_percent) AS max_humidity
|
|
FROM sensor_metric
|
|
WHERE measured_at >= :start_at AND measured_at < :end_at
|
|
");
|
|
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
|
$row = $stmt->fetch() ?: [];
|
|
$summary['metrics'] = (int)($row['metrics'] ?? 0);
|
|
$summary['avg_temp'] = round_or_null($row['avg_temp'] ?? null);
|
|
$summary['min_temp'] = round_or_null($row['min_temp'] ?? null);
|
|
$summary['max_temp'] = round_or_null($row['max_temp'] ?? null);
|
|
$summary['avg_humidity'] = round_or_null($row['avg_humidity'] ?? null);
|
|
$summary['min_humidity'] = round_or_null($row['min_humidity'] ?? null);
|
|
$summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
|
|
|
|
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
|
|
$stmt = $db->prepare("
|
|
SELECT sensor_id, sensor_name,
|
|
SUM(message LIKE '[고온경보]%') AS high_temp,
|
|
SUM(message LIKE '[저온경보]%') AS low_temp,
|
|
SUM(message LIKE '[고습경보]%') AS high_humidity,
|
|
SUM(message LIKE '[저습경보]%') AS low_humidity,
|
|
SUM(message LIKE '[정상복귀]%') AS recovery,
|
|
SUM(message LIKE '[장비 오프라인]%') AS device_offline,
|
|
SUM(message LIKE '[장비 복구]%') AS device_online,
|
|
SUM(status = 'fail') AS sms_fail
|
|
FROM sms_log
|
|
WHERE sent_at >= :start_at AND sent_at < :end_at
|
|
GROUP BY sensor_id, sensor_name
|
|
");
|
|
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
|
$sms_by_sensor = [];
|
|
foreach ($stmt->fetchAll() as $r) {
|
|
$sms_by_sensor[(int)$r['sensor_id']] = $r;
|
|
}
|
|
|
|
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
|
|
$stmt = $db->prepare("
|
|
SELECT sensor_id, sensor_name, device_location,
|
|
COUNT(*) AS metrics,
|
|
AVG(temperature_c) AS avg_temp,
|
|
MIN(temperature_c) AS min_temp,
|
|
MAX(temperature_c) AS max_temp,
|
|
AVG(humidity_percent) AS avg_humidity,
|
|
MIN(humidity_percent) AS min_humidity,
|
|
MAX(humidity_percent) AS max_humidity,
|
|
MAX(measured_at) AS last_measured_at
|
|
FROM sensor_metric
|
|
WHERE measured_at >= :start_at AND measured_at < :end_at
|
|
GROUP BY sensor_id, sensor_name, device_location
|
|
");
|
|
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
|
$metric_by_sensor = [];
|
|
foreach ($stmt->fetchAll() as $r) {
|
|
$metric_by_sensor[(int)$r['sensor_id']] = $r;
|
|
}
|
|
|
|
// ── 센서별 행 병합(측정 + SMS) ───────────────────────────────────────────────
|
|
$sensor_rows = [];
|
|
foreach (array_unique(array_merge(array_keys($metric_by_sensor), array_keys($sms_by_sensor))) as $sid) {
|
|
$m = $metric_by_sensor[$sid] ?? [];
|
|
$s = $sms_by_sensor[$sid] ?? [];
|
|
$sensor_rows[] = [
|
|
'sensor_id' => $sid,
|
|
'sensor_name' => $m['sensor_name'] ?? $s['sensor_name'] ?? ('센서' . $sid),
|
|
'device_location' => $m['device_location'] ?? '',
|
|
'high_temp' => (int)($s['high_temp'] ?? 0),
|
|
'low_temp' => (int)($s['low_temp'] ?? 0),
|
|
'high_humidity' => (int)($s['high_humidity'] ?? 0),
|
|
'low_humidity' => (int)($s['low_humidity'] ?? 0),
|
|
'threshold_alerts' => (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
|
|
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 0),
|
|
'recovery' => (int)($s['recovery'] ?? 0),
|
|
'device_offline' => (int)($s['device_offline'] ?? 0),
|
|
'device_online' => (int)($s['device_online'] ?? 0),
|
|
'sms_fail' => (int)($s['sms_fail'] ?? 0),
|
|
'metrics' => (int)($m['metrics'] ?? 0),
|
|
'avg_temp' => round_or_null($m['avg_temp'] ?? null),
|
|
'min_temp' => round_or_null($m['min_temp'] ?? null),
|
|
'max_temp' => round_or_null($m['max_temp'] ?? null),
|
|
'avg_humidity' => round_or_null($m['avg_humidity'] ?? null),
|
|
'min_humidity' => round_or_null($m['min_humidity'] ?? null),
|
|
'max_humidity' => round_or_null($m['max_humidity'] ?? null),
|
|
'last_measured_at' => $m['last_measured_at'] ?? '',
|
|
];
|
|
}
|
|
usort($sensor_rows, function ($a, $b) {
|
|
return [$b['threshold_alerts'], $a['sensor_id']] <=> [$a['threshold_alerts'], $b['sensor_id']];
|
|
});
|
|
|
|
// ── CSV 다운로드 ─────────────────────────────────────────────────────────────
|
|
if (($_GET['format'] ?? '') === 'csv') {
|
|
header('Content-Type: text/csv; charset=utf-8');
|
|
header('Content-Disposition: attachment; filename="sht30-report-' . $month . '.csv"');
|
|
$out = fopen('php://output', 'w');
|
|
fputcsv($out, ['month', $month]);
|
|
fputcsv($out, ['generated_at', $report_generated_at]);
|
|
fputcsv($out, ['scope', $report_scope]);
|
|
fputcsv($out, ['threshold_alerts', $summary['threshold_alerts']]);
|
|
fputcsv($out, ['high_temp_alerts', $summary['high_temp']]);
|
|
fputcsv($out, ['low_temp_alerts', $summary['low_temp']]);
|
|
fputcsv($out, ['high_humidity_alerts', $summary['high_humidity']]);
|
|
fputcsv($out, ['low_humidity_alerts', $summary['low_humidity']]);
|
|
fputcsv($out, ['recovery_sms', $summary['recovery']]);
|
|
fputcsv($out, ['device_offline_sms', $summary['device_offline']]);
|
|
fputcsv($out, ['device_online_sms', $summary['device_online']]);
|
|
fputcsv($out, ['sms_fail', $summary['sms_fail']]);
|
|
fputcsv($out, ['metrics', $summary['metrics']]);
|
|
fputcsv($out, ['avg_temp_c', $summary['avg_temp'] ?? '']);
|
|
fputcsv($out, ['min_temp_c', $summary['min_temp'] ?? '']);
|
|
fputcsv($out, ['max_temp_c', $summary['max_temp'] ?? '']);
|
|
fputcsv($out, ['avg_humidity_percent', $summary['avg_humidity'] ?? '']);
|
|
fputcsv($out, ['min_humidity_percent', $summary['min_humidity'] ?? '']);
|
|
fputcsv($out, ['max_humidity_percent', $summary['max_humidity'] ?? '']);
|
|
fputcsv($out, []);
|
|
fputcsv($out, [
|
|
'sensor_id', 'sensor_name', 'location',
|
|
'high_temp', 'low_temp', 'high_humidity', 'low_humidity', 'threshold_alerts',
|
|
'recovery', 'device_offline', 'device_online', 'sms_fail',
|
|
'metrics', 'avg_temp_c', 'min_temp_c', 'max_temp_c',
|
|
'avg_humidity_percent', 'min_humidity_percent', 'max_humidity_percent',
|
|
'last_measured_at',
|
|
]);
|
|
foreach ($sensor_rows as $s) {
|
|
fputcsv($out, [
|
|
$s['sensor_id'], csv_cell($s['sensor_name']), csv_cell($s['device_location']),
|
|
$s['high_temp'], $s['low_temp'], $s['high_humidity'], $s['low_humidity'], $s['threshold_alerts'],
|
|
$s['recovery'], $s['device_offline'], $s['device_online'], $s['sms_fail'],
|
|
$s['metrics'],
|
|
$s['avg_temp'] ?? '', $s['min_temp'] ?? '', $s['max_temp'] ?? '',
|
|
$s['avg_humidity'] ?? '', $s['min_humidity'] ?? '', $s['max_humidity'] ?? '',
|
|
$s['last_measured_at'],
|
|
]);
|
|
}
|
|
fclose($out);
|
|
exit;
|
|
}
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
|
|
header { background: #1a56db; color: #fff; padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
|
header h1 { margin:0; font-size:1.15rem; }
|
|
header a { color:#fff; text-decoration:none; background:rgba(255,255,255,.15); padding:7px 12px; border-radius:6px; font-size:.85rem; }
|
|
.container { max-width: 1060px; margin: 24px auto; padding: 0 16px; }
|
|
.toolbar { background:#fff; border-radius:8px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.1); display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:18px; }
|
|
input, button, .btn { border:1px solid #cbd5e1; border-radius:6px; padding:8px 10px; font-size:.9rem; }
|
|
button, .btn { background:#1a56db; color:#fff; border-color:#1a56db; text-decoration:none; cursor:pointer; }
|
|
.toolbar label { display:flex; align-items:center; gap:6px; }
|
|
.grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px; margin-bottom:18px; }
|
|
.card { background:#fff; border-radius:8px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
|
.card span { color:#64748b; font-size:.78rem; display:block; margin-bottom:5px; }
|
|
.card strong { font-size:1.35rem; }
|
|
table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.1); margin-bottom:22px; }
|
|
th, td { padding:9px 12px; border-bottom:1px solid #e2e8f0; text-align:left; font-size:.84rem; }
|
|
th { background:#f8fafc; color:#64748b; }
|
|
h2 { font-size:1rem; color:#475569; margin:22px 0 10px; }
|
|
.table-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; margin-bottom:22px; border-radius:8px; }
|
|
.table-wrap table { margin-bottom:0; min-width:860px; }
|
|
@media print {
|
|
body { background:#fff; }
|
|
header, .toolbar { display:none; }
|
|
.container { margin:0; max-width:none; }
|
|
.card, table { box-shadow:none; }
|
|
}
|
|
@media (max-width:760px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } header { flex-direction:column; align-items:flex-start; } }
|
|
@media (max-width:520px) {
|
|
.container { margin:16px auto; padding:0 10px; }
|
|
header { padding:14px 16px; }
|
|
header h1 { font-size:1rem; }
|
|
.toolbar { display:grid; grid-template-columns:1fr; }
|
|
.toolbar label, .toolbar input, .toolbar button, .toolbar .btn { width:100%; }
|
|
.toolbar label { display:grid; gap:6px; }
|
|
.grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap:8px; }
|
|
.card { padding:12px; }
|
|
.card strong { font-size:1.05rem; }
|
|
}
|
|
@media (max-width:360px) { .grid { grid-template-columns:1fr; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1>
|
|
<div>
|
|
<a href="dashboard.php">대시보드</a>
|
|
</div>
|
|
</header>
|
|
<main class="container">
|
|
<form class="toolbar" method="GET">
|
|
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
|
|
<button type="submit">조회</button>
|
|
<a class="btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
|
|
<button type="button" onclick="window.print()">인쇄/PDF</button>
|
|
</form>
|
|
|
|
<section class="card" style="margin-bottom:18px">
|
|
<span>보고서 생성</span>
|
|
<strong style="font-size:1rem"><?= htmlspecialchars($report_generated_at) ?></strong>
|
|
<p style="color:#64748b;font-size:.84rem;margin:8px 0 0">범위: <?= htmlspecialchars($report_scope) ?></p>
|
|
</section>
|
|
|
|
<section class="grid">
|
|
<div class="card"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
|
|
<div class="card"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
|
|
<div class="card"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
|
|
<div class="card"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
|
|
<div class="card"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
|
|
<div class="card"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
|
|
<div class="card"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
|
|
<div class="card"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
|
|
<div class="card"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
|
|
<div class="card"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
|
|
</section>
|
|
|
|
<h2>센서별 요약</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr>
|
|
<th>센서</th><th>위치</th>
|
|
<th>임계 경보</th><th>고온/저온</th><th>고습/저습</th>
|
|
<th>정상복귀</th><th>오프라인/복구</th><th>SMS 실패</th>
|
|
<th>측정</th><th>평균 온도/습도</th><th>온도 최저/최고</th><th>습도 최저/최고</th>
|
|
<th>최근 측정</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
<?php foreach ($sensor_rows as $s): ?>
|
|
<tr>
|
|
<td><?= htmlspecialchars($s['sensor_name']) ?></td>
|
|
<td><?= htmlspecialchars($s['device_location']) ?></td>
|
|
<td><?= $s['threshold_alerts'] ?></td>
|
|
<td><?= $s['high_temp'] ?>/<?= $s['low_temp'] ?></td>
|
|
<td><?= $s['high_humidity'] ?>/<?= $s['low_humidity'] ?></td>
|
|
<td><?= $s['recovery'] ?></td>
|
|
<td><?= $s['device_offline'] ?>/<?= $s['device_online'] ?></td>
|
|
<td><?= $s['sms_fail'] ?></td>
|
|
<td><?= $s['metrics'] ?></td>
|
|
<td><?= fmt_num($s['avg_temp'], '℃') ?> / <?= fmt_num($s['avg_humidity'], '%') ?></td>
|
|
<td><?= fmt_num($s['min_temp'], '℃') ?> / <?= fmt_num($s['max_temp'], '℃') ?></td>
|
|
<td><?= fmt_num($s['min_humidity'], '%') ?> / <?= fmt_num($s['max_humidity'], '%') ?></td>
|
|
<td><?= htmlspecialchars($s['last_measured_at'] ?: '-') ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
<?php if (empty($sensor_rows)): ?>
|
|
<tr><td colspan="13" style="text-align:center;color:#94a3b8">해당 월 측정·경보 내역이 없습니다.</td></tr>
|
|
<?php endif; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</main>
|
|
</body>
|
|
</html>
|