초기 버전 v1.0.0 구현
개인 NAS용 생존 확인 웹앱의 기본 기능, Docker 배포 설정, 설치 문서를 추가합니다.
This commit is contained in:
283
src/db.js
Normal file
283
src/db.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const FALLBACK_EMOJIS = ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'];
|
||||
|
||||
function ensureDirectory(filePath) {
|
||||
const directory = path.dirname(filePath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
|
||||
function createDatabase(databasePath) {
|
||||
ensureDirectory(databasePath);
|
||||
|
||||
const db = new Database(databasePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_check_at TEXT NOT NULL,
|
||||
total_checks INTEGER NOT NULL DEFAULT 0,
|
||||
trigger_executed_for_check_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trigger_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
executed_at TEXT NOT NULL,
|
||||
script_path TEXT NOT NULL,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
output TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
`);
|
||||
|
||||
const existingState = db.prepare('SELECT id FROM state WHERE id = 1').get();
|
||||
|
||||
if (!existingState) {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO state (id, last_check_at, total_checks, trigger_executed_for_check_at)
|
||||
VALUES (1, ?, 0, NULL)
|
||||
`).run(now);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function randomEmoji() {
|
||||
return FALLBACK_EMOJIS[Math.floor(Math.random() * FALLBACK_EMOJIS.length)];
|
||||
}
|
||||
|
||||
function getStateRow(db) {
|
||||
return db.prepare('SELECT * FROM state WHERE id = 1').get();
|
||||
}
|
||||
|
||||
function getRecentEvents(db, limit) {
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT emoji, created_at, accepted
|
||||
FROM events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(limit);
|
||||
}
|
||||
|
||||
function daysBetween(fromDate, toDate) {
|
||||
const start = new Date(fromDate);
|
||||
const end = new Date(toDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
function thresholdMilliseconds(config) {
|
||||
if (config.threshold_seconds) {
|
||||
return config.threshold_seconds * 1000;
|
||||
}
|
||||
|
||||
return config.threshold_days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function deadlineFor(lastCheckAt, config) {
|
||||
return new Date(new Date(lastCheckAt).getTime() + thresholdMilliseconds(config));
|
||||
}
|
||||
|
||||
function isExpired(state, config, now = new Date()) {
|
||||
return now.getTime() >= deadlineFor(state.last_check_at, config).getTime();
|
||||
}
|
||||
|
||||
function buildStatus(db, config) {
|
||||
const state = getStateRow(db);
|
||||
const now = new Date();
|
||||
const lastCheckAt = new Date(state.last_check_at);
|
||||
const elapsedDays = daysBetween(state.last_check_at, now.toISOString());
|
||||
const deadlineAt = deadlineFor(lastCheckAt, config);
|
||||
const remainingMs = Math.max(0, deadlineAt.getTime() - now.getTime());
|
||||
const remainingDays = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
||||
|
||||
return {
|
||||
last_check_at: state.last_check_at,
|
||||
deadline_at: deadlineAt.toISOString(),
|
||||
expired: remainingMs <= 0,
|
||||
last_check_display: formatDateTime(lastCheckAt),
|
||||
elapsed_days: elapsedDays,
|
||||
remaining_days: remainingDays,
|
||||
total_checks: state.total_checks,
|
||||
threshold_days: config.threshold_days,
|
||||
threshold_seconds: config.threshold_seconds,
|
||||
cooldown_seconds: config.cooldown_seconds,
|
||||
collection_path: config.collection_path,
|
||||
recent_traces: getRecentEvents(db, config.recent_traces_limit).map((event) => ({
|
||||
emoji: event.emoji,
|
||||
time: formatTime(new Date(event.created_at)),
|
||||
created_at: event.created_at,
|
||||
accepted: Boolean(event.accepted),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function recordCheck(db, config, requestedEmoji) {
|
||||
const emoji =
|
||||
typeof requestedEmoji === 'string' && requestedEmoji.trim()
|
||||
? requestedEmoji.trim().slice(0, 16)
|
||||
: randomEmoji();
|
||||
|
||||
const insertEvent = db.prepare(`
|
||||
INSERT INTO events (emoji, created_at, accepted)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateState = db.prepare(`
|
||||
UPDATE state
|
||||
SET last_check_at = ?,
|
||||
total_checks = total_checks + 1,
|
||||
trigger_executed_for_check_at = NULL
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
let accepted = false;
|
||||
let expired = false;
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const state = getStateRow(db);
|
||||
|
||||
if (isExpired(state, config, now)) {
|
||||
expired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastAcceptedAt = new Date(state.last_check_at);
|
||||
const secondsSinceLastCheck = (now.getTime() - lastAcceptedAt.getTime()) / 1000;
|
||||
accepted = secondsSinceLastCheck >= config.cooldown_seconds;
|
||||
|
||||
insertEvent.run(emoji, nowIso, accepted ? 1 : 0);
|
||||
|
||||
if (accepted) {
|
||||
updateState.run(nowIso);
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
if (expired) {
|
||||
return {
|
||||
accepted: false,
|
||||
expired: true,
|
||||
emoji,
|
||||
message: '이미 시간이 지났어요.',
|
||||
status: buildStatus(db, config),
|
||||
};
|
||||
}
|
||||
|
||||
const message = accepted
|
||||
? `${emoji} 방금 확인했어요!`
|
||||
: `${emoji} 잠시 후 다시 눌러주세요.`;
|
||||
|
||||
return {
|
||||
accepted,
|
||||
expired: false,
|
||||
emoji,
|
||||
message,
|
||||
status: buildStatus(db, config),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRunTrigger(db, config) {
|
||||
const state = getStateRow(db);
|
||||
|
||||
if (!isExpired(state, config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.trigger_executed_for_check_at === state.last_check_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function markTriggerExecuted(db, scriptPath, success, output) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const state = getStateRow(db);
|
||||
|
||||
const insertRun = db.prepare(`
|
||||
INSERT INTO trigger_runs (executed_at, script_path, success, output)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateState = db.prepare(`
|
||||
UPDATE state
|
||||
SET trigger_executed_for_check_at = ?
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
insertRun.run(nowIso, scriptPath, success ? 1 : 0, output ?? '');
|
||||
updateState.run(state.last_check_at);
|
||||
});
|
||||
|
||||
transaction();
|
||||
}
|
||||
|
||||
function resetKeeper(db) {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const resetState = db.prepare(`
|
||||
UPDATE state
|
||||
SET last_check_at = ?,
|
||||
total_checks = 0,
|
||||
trigger_executed_for_check_at = NULL
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
const deleteEvents = db.prepare('DELETE FROM events');
|
||||
const deleteTriggerRuns = db.prepare('DELETE FROM trigger_runs');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
resetState.run(nowIso);
|
||||
deleteEvents.run();
|
||||
deleteTriggerRuns.run();
|
||||
});
|
||||
|
||||
transaction();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FALLBACK_EMOJIS,
|
||||
createDatabase,
|
||||
buildStatus,
|
||||
recordCheck,
|
||||
shouldRunTrigger,
|
||||
markTriggerExecuted,
|
||||
resetKeeper,
|
||||
};
|
||||
Reference in New Issue
Block a user