Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/services/hinana-pedia.service.ts
공개 가능
1
import fs from 'fs';
2
import path from 'path';
3
import { randomUUID } from 'crypto';
4
import { hinanaPediaFile } from '../config/constants';
5
import { HinanaPediaReview } from '../types/models';
6
7
export interface HinanaPediaWork {
8
key: string;
9
title: string;
10
averageRating: number;
11
reviewCount: number;
12
latestAt: string;
13
reviews: HinanaPediaReview[];
14
}
15
16
export interface SaveHinanaPediaReviewInput {
17
title: string;
18
rating: number;
19
content: string;
20
discordUserId: string;
21
discordUsername: string;
22
discordDisplayName: string;
23
guildId: string | null;
24
guildName: string;
25
}
26
27
function normalizeText(value: string): string {
28
return String(value || '').normalize('NFKC').replace(/\s+/g, ' ').trim();
29
}
30
31
export function normalizePediaTitleKey(value: string): string {
32
return normalizeText(value).toLocaleLowerCase('ko-KR');
33
}
34
35
function readReviews(): HinanaPediaReview[] {
36
try {
37
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
38
return Array.isArray(parsed) ? parsed : [];
39
} catch (error: any) {
40
if (error?.code === 'ENOENT') return [];
41
throw error;
42
}
43
}
44
45
function writeReviews(reviews: HinanaPediaReview[]): void {
46
fs.mkdirSync(path.dirname(hinanaPediaFile), { recursive: true });
47
const temporaryFile = `${hinanaPediaFile}.tmp`;
48
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
49
fs.renameSync(temporaryFile, hinanaPediaFile);
50
}
51
52
function isValidRating(value: number): boolean {
53
return Number.isFinite(value) && value >= 0.5 && value <= 5 && Number.isInteger(value * 2);
54
}
55
56
export function saveHinanaPediaReview(input: SaveHinanaPediaReviewInput): { review: HinanaPediaReview; created: boolean } {
57
const title = normalizeText(input.title).slice(0, 100);
58
const titleKey = normalizePediaTitleKey(title);
59
const content = normalizeText(input.content).slice(0, 1000);
60
if (!titleKey) throw new Error('INVALID_TITLE');
61
if (!content) throw new Error('INVALID_CONTENT');
62
if (!isValidRating(input.rating)) throw new Error('INVALID_RATING');
63
64
const reviews = readReviews();
65
const existingIndex = reviews.findIndex(review =>
66
review.discordUserId === input.discordUserId && review.titleKey === titleKey
67
);
68
const now = new Date().toISOString();
69
const common = {
70
title,
71
titleKey,
72
rating: input.rating,
73
content,
74
discordUserId: input.discordUserId,
75
discordUsername: normalizeText(input.discordUsername).slice(0, 80),
76
discordDisplayName: normalizeText(input.discordDisplayName).slice(0, 80),
77
guildId: input.guildId,
78
guildName: normalizeText(input.guildName).slice(0, 100),
79
updatedAt: now
80
};
81
82
let review: HinanaPediaReview;
83
if (existingIndex >= 0) {
84
review = { ...reviews[existingIndex], ...common };
85
reviews[existingIndex] = review;
86
} else {
87
review = { ...common, id: randomUUID(), createdAt: now };
88
reviews.push(review);
89
}
90
writeReviews(reviews);
91
return { review, created: existingIndex < 0 };
92
}
93
94
export function getHinanaPediaWorks(search = '', sort = 'recent'): HinanaPediaWork[] {
95
const searchKey = normalizePediaTitleKey(search);
96
const groups = new Map<string, HinanaPediaReview[]>();
97
for (const review of readReviews()) {
98
if (searchKey && !review.titleKey.includes(searchKey)) continue;
99
const group = groups.get(review.titleKey) || [];
100
group.push(review);
101
groups.set(review.titleKey, group);
102
}
103
104
const works = [...groups.entries()].map(([key, reviews]) => {
105
reviews.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
106
const total = reviews.reduce((sum, review) => sum + review.rating, 0);
107
return {
108
key,
109
title: reviews[0].title,
110
averageRating: Math.round((total / reviews.length) * 100) / 100,
111
reviewCount: reviews.length,
112
latestAt: reviews[0].updatedAt,
113
reviews
114
};
115
});
116
117
if (sort === 'rating') return works.sort((a, b) => b.averageRating - a.averageRating || b.reviewCount - a.reviewCount);
118
if (sort === 'reviews') return works.sort((a, b) => b.reviewCount - a.reviewCount || b.averageRating - a.averageRating);
119
if (sort === 'title') return works.sort((a, b) => a.title.localeCompare(b.title, 'ko'));
120
return works.sort((a, b) => b.latestAt.localeCompare(a.latestAt));
121
}
122
123
export function getHinanaPediaStats(): { workCount: number; reviewCount: number; averageRating: number } {
124
const reviews = readReviews();
125
const workCount = new Set(reviews.map(review => review.titleKey)).size;
126
const total = reviews.reduce((sum, review) => sum + review.rating, 0);
127
return {
128
workCount,
129
reviewCount: reviews.length,
130
averageRating: reviews.length ? Math.round((total / reviews.length) * 100) / 100 : 0
131
};
132
}
133
134
export function deleteHinanaPediaReview(id: string): boolean {
135
const reviews = readReviews();
136
const next = reviews.filter(review => review.id !== id);
137
if (next.length === reviews.length) return false;
138
writeReviews(next);
139
return true;
140
}
141