prepare(" SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table "); $stmt->execute([':table' => $table]); return (int)$stmt->fetchColumn() > 0; } function ops_column_exists(PDO $db, string $table, string $column): bool { $stmt = $db->prepare(" SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table AND COLUMN_NAME = :column "); $stmt->execute([':table' => $table, ':column' => $column]); return (int)$stmt->fetchColumn() > 0; } function ops_check(string $category, string $label, bool $ok, string $detail, string $fix = '', string $severity = 'warn', string $evidence = ''): array { return [ 'category' => $category, 'label' => $label, 'ok' => $ok, 'detail' => $detail, 'fix' => $fix, 'severity' => $severity, 'evidence' => $evidence, ]; } function collect_ops_checks(): array { $checks = []; try { $db = get_db(); $checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면'); $required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log']; foreach ($required_tables as $table) { $exists = ops_table_exists($db, $table); $checks[] = ops_check( 'DB', "테이블 {$table}", $exists, $exists ? '확인됨' : '누락됨', 'sql/schema_sht30.sql 실행', 'critical', 'DB 테이블 목록 또는 설치 점검 화면' ); } // 최근 온습도 측정 수신 여부(센서 동작/수집 확인) if (ops_table_exists($db, 'sensor_metric')) { $recent_metric_stmt = $db->query(" SELECT COUNT(*) FROM sensor_metric WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) "); $recent_metric = (int)$recent_metric_stmt->fetchColumn(); $checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신'); } if (ops_table_exists($db, 'sms_log')) { $recent_sms_fail_stmt = $db->query(" SELECT COUNT(*) FROM sms_log WHERE status = 'fail' AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) "); $recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn(); $checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수'); } } catch (Throwable $e) { $checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면'); } $default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000'; $checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 /etc/sht30-sensor.env(또는 펌웨어 secrets.h)의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인'); $checks[] = ops_check('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "new-password" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인'); $checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표'); $checks[] = ops_check('관리자 인증', '감사로그', is_file(admin_audit_log_path()) || is_writable(__DIR__ . '/var'), admin_audit_log_path(), '로그인 성공/실패, 로그아웃, 관리자 활동 기록 확인', 'warn', 'php/var/admin_audit.log'); $checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표'); $thr_ok = (METRIC_TEMP_LOW_C < METRIC_TEMP_HIGH_C) && (METRIC_RH_LOW < METRIC_RH_HIGH); $checks[] = ops_check('설정', '온습도 임계치', $thr_ok, sprintf('온도 %s~%s℃ / 습도 %s~%s%%', METRIC_TEMP_LOW_C, METRIC_TEMP_HIGH_C, METRIC_RH_LOW, METRIC_RH_HIGH), 'config.local.php의 METRIC_* 임계치를 운영값으로 설정(저온<고온, 저습<고습)', 'warn', '임계치 설정 확인'); $checks[] = ops_check('파일', '상태 파일 폴더', is_dir(__DIR__ . '/var') && is_writable(__DIR__ . '/var'), __DIR__ . '/var', 'php/var 생성 및 쓰기 권한 확인', 'warn', '폴더 권한 화면'); $checks[] = ops_check('보관기간', '보관기간 정리', is_file(__DIR__ . '/retention_cleanup.php'), 'SMS ' . SMS_LOG_RETENTION_DAYS . '일 / 센서로그 ' . SENSOR_LOG_RETENTION_DAYS . '일 / 측정 ' . SENSOR_METRIC_RETENTION_DAYS . '일', 'php retention_cleanup.php --dry-run 결과 확인 후 --execute 수행', 'warn', 'retention_cleanup.php 실행 결과'); $checks[] = ops_check('백업', '백업 증적', is_file(dirname(__DIR__) . '/scripts/backup_evidence.php'), 'scripts/backup_evidence.php', '백업 파일 목록과 복구 테스트 결과를 Markdown으로 보관', 'warn', 'backup-evidence 보고서'); return $checks; } function ops_failed_count(array $checks): int { return count(array_filter($checks, fn($check) => !$check['ok'])); }