외부 통계 API 추가
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.22",
|
||||
"version": "4.0.23",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
70
api/src/routes/stats.js
Normal 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;
|
||||
}
|
||||
118
api/src/services/statsService.js
Normal file
118
api/src/services/statsService.js
Normal 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,
|
||||
};
|
||||
}
|
||||
155
api/test/stats.test.js
Normal file
155
api/test/stats.test.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import express from 'express';
|
||||
import { initDb } from '../src/db.js';
|
||||
import { createStatsRouter } from '../src/routes/stats.js';
|
||||
|
||||
/**
|
||||
* 테스트용 카드 데이터 파일을 생성한다.
|
||||
* @param {string} datasPath
|
||||
* @returns {void}
|
||||
*/
|
||||
function writeTestDatas(datasPath) {
|
||||
fs.mkdirSync(datasPath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(datasPath, 'ua01.js'),
|
||||
`
|
||||
export const cardList = [
|
||||
{ imgSrc: '/ua01/001.png', Number: 'UA01-001', parallel: false },
|
||||
{ imgSrc: '/ua01/001_p1.png', Number: 'UA01-001', parallel: true },
|
||||
{ imgSrc: '/ua01/002.png', Number: 'UA01-002', parallel: false },
|
||||
];
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(datasPath, 'uapr.js'),
|
||||
`
|
||||
export const cardList = [
|
||||
{ imgSrc: '/uapr/001.png', Number: 'UAPR-001', parallel: false },
|
||||
];
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(datasPath, 'ua99 sample.js'),
|
||||
`
|
||||
export const cardList = [
|
||||
{ imgSrc: '/sample/001.png', Number: 'SAMPLE-001', parallel: false },
|
||||
];
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 중 바꾼 환경변수를 원래 상태로 되돌린다.
|
||||
* @param {string} key
|
||||
* @param {string | undefined} value
|
||||
* @returns {void}
|
||||
*/
|
||||
function restoreEnv(key, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
test('통계 API가 DB와 카드 데이터 운영 지표를 반환한다', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-stats-'));
|
||||
const dbPath = path.join(tempDir, 'test.db');
|
||||
const datasPath = path.join(tempDir, 'datas');
|
||||
const previousDatasPath = process.env.DATAS_PATH;
|
||||
const previousToken = process.env.STATS_API_TOKEN;
|
||||
process.env.DATAS_PATH = datasPath;
|
||||
delete process.env.STATS_API_TOKEN;
|
||||
writeTestDatas(datasPath);
|
||||
|
||||
const db = initDb(dbPath);
|
||||
db.prepare(`
|
||||
INSERT INTO users (
|
||||
email, username, password_hash, display_name, email_verified_at, last_login_at
|
||||
)
|
||||
VALUES
|
||||
('active@example.com', 'active', 'hash', 'active', datetime('now'), datetime('now')),
|
||||
('pending@example.com', 'pending', 'hash', 'pending', NULL, NULL)
|
||||
`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO decks (
|
||||
share_code, share_type, user_id, series, series_name, name, cards_json, is_public, view_count
|
||||
)
|
||||
VALUES
|
||||
('PUBLIC01', 'user', 1, 'ua01', '테스트', '공개 덱', '[]', 1, 3),
|
||||
('PRIVATE1', 'user', 1, 'ua01', '테스트', '비공개 덱', '[]', 0, 2),
|
||||
('PRESET01', 'preset', NULL, 'uapr', '프로모', '프리셋', '[]', 1, 5)
|
||||
`).run();
|
||||
|
||||
const app = express();
|
||||
app.use('/api/stats', createStatsRouter(db, '4.0.test'));
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const address = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/api/stats`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(body.status, 'online');
|
||||
assert.equal(body.version, '4.0.test');
|
||||
assert.equal(body.members.total, 2);
|
||||
assert.equal(body.members.verified, 1);
|
||||
assert.equal(body.members.unverified, 1);
|
||||
assert.equal(body.decks.total, 3);
|
||||
assert.equal(body.decks.publicTotal, 2);
|
||||
assert.equal(body.decks.privateTotal, 1);
|
||||
assert.equal(body.decks.totalViews, 10);
|
||||
assert.equal(body.cards.seriesTotal, 2);
|
||||
assert.equal(body.cards.cardTypesTotal, 3);
|
||||
assert.equal(body.cards.cardPrintsTotal, 4);
|
||||
assert.equal(body.cards.parallelPrintsTotal, 1);
|
||||
} finally {
|
||||
server.close();
|
||||
db.close();
|
||||
restoreEnv('DATAS_PATH', previousDatasPath);
|
||||
restoreEnv('STATS_API_TOKEN', previousToken);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('통계 API 토큰이 설정되면 인증 헤더를 요구한다', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-stats-token-'));
|
||||
const dbPath = path.join(tempDir, 'test.db');
|
||||
const datasPath = path.join(tempDir, 'datas');
|
||||
const previousDatasPath = process.env.DATAS_PATH;
|
||||
const previousToken = process.env.STATS_API_TOKEN;
|
||||
process.env.DATAS_PATH = datasPath;
|
||||
process.env.STATS_API_TOKEN = 'dashboard-secret';
|
||||
writeTestDatas(datasPath);
|
||||
|
||||
const db = initDb(dbPath);
|
||||
const app = express();
|
||||
app.use('/api/stats', createStatsRouter(db, '4.0.test'));
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}/api/stats`;
|
||||
|
||||
const denied = await fetch(baseUrl);
|
||||
assert.equal(denied.status, 401);
|
||||
|
||||
const allowed = await fetch(baseUrl, {
|
||||
headers: { Authorization: 'Bearer dashboard-secret' },
|
||||
});
|
||||
assert.equal(allowed.status, 200);
|
||||
} finally {
|
||||
server.close();
|
||||
db.close();
|
||||
restoreEnv('DATAS_PATH', previousDatasPath);
|
||||
restoreEnv('STATS_API_TOKEN', previousToken);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user