외부 통계 API 추가

This commit is contained in:
2026-08-12 17:57:35 +09:00
parent 65818cf2ee
commit 06442f8344
15 changed files with 518 additions and 22 deletions

View File

@@ -8,6 +8,7 @@ import { initDb, seedAdmin } from './db.js';
import { createAuthRouter } from './routes/auth.js';
import { createDecksRouter } from './routes/decks.js';
import { createAdminRouter } from './routes/admin.js';
import { createStatsRouter } from './routes/stats.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 3000;
@@ -51,6 +52,8 @@ app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: APP_VERSION });
});
app.use('/api/stats', createStatsRouter(db, APP_VERSION));
app.use('/api/v1/stats', createStatsRouter(db, APP_VERSION));
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/decks', createDecksRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));

70
api/src/routes/stats.js Normal file
View File

@@ -0,0 +1,70 @@
import crypto from 'crypto';
import { Router } from 'express';
import { getCardStats, getDatabaseStats } from '../services/statsService.js';
/**
* 토큰 값을 안전하게 비교한다.
* @param {string} received
* @param {string} expected
* @returns {boolean}
*/
function isSameToken(received, expected) {
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
/**
* 통계 API 토큰이 설정된 경우 요청을 보호한다.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
*/
function requireStatsToken(req, res, next) {
const expectedToken = process.env.STATS_API_TOKEN;
if (!expectedToken) {
return next();
}
const bearer = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: '';
const receivedToken = req.headers['x-stats-token'] || bearer;
if (typeof receivedToken === 'string' && isSameToken(receivedToken, expectedToken)) {
return next();
}
res.status(401).json({ error: '통계 API 토큰이 필요합니다.' });
}
/**
* 외부 대시보드용 통계 라우트
* @param {import('better-sqlite3').Database} db
* @param {string} appVersion
* @returns {import('express').Router}
*/
export function createStatsRouter(db, appVersion) {
const router = Router();
router.get('/', requireStatsToken, async (_req, res, next) => {
try {
const databaseStats = getDatabaseStats(db);
const cardStats = await getCardStats();
res.json({
status: 'online',
ok: true,
version: appVersion,
lastUpdated: new Date().toISOString(),
...databaseStats,
cards: cardStats,
});
} catch (err) {
next(err);
}
});
return router;
}

View File

@@ -0,0 +1,118 @@
import fs from 'fs/promises';
import path from 'path';
import { pathToFileURL } from 'url';
const SERIES_FILE_PATTERN = /^(ua\d+|uapr)\.js$/;
/**
* COUNT 쿼리 결과를 숫자로 변환한다.
* @param {{ count: number } | undefined} row
* @returns {number}
*/
function countValue(row) {
return Number(row?.count ?? 0);
}
/**
* 외부 대시보드용 DB 집계값을 만든다.
* @param {import('better-sqlite3').Database} db
* @returns {object}
*/
export function getDatabaseStats(db) {
const userStats = db.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) AS inactive,
SUM(CASE WHEN email_verified_at IS NOT NULL THEN 1 ELSE 0 END) AS verified,
SUM(CASE WHEN email_verified_at IS NULL THEN 1 ELSE 0 END) AS unverified,
SUM(CASE WHEN created_at >= datetime('now', '-1 day') THEN 1 ELSE 0 END) AS created24h,
MAX(created_at) AS latestCreatedAt,
MAX(last_login_at) AS latestLoginAt
FROM users
`).get();
const deckStats = db.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN share_type = 'user' THEN 1 ELSE 0 END) AS userTotal,
SUM(CASE WHEN share_type = 'preset' THEN 1 ELSE 0 END) AS presetTotal,
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS publicTotal,
SUM(CASE WHEN is_public = 0 THEN 1 ELSE 0 END) AS privateTotal,
SUM(CASE WHEN user_id IS NULL THEN 1 ELSE 0 END) AS anonymousTotal,
SUM(CASE WHEN created_at >= datetime('now', '-1 day') THEN 1 ELSE 0 END) AS created24h,
SUM(CASE WHEN updated_at >= datetime('now', '-1 day') THEN 1 ELSE 0 END) AS updated24h,
COALESCE(SUM(view_count), 0) AS totalViews,
MAX(created_at) AS latestCreatedAt,
MAX(updated_at) AS latestUpdatedAt
FROM decks
`).get();
return {
members: {
total: countValue({ count: userStats.total }),
active: countValue({ count: userStats.active }),
inactive: countValue({ count: userStats.inactive }),
verified: countValue({ count: userStats.verified }),
unverified: countValue({ count: userStats.unverified }),
created24h: countValue({ count: userStats.created24h }),
latestCreatedAt: userStats.latestCreatedAt,
latestLoginAt: userStats.latestLoginAt,
},
decks: {
total: countValue({ count: deckStats.total }),
userTotal: countValue({ count: deckStats.userTotal }),
presetTotal: countValue({ count: deckStats.presetTotal }),
publicTotal: countValue({ count: deckStats.publicTotal }),
privateTotal: countValue({ count: deckStats.privateTotal }),
anonymousTotal: countValue({ count: deckStats.anonymousTotal }),
created24h: countValue({ count: deckStats.created24h }),
updated24h: countValue({ count: deckStats.updated24h }),
totalViews: countValue({ count: deckStats.totalViews }),
latestCreatedAt: deckStats.latestCreatedAt,
latestUpdatedAt: deckStats.latestUpdatedAt,
},
};
}
/**
* 카드 데이터 파일 하나의 통계를 읽는다.
* @param {string} datasPath
* @param {string} fileName
* @returns {Promise<object>}
*/
async function getSeriesStats(datasPath, fileName) {
const series = fileName.replace(/\.js$/, '');
const filePath = path.join(datasPath, fileName);
const mod = await import(pathToFileURL(filePath).href);
const cards = Array.isArray(mod.cardList) ? mod.cardList : [];
const uniqueNumbers = new Set(cards.map((card) => card.Number).filter(Boolean));
const parallelPrints = cards.filter((card) => card.parallel === true).length;
return {
series,
cardTypes: uniqueNumbers.size,
cardPrints: cards.length,
parallelPrints,
};
}
/**
* datas/*.js 기반 카드 시리즈·카드 수 집계값을 만든다.
* @returns {Promise<object>}
*/
export async function getCardStats() {
const datasPath = process.env.DATAS_PATH || path.join(process.cwd(), '..', 'datas');
const files = (await fs.readdir(datasPath))
.filter((fileName) => SERIES_FILE_PATTERN.test(fileName))
.sort((a, b) => a.localeCompare(b, 'en'));
const bySeries = await Promise.all(files.map((fileName) => getSeriesStats(datasPath, fileName)));
return {
seriesTotal: bySeries.length,
cardTypesTotal: bySeries.reduce((sum, row) => sum + row.cardTypes, 0),
cardPrintsTotal: bySeries.reduce((sum, row) => sum + row.cardPrints, 0),
parallelPrintsTotal: bySeries.reduce((sum, row) => sum + row.parallelPrints, 0),
bySeries,
};
}