Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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