feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화

- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침)
- Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원)
- 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일
- 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지)
- 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가
- 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선
- 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
2026-05-23 11:32:01 +09:00
parent e4f542d487
commit 3e5c079e68
18 changed files with 1811 additions and 79 deletions
+179
View File
@@ -0,0 +1,179 @@
import { getConfig } from "./config.js";
import { getDb, hasMongoConfig } from "./db.js";
const DEFAULT_ABOUT_COLLECTION_NAME = "about_content";
const DEVELOPER_INFO_ID = "developer-info";
const PRIVACY_POLICY_ID = "privacy-policy";
const DEFAULT_DEVELOPER_INFO = {
alias: "horoli",
email: "sunha321@gmail.com",
github: "https://github.com/Horoli",
};
const DEFAULT_PRIVACY_POLICY_MARKDOWN = `
### 개인정보처리방침 (초안)
**Arena Picker**는 이용자의 개인정보를 최소한으로 수집하며, 투명하게 관리하기 위해 노력합니다.
#### 1. 수집하는 개인정보 항목 및 방법
본 서비스는 별도의 회원가입 없이 이용 가능하며, 서비스 운영 지표 측정을 위해 아래와 같은 정보를 수집합니다.
- **수집 항목**: 방문자 식별값 (브라우저 쿠키를 기반으로 생성된 암호화된 UUID 해시), 방문 일시, 서비스 이용 기록 (전투 시작/종료, 버튼 클릭 등)
- **수집 방법**: 서비스 접속 시 자동으로 생성 및 서버로 전송
#### 2. 개인정보의 수집 및 이용 목적
수집된 정보는 오직 서비스 품질 개선 및 통계 분석을 위해서만 활용됩니다.
- 중복되지 않는 일일 방문자 수 측정
- 서비스 이용 통계 (전투 횟수, 선호 캐릭터 등) 분석
- 서비스 안정성 확인 및 버그 진단
#### 3. 개인정보의 보유 및 이용 기간
- 수집된 활동 로그 및 통계 데이터는 수집일로부터 **60일**간 보관 후 복구 불가능한 방법으로 파기됩니다.
#### 4. 개인정보의 제3자 제공
본 서비스는 이용자의 개인정보를 외부에 제공하거나 공유하지 않습니다.
#### 5. 이용자의 권리
이용자는 브라우저의 쿠키를 삭제함으로써 언제든지 식별 정보를 초기화할 수 있습니다.
**공고일자**: 2024년 5월 23일
**시행일자**: 2024년 5월 23일
`;
let aboutCache;
let aboutIndexesReady;
let aboutWarmupPromise;
export async function aboutRoutes(fastify) {
fastify.get("/about", async () => getAboutContent());
fastify.get("/about/", async () => getAboutContent());
}
export async function warmAboutContent() {
if (!hasMongoConfig()) {
aboutCache = formatAboutContent();
return aboutCache;
}
if (!aboutWarmupPromise) {
aboutWarmupPromise = loadAboutContent()
.then((content) => {
aboutCache = content;
return content;
})
.finally(() => {
aboutWarmupPromise = undefined;
});
}
return aboutWarmupPromise;
}
async function getAboutContent() {
if (aboutCache) {
return aboutCache;
}
return warmAboutContent();
}
async function loadAboutContent() {
const collection = await getAboutCollection();
await ensureAboutDefaults(collection);
const [developerInfo, privacyPolicy] = await Promise.all([
collection.findOne({ _id: DEVELOPER_INFO_ID }),
collection.findOne({ _id: PRIVACY_POLICY_ID }),
]);
return formatAboutContent(developerInfo, privacyPolicy);
}
async function getAboutCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_ABOUT_COLLECTION || DEFAULT_ABOUT_COLLECTION_NAME,
);
await ensureAboutIndexes(collection);
return collection;
}
async function ensureAboutIndexes(collection) {
if (!aboutIndexesReady) {
aboutIndexesReady = collection.createIndex({ type: 1 });
}
return aboutIndexesReady;
}
async function ensureAboutDefaults(collection) {
const now = new Date();
await collection.bulkWrite(
[
{
updateOne: {
filter: { _id: DEVELOPER_INFO_ID },
update: {
$setOnInsert: {
_id: DEVELOPER_INFO_ID,
type: "developerInfo",
...DEFAULT_DEVELOPER_INFO,
createdAt: now,
},
},
upsert: true,
},
},
{
updateOne: {
filter: { _id: PRIVACY_POLICY_ID },
update: {
$set: {
markdown: DEFAULT_PRIVACY_POLICY_MARKDOWN,
updatedAt: now,
},
$setOnInsert: {
_id: PRIVACY_POLICY_ID,
type: "privacyPolicy",
createdAt: now,
},
},
upsert: true,
},
},
],
{ ordered: false },
);
}
function formatAboutContent(developerInfo = {}, privacyPolicy = {}) {
return {
developer: normalizeDeveloperInfo(developerInfo),
privacyPolicy: {
markdown: stringValue(
privacyPolicy?.markdown,
DEFAULT_PRIVACY_POLICY_MARKDOWN,
),
updatedAt: dateString(privacyPolicy?.updatedAt || privacyPolicy?.createdAt),
},
};
}
function normalizeDeveloperInfo(document = {}) {
return {
alias: stringValue(document?.alias, DEFAULT_DEVELOPER_INFO.alias),
email: stringValue(document?.email, DEFAULT_DEVELOPER_INFO.email),
github: stringValue(document?.github, DEFAULT_DEVELOPER_INFO.github),
};
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string");
return value?.trim() ?? "";
}
function dateString(value) {
return value?.toISOString?.() ?? null;
}
+31
View File
@@ -11,10 +11,15 @@ const DEFAULT_CONFIG = {
MONGODB_PASS: "",
MONGODB_URI: "",
MONGODB_VISITOR_COLLECTION: "visitors",
MONGODB_ABOUT_COLLECTION: "about_content",
MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
MONGODB_DAILY_METRICS_COLLECTION: "daily_metrics",
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: "daily_visitor_activity",
MONGODB_MAX_POOL_SIZE: 10,
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
ANALYTICS_TIME_ZONE: "Asia/Seoul",
DAILY_ACTIVITY_RETENTION_DAYS: 60,
COOKIE_SECURE: false,
};
@@ -77,11 +82,26 @@ function normalizeConfig(rawConfig) {
mongodb.visitorCollection,
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
),
MONGODB_ABOUT_COLLECTION: stringValue(
rawConfig.MONGODB_ABOUT_COLLECTION,
mongodb.aboutCollection,
DEFAULT_CONFIG.MONGODB_ABOUT_COLLECTION,
),
MONGODB_DAILY_DEATH_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
mongodb.dailyDeathCollection,
DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
),
MONGODB_DAILY_METRICS_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_METRICS_COLLECTION,
mongodb.dailyMetricsCollection,
DEFAULT_CONFIG.MONGODB_DAILY_METRICS_COLLECTION,
),
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
mongodb.dailyVisitorActivityCollection,
DEFAULT_CONFIG.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
),
MONGODB_MAX_POOL_SIZE: numberValue(
rawConfig.MONGODB_MAX_POOL_SIZE,
mongodb.maxPoolSize,
@@ -98,6 +118,17 @@ function normalizeConfig(rawConfig) {
mongodb.deathStatsTimeZone,
DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
),
ANALYTICS_TIME_ZONE: stringValue(
rawConfig.ANALYTICS_TIME_ZONE,
rawConfig.TIME_ZONE,
mongodb.analyticsTimeZone,
DEFAULT_CONFIG.ANALYTICS_TIME_ZONE,
),
DAILY_ACTIVITY_RETENTION_DAYS: numberValue(
rawConfig.DAILY_ACTIVITY_RETENTION_DAYS,
mongodb.dailyActivityRetentionDays,
DEFAULT_CONFIG.DAILY_ACTIVITY_RETENTION_DAYS,
),
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
};
}
+357
View File
@@ -0,0 +1,357 @@
import { createHash } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
import { readVisitorCookie, isValidVisitorId } from "./visitorCookie.js";
const DEFAULT_DAILY_METRICS_COLLECTION_NAME = "daily_metrics";
const DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME = "daily_visitor_activity";
const DEFAULT_ACTIVITY_RETENTION_DAYS = 60;
const METRIC_FIELDS = [
"uniqueVisitors",
"totalVisits",
"totalMatchStarts",
"totalMatchFinishes",
"visitorsWithTwoOrMoreMatches",
"donationClicks",
];
const EVENT_CONFIG = {
"match-started": {
metricField: "totalMatchStarts",
activityField: "matchStarts",
},
"match-finished": {
metricField: "totalMatchFinishes",
activityField: "matchFinishes",
},
"donation-clicked": {
metricField: "donationClicks",
activityField: "donationClicks",
},
};
let metricsIndexesReady;
let activityIndexesReady;
export async function dailyMetricsRoutes(fastify) {
fastify.get("/today", async () => {
return getTodayDailyMetrics();
});
fastify.post("/match-started", async (request) => {
return recordDailyMetricEvent("match-started", {
visitorId: readVisitorCookie(request),
});
});
fastify.post("/match-finished", async (request) => {
return recordDailyMetricEvent("match-finished", {
visitorId: readVisitorCookie(request),
});
});
fastify.post("/donation-clicked", async (request) => {
return recordDailyMetricEvent("donation-clicked", {
visitorId: readVisitorCookie(request),
});
});
}
export async function recordDailyVisit(visitorId, { now = new Date() } = {}) {
const date = dayKey(now);
let uniqueVisitors = 0;
if (isValidVisitorId(visitorId)) {
const activityCollection = await getDailyVisitorActivityCollection();
const activityId = await ensureDailyVisitorActivity(
activityCollection,
date,
visitorId,
now,
);
const uniqueResult = await activityCollection.updateOne(
{
_id: activityId,
dailyUniqueCounted: { $ne: true },
},
{
$set: {
dailyUniqueCounted: true,
lastSeenAt: now,
},
$inc: {
visits: 1,
},
},
);
if (uniqueResult.modifiedCount > 0) {
uniqueVisitors = 1;
} else {
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
visits: 1,
},
},
);
}
}
return updateDailyMetrics(date, now, {
totalVisits: 1,
uniqueVisitors,
});
}
export async function recordDailyMetricEvent(eventType, { visitorId, now = new Date() } = {}) {
const eventConfig = EVENT_CONFIG[eventType];
if (!eventConfig) {
throw new Error(`Unknown daily metric event: ${eventType}`);
}
const date = dayKey(now);
const increments = {
[eventConfig.metricField]: 1,
};
if (isValidVisitorId(visitorId)) {
const countedSecondMatch = await recordDailyVisitorEvent(
date,
visitorId,
eventConfig.activityField,
now,
);
if (countedSecondMatch) {
increments.visitorsWithTwoOrMoreMatches = 1;
}
}
return updateDailyMetrics(date, now, increments);
}
async function recordDailyVisitorEvent(date, visitorId, activityField, now) {
const activityCollection = await getDailyVisitorActivityCollection();
const activityId = await ensureDailyVisitorActivity(
activityCollection,
date,
visitorId,
now,
);
if (activityField !== "matchStarts") {
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
[activityField]: 1,
},
},
);
return false;
}
const secondMatchResult = await activityCollection.updateOne(
{
_id: activityId,
matchStarts: 1,
},
{
$set: {
lastSeenAt: now,
},
$inc: {
matchStarts: 1,
},
},
);
if (secondMatchResult.modifiedCount > 0) {
return true;
}
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
matchStarts: 1,
},
},
);
return false;
}
async function ensureDailyVisitorActivity(collection, date, visitorId, now) {
const visitorHash = dailyVisitorHash(date, visitorId);
const activityId = `${date}:${visitorHash}`;
await collection.updateOne(
{ _id: activityId },
{
$setOnInsert: {
_id: activityId,
date,
visitorHash,
dailyUniqueCounted: false,
visits: 0,
matchStarts: 0,
matchFinishes: 0,
donationClicks: 0,
firstSeenAt: now,
expireAt: retentionDate(now),
},
},
{ upsert: true },
);
return activityId;
}
async function updateDailyMetrics(date, now, increments) {
const collection = await getDailyMetricsCollection();
const normalizedIncrements = normalizeIncrements(increments);
await collection.updateOne(
{ _id: date },
{
$setOnInsert: {
_id: date,
date,
createdAt: now,
},
$set: {
updatedAt: now,
},
$inc: normalizedIncrements,
},
{ upsert: true },
);
const today = await collection.findOne({ _id: date });
return formatDailyMetrics(today, date);
}
async function getTodayDailyMetrics(now = new Date()) {
const date = dayKey(now);
const collection = await getDailyMetricsCollection();
const today = await collection.findOne({ _id: date });
return formatDailyMetrics(today, date);
}
async function getDailyMetricsCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_DAILY_METRICS_COLLECTION || DEFAULT_DAILY_METRICS_COLLECTION_NAME,
);
await ensureDailyMetricsIndexes(collection);
return collection;
}
async function getDailyVisitorActivityCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION
|| DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME,
);
await ensureDailyVisitorActivityIndexes(collection);
return collection;
}
async function ensureDailyMetricsIndexes(collection) {
if (!metricsIndexesReady) {
metricsIndexesReady = collection.createIndex({ updatedAt: -1 });
}
return metricsIndexesReady;
}
async function ensureDailyVisitorActivityIndexes(collection) {
if (!activityIndexesReady) {
activityIndexesReady = Promise.all([
collection.createIndex({ date: 1 }),
collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }),
]);
}
return activityIndexesReady;
}
function normalizeIncrements(increments) {
return Object.entries(increments).reduce((result, [field, value]) => {
const numericValue = Math.max(0, Math.round(Number(value) || 0));
if (METRIC_FIELDS.includes(field) && numericValue > 0) {
result[field] = numericValue;
}
return result;
}, {});
}
function formatDailyMetrics(document, date) {
return {
date,
uniqueVisitors: metricNumber(document?.uniqueVisitors),
totalVisits: metricNumber(document?.totalVisits),
totalMatchStarts: metricNumber(document?.totalMatchStarts),
totalMatchFinishes: metricNumber(document?.totalMatchFinishes),
visitorsWithTwoOrMoreMatches: metricNumber(document?.visitorsWithTwoOrMoreMatches),
donationClicks: metricNumber(document?.donationClicks),
updatedAt: document?.updatedAt?.toISOString?.() ?? null,
};
}
function metricNumber(value) {
return Math.max(0, Math.round(Number(value) || 0));
}
function dailyVisitorHash(date, visitorId) {
return createHash("sha256")
.update(`${date}:${visitorId}`)
.digest("hex")
.slice(0, 32);
}
function retentionDate(now) {
const retentionDays = getConfig().DAILY_ACTIVITY_RETENTION_DAYS || DEFAULT_ACTIVITY_RETENTION_DAYS;
return new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000);
}
function dayKey(date) {
const appConfig = getConfig();
const timeZone = appConfig.ANALYTICS_TIME_ZONE || appConfig.DEATH_STATS_TIME_ZONE || "Asia/Seoul";
try {
const parts = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric",
})
.formatToParts(date)
.reduce((result, part) => {
result[part.type] = part.value;
return result;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
} catch {
return date.toISOString().slice(0, 10);
}
}
+17 -2
View File
@@ -4,6 +4,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { getConfig } from "./config.js";
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js";
import { aboutRoutes, warmAboutContent } from "./about.js";
import { dailyMetricsRoutes } from "./dailyMetrics.js";
import { deathStatsRoutes } from "./deathStats.js";
import { visitorRoutes } from "./visitors.js";
@@ -45,12 +47,19 @@ if (!isProduction) {
}
await app.register(visitorRoutes, { prefix: "/api/visitors" });
await app.register(aboutRoutes, { prefix: "/api" });
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
await app.register(dailyMetricsRoutes, { prefix: "/api/daily-metrics" });
if (isProduction) {
await app.register(fastifyStatic, {
root: distPath,
prefix: "/",
cacheControl: true,
maxAge: 3600000 * 24 * 7, // 7일간 캐시 유지
immutable: true,
lastModified: true,
etag: true,
});
app.setNotFoundHandler((request, reply) => {
@@ -108,11 +117,17 @@ console.log(`Arena Picker listening on http://localhost:${port}`);
if (hasMongoConfig()) {
getMongoClient()
.then(() => {
.then(async () => {
console.log("MongoDB connection pool is ready.");
try {
await warmAboutContent();
console.log("About content cache is ready.");
} catch (error) {
console.error("About content cache warmup failed. API route will retry on request.", error);
}
})
.catch((error) => {
console.error("MongoDB connection failed. Visitor API will retry on request.", error);
console.error("MongoDB connection failed. API routes will retry on request.", error);
});
}
+42
View File
@@ -0,0 +1,42 @@
export const VISITOR_COOKIE_NAME = "arena_visitor_id";
export const VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
export function readVisitorCookie(request) {
return readCookie(request, VISITOR_COOKIE_NAME);
}
export function writeVisitorCookie(reply, visitorId, { secure = false } = {}) {
const secureFlag = secure ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${VISITOR_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
export function isValidVisitorId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
function readCookie(request, name) {
const cookieHeader = request.headers.cookie;
if (!cookieHeader) {
return "";
}
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
if (!matchedCookie) {
return "";
}
try {
return decodeURIComponent(matchedCookie.slice(name.length + 1));
} catch {
return "";
}
}
+10 -39
View File
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
import { recordDailyVisit } from "./dailyMetrics.js";
import { isValidVisitorId, readVisitorCookie, writeVisitorCookie } from "./visitorCookie.js";
const COOKIE_NAME = "arena_visitor_id";
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
const DEFAULT_COLLECTION_NAME = "visitors";
const USER_AGENT_LIMIT = 500;
@@ -28,7 +28,7 @@ async function recordVisitor(request, reply) {
const collection = await getVisitorCollection();
await ensureVisitorIndexes(collection);
let visitorId = readCookie(request, COOKIE_NAME);
let visitorId = readVisitorCookie(request);
const hadValidCookie = isValidVisitorId(visitorId);
if (!hadValidCookie) {
@@ -57,7 +57,13 @@ async function recordVisitor(request, reply) {
);
if (!hadValidCookie || result.upsertedCount > 0) {
writeVisitorCookie(reply, visitorId);
writeVisitorCookie(reply, visitorId, { secure: getConfig().COOKIE_SECURE });
}
try {
await recordDailyVisit(visitorId, { now });
} catch (error) {
request.log.warn({ err: error }, "Daily visit metrics update failed.");
}
return {
@@ -80,38 +86,3 @@ async function ensureVisitorIndexes(collection) {
return indexesReady;
}
function readCookie(request, name) {
const cookieHeader = request.headers.cookie;
if (!cookieHeader) {
return "";
}
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
if (!matchedCookie) {
return "";
}
try {
return decodeURIComponent(matchedCookie.slice(name.length + 1));
} catch {
return "";
}
}
function writeVisitorCookie(reply, visitorId) {
const secureFlag = getConfig().COOKIE_SECURE ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
function isValidVisitorId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}