From 06442f83440f1d1b8f9c00c4a525fb81344faee3 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 12 Aug 2026 17:57:35 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=20=ED=86=B5=EA=B3=84=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + VERSION | 2 +- api/package.json | 2 +- api/src/index.js | 3 + api/src/routes/stats.js | 70 ++++++++++++++ api/src/services/statsService.js | 118 +++++++++++++++++++++++ api/test/stats.test.js | 155 +++++++++++++++++++++++++++++++ docs/changelog.md | 8 ++ docs/deploy.md | 4 + docs/history.md | 5 + docs/map.md | 4 + docs/spec.md | 47 +++++++++- docs/todo.md | 1 - docs/update.md | 14 ++- docs/widget.md | 105 +++++++++++++++++---- 15 files changed, 518 insertions(+), 22 deletions(-) create mode 100644 api/src/routes/stats.js create mode 100644 api/src/services/statsService.js create mode 100644 api/test/stats.test.js diff --git a/.env.example b/.env.example index aec9adf..11f438c 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ API_PORT=3000 JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력 JWT_EXPIRES=7d APP_BASE_URL=https://uniare.sori.studio +# 비워두면 /api/stats 공개, 값을 넣으면 Bearer 또는 X-Stats-Token 필요 +STATS_API_TOKEN= # --- 이메일 (Resend) --- RESEND_API_KEY=re_여기에_API_KEY_입력 diff --git a/VERSION b/VERSION index f3ab5be..cdd7537 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v4.0.22 +v4.0.23 diff --git a/api/package.json b/api/package.json index 9724086..efa59fe 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "uadeck-api", - "version": "4.0.22", + "version": "4.0.23", "type": "module", "main": "src/index.js", "scripts": { diff --git a/api/src/index.js b/api/src/index.js index aa5b5a2..f6dc9b3 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -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)); diff --git a/api/src/routes/stats.js b/api/src/routes/stats.js new file mode 100644 index 0000000..e38ba50 --- /dev/null +++ b/api/src/routes/stats.js @@ -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; +} diff --git a/api/src/services/statsService.js b/api/src/services/statsService.js new file mode 100644 index 0000000..25efcae --- /dev/null +++ b/api/src/services/statsService.js @@ -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} + */ +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} + */ +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, + }; +} diff --git a/api/test/stats.test.js b/api/test/stats.test.js new file mode 100644 index 0000000..7729475 --- /dev/null +++ b/api/test/stats.test.js @@ -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 }); + } +}); diff --git a/docs/changelog.md b/docs/changelog.md index 90376cb..b89a18b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,13 @@ # 변경사항 +## v4.0.23 + +### 운영 지표 API 추가 + +통합 대시보드에서 서비스 상태를 확인할 수 있도록 `GET /api/stats`를 추가했습니다. + +회원 수, 신규 회원 수, 덱 수, 신규 덱 수, 카드 시리즈 수, 등록 카드 수 같은 집계 정보를 확인할 수 있습니다. 개인정보나 상세 회원 목록은 포함하지 않습니다. + ## v4.0.22 ### 아이돌마스터 신데렐라 걸즈 추가 diff --git a/docs/deploy.md b/docs/deploy.md index d7d0cde..cfc0ac2 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -32,6 +32,7 @@ WEB_PORT=44082 APP_BASE_URL=http://localhost:44082 RESEND_API_KEY=Resend_API_Key RESEND_FROM_EMAIL=UNIARE <인증된_발신주소> +STATS_API_TOKEN=대시보드용_토큰_또는_빈값 ``` 랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다. @@ -65,6 +66,7 @@ CORS_ORIGINS=https://uniare.sori.studio APP_BASE_URL=https://uniare.sori.studio RESEND_API_KEY=Resend_API_Key RESEND_FROM_EMAIL=UNIARE +STATS_API_TOKEN=대시보드용_긴_랜덤_문자열 ``` 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` +운영 지표 확인: `curl -fsS http://localhost:44082/api/stats` + 정상이면 `{"ok":true,"version":"4.0.16"}`이 표시된다. 이메일 기능 확인: diff --git a/docs/history.md b/docs/history.md index 1e3d3bd..944f421 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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홉 💡 API가 nginx 뒤에만 노출되므로 `trust proxy=1`로 X-Forwarded-For를 신뢰해야 컨테이너 IP가 아닌 실제 클라이언트 IP로 제한이 걸림. diff --git a/docs/map.md b/docs/map.md index 815e227..e48042d 100644 --- a/docs/map.md +++ b/docs/map.md @@ -119,6 +119,10 @@ style/output.css 서버 진입·헬스 체크 api/src/index.js +외부 통계 API +api/src/routes/stats.js +api/src/services/statsService.js + SQLite 스키마·관리자 시드 api/src/db.js diff --git a/docs/spec.md b/docs/spec.md index 6e40552..2378594 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -3,7 +3,7 @@ ## 프로젝트 개요 - 서비스명: `UNIARE - Union Arena Deck Builder` -- 현재 버전: `v4.0.22` +- 현재 버전: `v4.0.23` - 프론트: 정적 HTML, CSS, JavaScript - 백엔드: Express, SQLite(`better-sqlite3`) - 운영: Docker Compose 기반 Nginx + API @@ -208,6 +208,49 @@ | Method | Path | 권한 | 기능 | |---|---|---|---| | GET | `/api/v1/health` | 공개 | API 상태와 버전 | +| GET | `/api/stats` | 공개 또는 토큰 | 외부 대시보드용 운영 지표 | +| GET | `/api/v1/stats` | 공개 또는 토큰 | 외부 대시보드용 운영 지표 | + +`/api/stats`와 `/api/v1/stats`는 같은 응답을 반환한다. `.env`의 `STATS_API_TOKEN`이 비어 있으면 공개 집계 API로 동작하고, 값이 있으면 `Authorization: Bearer ` 또는 `X-Stats-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`의 인라인 스크립트가 커서 변경 영향 범위가 넓다. - `admin.html`은 3개 언어 번역 적용이 완료되지 않았다. -- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다. +- 외부 통계 API는 집계값만 제공하며, 상세 사용자·덱 정보는 포함하지 않는다. - 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다. - Resend 발송은 운영 `.env`의 API 키·발신 주소와 Resend에서 인증된 발신 도메인이 필요하다. diff --git a/docs/todo.md b/docs/todo.md index 032d24b..b7387ff 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -14,7 +14,6 @@ - `admin.html`의 고정 한국어 문구를 `ko`, `ja`, `en` 번역 체계에 연결한다. - 옆 프로젝트에 `integrations/uadeck-deck-client.js`를 적용하고 실제 공유 덱 조회를 검증한다. -- Homepage 연동이 필요해지면 `GET /api/homepage`와 운영 지표 응답을 구현한다. ## 유지보수 diff --git a/docs/update.md b/docs/update.md index 32231e1..98e6133 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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 카드 시리즈 추가 ### 수행 작업 @@ -223,7 +235,7 @@ - `deploy.md`를 최초 설치·실행·업데이트·백업·롤백 순서의 운영 가이드로 전면 재작성 - `deploy.md`에서 로컬 개발과 NAS 운영을 분리하고 이미지·`datas/`·API 변경별 반영 명령을 구분 - `AGENTS.md` 재검수 결과를 반영해 SQLite WAL 안전 백업·복구 절차와 Compose 고정 이름 제약을 문서화 -- Homepage Custom API YAML을 공식 문서 기준으로 재확인하고 참고 URL 추가 +- Custom API YAML을 공식 문서 기준으로 재확인하고 참고 URL 추가 - `.cursorrules`의 잘못된 `.AGENTS.md` 참조를 `AGENTS.md`로 수정하고 배포 문서의 개인 로컬 절대 경로를 제거 - `deploy.md`의 중복 설명을 제거하고 초심자가 상황별 절차를 바로 선택할 수 있도록 간소화 - `deploy.md`의 Markdown 표를 제거하고 불필요한 줄바꿈을 줄여 명령 중심으로 재정리 diff --git a/docs/widget.md b/docs/widget.md index 311c998..d104de8 100644 --- a/docs/widget.md +++ b/docs/widget.md @@ -4,8 +4,9 @@ - 운영 URL: `https://uniare.sori.studio` - API 상태: `https://uniare.sori.studio/api/v1/health` +- 운영 지표: `https://uniare.sori.studio/api/stats` - 공유 덱: `https://uniare.sori.studio/api/v1/decks/` -- 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 { "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 ``` -## Homepage 위젯 +## 대시보드 위젯 -현재 구현된 헬스 API를 사용하는 예시다. +운영 지표 API를 사용하는 예시다. -Homepage Custom API 공식 문서: +Custom API 공식 문서: ```text https://gethomepage.dev/widgets/services/customapi/ @@ -43,18 +44,92 @@ https://gethomepage.dev/widgets/services/customapi/ description: Union Arena Deck Builder widget: type: customapi - url: https://uniare.sori.studio/api/v1/health + url: https://uniare.sori.studio/api/stats refreshInterval: 30000 mappings: - - field: ok + - field: status label: 상태 format: text - field: version label: 버전 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`를 사용한다. -## 운영 지표 제안 +## 운영 지표 범위 -`GET /api/homepage`를 구현할 때 다음 지표를 우선 제공한다. +현재 운영 지표 API는 다음 집계값을 제공한다. - API 상태와 현재 버전 -- 전체 회원 수 -- 전체 덱 수와 공개 덱 수 -- 최근 24시간 생성 덱 수 -- DB 백업 시각과 성공 여부 -- 미리보기·아바타 저장 공간 사용량 +- 전체·활성·인증 회원 수, 최근 24시간 신규 회원 수, 최근 가입·접속 시각 +- 전체·공개·비공개·익명 덱 수, 최근 24시간 생성·수정 덱 수, 전체 조회 수 +- 등록 카드 시리즈 수, 고유 카드 번호 수, 패러렐 포함 카드 수, 시리즈별 카드 수 -회원 수나 덱 수처럼 운영 정보가 포함된 엔드포인트는 공개 범위를 검토하고, 필요하면 내부 네트워크 또는 별도 토큰으로 제한한다. +DB 백업 시각과 미리보기·아바타 저장 공간 사용량은 아직 포함하지 않는다.