외부 통계 API 추가
This commit is contained in:
@@ -5,6 +5,8 @@ API_PORT=3000
|
|||||||
JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력
|
JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력
|
||||||
JWT_EXPIRES=7d
|
JWT_EXPIRES=7d
|
||||||
APP_BASE_URL=https://uniare.sori.studio
|
APP_BASE_URL=https://uniare.sori.studio
|
||||||
|
# 비워두면 /api/stats 공개, 값을 넣으면 Bearer 또는 X-Stats-Token 필요
|
||||||
|
STATS_API_TOKEN=
|
||||||
|
|
||||||
# --- 이메일 (Resend) ---
|
# --- 이메일 (Resend) ---
|
||||||
RESEND_API_KEY=re_여기에_API_KEY_입력
|
RESEND_API_KEY=re_여기에_API_KEY_입력
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "uadeck-api",
|
"name": "uadeck-api",
|
||||||
"version": "4.0.22",
|
"version": "4.0.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { initDb, seedAdmin } from './db.js';
|
|||||||
import { createAuthRouter } from './routes/auth.js';
|
import { createAuthRouter } from './routes/auth.js';
|
||||||
import { createDecksRouter } from './routes/decks.js';
|
import { createDecksRouter } from './routes/decks.js';
|
||||||
import { createAdminRouter } from './routes/admin.js';
|
import { createAdminRouter } from './routes/admin.js';
|
||||||
|
import { createStatsRouter } from './routes/stats.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
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 });
|
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/auth', createAuthRouter(db));
|
||||||
app.use('/api/v1/decks', createDecksRouter(db));
|
app.use('/api/v1/decks', createDecksRouter(db));
|
||||||
app.use('/api/v1/admin', createAdminRouter(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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# 변경사항
|
# 변경사항
|
||||||
|
|
||||||
|
## v4.0.23
|
||||||
|
|
||||||
|
### 운영 지표 API 추가
|
||||||
|
|
||||||
|
통합 대시보드에서 서비스 상태를 확인할 수 있도록 `GET /api/stats`를 추가했습니다.
|
||||||
|
|
||||||
|
회원 수, 신규 회원 수, 덱 수, 신규 덱 수, 카드 시리즈 수, 등록 카드 수 같은 집계 정보를 확인할 수 있습니다. 개인정보나 상세 회원 목록은 포함하지 않습니다.
|
||||||
|
|
||||||
## v4.0.22
|
## v4.0.22
|
||||||
|
|
||||||
### 아이돌마스터 신데렐라 걸즈 추가
|
### 아이돌마스터 신데렐라 걸즈 추가
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ WEB_PORT=44082
|
|||||||
APP_BASE_URL=http://localhost:44082
|
APP_BASE_URL=http://localhost:44082
|
||||||
RESEND_API_KEY=Resend_API_Key
|
RESEND_API_KEY=Resend_API_Key
|
||||||
RESEND_FROM_EMAIL=UNIARE <인증된_발신주소>
|
RESEND_FROM_EMAIL=UNIARE <인증된_발신주소>
|
||||||
|
STATS_API_TOKEN=대시보드용_토큰_또는_빈값
|
||||||
```
|
```
|
||||||
|
|
||||||
랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다.
|
랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다.
|
||||||
@@ -65,6 +66,7 @@ CORS_ORIGINS=https://uniare.sori.studio
|
|||||||
APP_BASE_URL=https://uniare.sori.studio
|
APP_BASE_URL=https://uniare.sori.studio
|
||||||
RESEND_API_KEY=Resend_API_Key
|
RESEND_API_KEY=Resend_API_Key
|
||||||
RESEND_FROM_EMAIL=UNIARE <account@인증된_발신도메인>
|
RESEND_FROM_EMAIL=UNIARE <account@인증된_발신도메인>
|
||||||
|
STATS_API_TOKEN=대시보드용_긴_랜덤_문자열
|
||||||
```
|
```
|
||||||
|
|
||||||
Resend에서 소유한 도메인 또는 발신용 하위 도메인을 먼저 추가하고 DNS 인증을 완료해야 한다. API Key에는 메일 발송 권한이 필요하며 `.env` 외에는 기록하지 않는다.
|
Resend에서 소유한 도메인 또는 발신용 하위 도메인을 먼저 추가하고 DNS 인증을 완료해야 한다. API Key에는 메일 발송 권한이 필요하며 `.env` 외에는 기록하지 않는다.
|
||||||
@@ -75,6 +77,8 @@ Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
|
|||||||
|
|
||||||
API 확인: `curl -fsS http://localhost:44082/api/v1/health`
|
API 확인: `curl -fsS http://localhost:44082/api/v1/health`
|
||||||
|
|
||||||
|
운영 지표 확인: `curl -fsS http://localhost:44082/api/stats`
|
||||||
|
|
||||||
정상이면 `{"ok":true,"version":"4.0.16"}`이 표시된다.
|
정상이면 `{"ok":true,"version":"4.0.16"}`이 표시된다.
|
||||||
|
|
||||||
이메일 기능 확인:
|
이메일 기능 확인:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-08-12 v4.0.23 — 운영 지표는 집계 API로만 공개
|
||||||
|
|
||||||
|
💡 통합 대시보드에서 변화를 감지하되 개인 정보와 상세 덱 목록을 외부로 내보내지 않기 위함.
|
||||||
|
🔧 `/api/stats`와 `/api/v1/stats`에 회원·덱·카드 집계값을 제공하고, `STATS_API_TOKEN` 설정 시 토큰 인증을 적용.
|
||||||
|
|
||||||
## 2026-07-03 v4.0.21 — 인증 rate limit은 IP 기준 + 프록시 신뢰 1홉
|
## 2026-07-03 v4.0.21 — 인증 rate limit은 IP 기준 + 프록시 신뢰 1홉
|
||||||
|
|
||||||
💡 API가 nginx 뒤에만 노출되므로 `trust proxy=1`로 X-Forwarded-For를 신뢰해야 컨테이너 IP가 아닌 실제 클라이언트 IP로 제한이 걸림.
|
💡 API가 nginx 뒤에만 노출되므로 `trust proxy=1`로 X-Forwarded-For를 신뢰해야 컨테이너 IP가 아닌 실제 클라이언트 IP로 제한이 걸림.
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ style/output.css
|
|||||||
서버 진입·헬스 체크
|
서버 진입·헬스 체크
|
||||||
api/src/index.js
|
api/src/index.js
|
||||||
|
|
||||||
|
외부 통계 API
|
||||||
|
api/src/routes/stats.js
|
||||||
|
api/src/services/statsService.js
|
||||||
|
|
||||||
SQLite 스키마·관리자 시드
|
SQLite 스키마·관리자 시드
|
||||||
api/src/db.js
|
api/src/db.js
|
||||||
|
|
||||||
|
|||||||
47
docs/spec.md
47
docs/spec.md
@@ -3,7 +3,7 @@
|
|||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||||
- 현재 버전: `v4.0.22`
|
- 현재 버전: `v4.0.23`
|
||||||
- 프론트: 정적 HTML, CSS, JavaScript
|
- 프론트: 정적 HTML, CSS, JavaScript
|
||||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||||
- 운영: Docker Compose 기반 Nginx + API
|
- 운영: Docker Compose 기반 Nginx + API
|
||||||
@@ -208,6 +208,49 @@
|
|||||||
| Method | Path | 권한 | 기능 |
|
| Method | Path | 권한 | 기능 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/v1/health` | 공개 | API 상태와 버전 |
|
| GET | `/api/v1/health` | 공개 | API 상태와 버전 |
|
||||||
|
| GET | `/api/stats` | 공개 또는 토큰 | 외부 대시보드용 운영 지표 |
|
||||||
|
| GET | `/api/v1/stats` | 공개 또는 토큰 | 외부 대시보드용 운영 지표 |
|
||||||
|
|
||||||
|
`/api/stats`와 `/api/v1/stats`는 같은 응답을 반환한다. `.env`의 `STATS_API_TOKEN`이 비어 있으면 공개 집계 API로 동작하고, 값이 있으면 `Authorization: Bearer <token>` 또는 `X-Stats-Token: <token>` 헤더가 필요하다. 응답에는 개인정보 없이 집계값만 포함한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "online",
|
||||||
|
"ok": true,
|
||||||
|
"version": "4.0.23",
|
||||||
|
"lastUpdated": "2026-08-12T00:00:00.000Z",
|
||||||
|
"members": {
|
||||||
|
"total": 0,
|
||||||
|
"active": 0,
|
||||||
|
"inactive": 0,
|
||||||
|
"verified": 0,
|
||||||
|
"unverified": 0,
|
||||||
|
"created24h": 0,
|
||||||
|
"latestCreatedAt": null,
|
||||||
|
"latestLoginAt": null
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"total": 0,
|
||||||
|
"userTotal": 0,
|
||||||
|
"presetTotal": 0,
|
||||||
|
"publicTotal": 0,
|
||||||
|
"privateTotal": 0,
|
||||||
|
"anonymousTotal": 0,
|
||||||
|
"created24h": 0,
|
||||||
|
"updated24h": 0,
|
||||||
|
"totalViews": 0,
|
||||||
|
"latestCreatedAt": null,
|
||||||
|
"latestUpdatedAt": null
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"seriesTotal": 0,
|
||||||
|
"cardTypesTotal": 0,
|
||||||
|
"cardPrintsTotal": 0,
|
||||||
|
"parallelPrintsTotal": 0,
|
||||||
|
"bySeries": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 인증
|
### 인증
|
||||||
|
|
||||||
@@ -274,6 +317,6 @@
|
|||||||
|
|
||||||
- `cardList.html`, `result.html`의 인라인 스크립트가 커서 변경 영향 범위가 넓다.
|
- `cardList.html`, `result.html`의 인라인 스크립트가 커서 변경 영향 범위가 넓다.
|
||||||
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
|
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
|
||||||
- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다.
|
- 외부 통계 API는 집계값만 제공하며, 상세 사용자·덱 정보는 포함하지 않는다.
|
||||||
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
|
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
|
||||||
- Resend 발송은 운영 `.env`의 API 키·발신 주소와 Resend에서 인증된 발신 도메인이 필요하다.
|
- Resend 발송은 운영 `.env`의 API 키·발신 주소와 Resend에서 인증된 발신 도메인이 필요하다.
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
- `admin.html`의 고정 한국어 문구를 `ko`, `ja`, `en` 번역 체계에 연결한다.
|
- `admin.html`의 고정 한국어 문구를 `ko`, `ja`, `en` 번역 체계에 연결한다.
|
||||||
- 옆 프로젝트에 `integrations/uadeck-deck-client.js`를 적용하고 실제 공유 덱 조회를 검증한다.
|
- 옆 프로젝트에 `integrations/uadeck-deck-client.js`를 적용하고 실제 공유 덱 조회를 검증한다.
|
||||||
- Homepage 연동이 필요해지면 `GET /api/homepage`와 운영 지표 응답을 구현한다.
|
|
||||||
|
|
||||||
## 유지보수
|
## 유지보수
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## 2026-08-12 / v4.0.23 — 운영 지표 API 추가
|
||||||
|
|
||||||
|
### 수행 작업
|
||||||
|
- 외부 대시보드용 `GET /api/stats`, `GET /api/v1/stats` 추가
|
||||||
|
- 회원·덱 집계와 카드 시리즈·고유 카드 번호·패러렐 포함 카드 수 집계 추가
|
||||||
|
- `.env`의 `STATS_API_TOKEN` 설정 시 Bearer 또는 `X-Stats-Token` 인증 적용
|
||||||
|
- 외부 대시보드 위젯 문서와 API 명세 갱신
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
- 통계 API 라우트 테스트 추가
|
||||||
|
- API 테스트 전체 실행 예정
|
||||||
|
|
||||||
## 2026-08-09 / v4.0.22 — UA55 카드 시리즈 추가
|
## 2026-08-09 / v4.0.22 — UA55 카드 시리즈 추가
|
||||||
|
|
||||||
### 수행 작업
|
### 수행 작업
|
||||||
@@ -223,7 +235,7 @@
|
|||||||
- `deploy.md`를 최초 설치·실행·업데이트·백업·롤백 순서의 운영 가이드로 전면 재작성
|
- `deploy.md`를 최초 설치·실행·업데이트·백업·롤백 순서의 운영 가이드로 전면 재작성
|
||||||
- `deploy.md`에서 로컬 개발과 NAS 운영을 분리하고 이미지·`datas/`·API 변경별 반영 명령을 구분
|
- `deploy.md`에서 로컬 개발과 NAS 운영을 분리하고 이미지·`datas/`·API 변경별 반영 명령을 구분
|
||||||
- `AGENTS.md` 재검수 결과를 반영해 SQLite WAL 안전 백업·복구 절차와 Compose 고정 이름 제약을 문서화
|
- `AGENTS.md` 재검수 결과를 반영해 SQLite WAL 안전 백업·복구 절차와 Compose 고정 이름 제약을 문서화
|
||||||
- Homepage Custom API YAML을 공식 문서 기준으로 재확인하고 참고 URL 추가
|
- Custom API YAML을 공식 문서 기준으로 재확인하고 참고 URL 추가
|
||||||
- `.cursorrules`의 잘못된 `.AGENTS.md` 참조를 `AGENTS.md`로 수정하고 배포 문서의 개인 로컬 절대 경로를 제거
|
- `.cursorrules`의 잘못된 `.AGENTS.md` 참조를 `AGENTS.md`로 수정하고 배포 문서의 개인 로컬 절대 경로를 제거
|
||||||
- `deploy.md`의 중복 설명을 제거하고 초심자가 상황별 절차를 바로 선택할 수 있도록 간소화
|
- `deploy.md`의 중복 설명을 제거하고 초심자가 상황별 절차를 바로 선택할 수 있도록 간소화
|
||||||
- `deploy.md`의 Markdown 표를 제거하고 불필요한 줄바꿈을 줄여 명령 중심으로 재정리
|
- `deploy.md`의 Markdown 표를 제거하고 불필요한 줄바꿈을 줄여 명령 중심으로 재정리
|
||||||
|
|||||||
105
docs/widget.md
105
docs/widget.md
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
- 운영 URL: `https://uniare.sori.studio`
|
- 운영 URL: `https://uniare.sori.studio`
|
||||||
- API 상태: `https://uniare.sori.studio/api/v1/health`
|
- API 상태: `https://uniare.sori.studio/api/v1/health`
|
||||||
|
- 운영 지표: `https://uniare.sori.studio/api/stats`
|
||||||
- 공유 덱: `https://uniare.sori.studio/api/v1/decks/<shareCode>`
|
- 공유 덱: `https://uniare.sori.studio/api/v1/decks/<shareCode>`
|
||||||
- Homepage 전용 `GET /api/homepage`는 아직 구현되지 않았다.
|
- `STATS_API_TOKEN`이 설정된 경우 운영 지표 API는 Bearer 토큰 또는 `X-Stats-Token` 헤더가 필요하다.
|
||||||
|
|
||||||
## 상태 확인
|
## 상태 확인
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ curl -fsS https://uniare.sori.studio/api/v1/health
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"version": "4.0.0"
|
"version": "4.0.23"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,11 +27,11 @@ curl -fsS https://uniare.sori.studio/api/v1/health
|
|||||||
curl -fsS http://localhost:44082/api/v1/health
|
curl -fsS http://localhost:44082/api/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## Homepage 위젯
|
## 대시보드 위젯
|
||||||
|
|
||||||
현재 구현된 헬스 API를 사용하는 예시다.
|
운영 지표 API를 사용하는 예시다.
|
||||||
|
|
||||||
Homepage Custom API 공식 문서:
|
Custom API 공식 문서:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://gethomepage.dev/widgets/services/customapi/
|
https://gethomepage.dev/widgets/services/customapi/
|
||||||
@@ -43,18 +44,92 @@ https://gethomepage.dev/widgets/services/customapi/
|
|||||||
description: Union Arena Deck Builder
|
description: Union Arena Deck Builder
|
||||||
widget:
|
widget:
|
||||||
type: customapi
|
type: customapi
|
||||||
url: https://uniare.sori.studio/api/v1/health
|
url: https://uniare.sori.studio/api/stats
|
||||||
refreshInterval: 30000
|
refreshInterval: 30000
|
||||||
mappings:
|
mappings:
|
||||||
- field: ok
|
- field: status
|
||||||
label: 상태
|
label: 상태
|
||||||
format: text
|
format: text
|
||||||
- field: version
|
- field: version
|
||||||
label: 버전
|
label: 버전
|
||||||
format: text
|
format: text
|
||||||
|
- field: members.total
|
||||||
|
label: 회원
|
||||||
|
format: number
|
||||||
|
- field: members.created24h
|
||||||
|
label: 신규 회원 24h
|
||||||
|
format: number
|
||||||
|
- field: decks.total
|
||||||
|
label: 덱
|
||||||
|
format: number
|
||||||
|
- field: decks.created24h
|
||||||
|
label: 신규 덱 24h
|
||||||
|
format: number
|
||||||
|
- field: cards.seriesTotal
|
||||||
|
label: 시리즈
|
||||||
|
format: number
|
||||||
|
- field: cards.cardTypesTotal
|
||||||
|
label: 카드 종류
|
||||||
|
format: number
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker 내부의 Homepage 컨테이너에서 같은 Compose 네트워크를 사용할 경우 공개 도메인 대신 `http://uniare-web/api/v1/health`를 사용할 수 있다. 서로 다른 Compose 프로젝트라면 공통 외부 네트워크 연결이 필요하다.
|
Docker 내부의 대시보드 컨테이너에서 같은 Compose 네트워크를 사용할 경우 공개 도메인 대신 `http://uniare-web/api/stats`를 사용할 수 있다. 서로 다른 Compose 프로젝트라면 공통 외부 네트워크 연결이 필요하다.
|
||||||
|
|
||||||
|
토큰을 설정한 경우 curl 예시는 다음과 같다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: Bearer $STATS_API_TOKEN" \
|
||||||
|
https://uniare.sori.studio/api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "online",
|
||||||
|
"ok": true,
|
||||||
|
"version": "4.0.23",
|
||||||
|
"lastUpdated": "2026-08-12T00:00:00.000Z",
|
||||||
|
"members": {
|
||||||
|
"total": 12,
|
||||||
|
"active": 12,
|
||||||
|
"inactive": 0,
|
||||||
|
"verified": 10,
|
||||||
|
"unverified": 2,
|
||||||
|
"created24h": 1,
|
||||||
|
"latestCreatedAt": "2026-08-12 09:00:00",
|
||||||
|
"latestLoginAt": "2026-08-12T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"total": 34,
|
||||||
|
"userTotal": 34,
|
||||||
|
"presetTotal": 0,
|
||||||
|
"publicTotal": 20,
|
||||||
|
"privateTotal": 14,
|
||||||
|
"anonymousTotal": 0,
|
||||||
|
"created24h": 3,
|
||||||
|
"updated24h": 5,
|
||||||
|
"totalViews": 120,
|
||||||
|
"latestCreatedAt": "2026-08-12 09:30:00",
|
||||||
|
"latestUpdatedAt": "2026-08-12T10:10:00.000Z"
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"seriesTotal": 56,
|
||||||
|
"cardTypesTotal": 5400,
|
||||||
|
"cardPrintsTotal": 6200,
|
||||||
|
"parallelPrintsTotal": 800,
|
||||||
|
"bySeries": [
|
||||||
|
{
|
||||||
|
"series": "ua01",
|
||||||
|
"cardTypes": 90,
|
||||||
|
"cardPrints": 110,
|
||||||
|
"parallelPrints": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 공유 덱 조회
|
## 공유 덱 조회
|
||||||
|
|
||||||
@@ -64,15 +139,13 @@ curl -fsS https://uniare.sori.studio/api/v1/decks/x7Kp9m2Q
|
|||||||
|
|
||||||
브라우저와 Node.js 연동은 `integrations/uadeck-deck-client.js` 또는 `docs/api-integration.md`를 사용한다.
|
브라우저와 Node.js 연동은 `integrations/uadeck-deck-client.js` 또는 `docs/api-integration.md`를 사용한다.
|
||||||
|
|
||||||
## 운영 지표 제안
|
## 운영 지표 범위
|
||||||
|
|
||||||
`GET /api/homepage`를 구현할 때 다음 지표를 우선 제공한다.
|
현재 운영 지표 API는 다음 집계값을 제공한다.
|
||||||
|
|
||||||
- API 상태와 현재 버전
|
- API 상태와 현재 버전
|
||||||
- 전체 회원 수
|
- 전체·활성·인증 회원 수, 최근 24시간 신규 회원 수, 최근 가입·접속 시각
|
||||||
- 전체 덱 수와 공개 덱 수
|
- 전체·공개·비공개·익명 덱 수, 최근 24시간 생성·수정 덱 수, 전체 조회 수
|
||||||
- 최근 24시간 생성 덱 수
|
- 등록 카드 시리즈 수, 고유 카드 번호 수, 패러렐 포함 카드 수, 시리즈별 카드 수
|
||||||
- DB 백업 시각과 성공 여부
|
|
||||||
- 미리보기·아바타 저장 공간 사용량
|
|
||||||
|
|
||||||
회원 수나 덱 수처럼 운영 정보가 포함된 엔드포인트는 공개 범위를 검토하고, 필요하면 내부 네트워크 또는 별도 토큰으로 제한한다.
|
DB 백업 시각과 미리보기·아바타 저장 공간 사용량은 아직 포함하지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user