feat: 공지 탭 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-10 19:58:43 +09:00
parent 1a6e9b6327
commit 68e9fc0a32
3 changed files with 273 additions and 1 deletions

View File

@@ -501,3 +501,105 @@ def delete_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db
cur.execute("DELETE FROM board_replies WHERE id = %s", (reply_id,)) cur.execute("DELETE FROM board_replies WHERE id = %s", (reply_id,))
conn.commit() conn.commit()
return {"ok": True} return {"ok": True}
# ─── 공지사항 ──────────────────────────────────────────────
class NoticeCreate(BaseModel):
title: str
content: str
class NoticeReplyCreate(BaseModel):
content: str
def init_notice_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS notice_posts (
id SERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notice_replies (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES notice_posts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_notice():
import time
for _ in range(10):
try:
init_notice_db()
print("Notice DB initialized")
break
except Exception as e:
print(f"Notice DB not ready... {e}")
time.sleep(3)
@app.get("/api/notice/posts")
def list_notice_posts(token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, author_name, created_at FROM notice_posts ORDER BY created_at DESC")
return cur.fetchall()
@app.post("/api/notice/posts")
def create_notice_post(data: NoticeCreate, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO notice_posts (title, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(data.title, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.get("/api/notice/posts/{post_id}")
def get_notice_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM notice_posts WHERE id = %s", (post_id,))
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
cur.execute("SELECT * FROM notice_replies WHERE post_id = %s ORDER BY created_at ASC", (post_id,))
replies = cur.fetchall()
return {"post": post, "replies": replies}
@app.delete("/api/notice/posts/{post_id}")
def delete_notice_post(post_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM notice_posts WHERE id = %s", (post_id,))
conn.commit()
return {"ok": True}
@app.post("/api/notice/posts/{post_id}/replies")
def create_notice_reply(post_id: int, data: NoticeReplyCreate, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO notice_replies (post_id, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(post_id, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.delete("/api/notice/replies/{reply_id}")
def delete_notice_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM notice_replies WHERE id = %s", (reply_id,))
reply = cur.fetchone()
if not reply:
raise HTTPException(status_code=404, detail="Reply not found")
if not token.get("is_admin") and reply["author_id"] != int(token["sub"]):
raise HTTPException(status_code=403, detail="Permission denied")
cur.execute("DELETE FROM notice_replies WHERE id = %s", (reply_id,))
conn.commit()
return {"ok": True}

View File

@@ -205,6 +205,7 @@
</header> </header>
<nav id="main-nav"> <nav id="main-nav">
<button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button> <button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button>
<button class="nav-btn" onclick="showPage('notice')">📢 공지</button>
<button class="nav-btn" onclick="showPage('board')">📋 관리자 요청</button> <button class="nav-btn" onclick="showPage('board')">📋 관리자 요청</button>
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button> <button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button> <button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
@@ -229,6 +230,59 @@
</div> </div>
</div> </div>
<!-- Notice List -->
<div id="page-notice" class="page">
<div class="board-header">
<div><h2>📢 공지사항</h2><p style="color:var(--muted);font-size:.875rem;margin-top:2px">관리자가 등록한 공지사항입니다.</p></div>
<button id="notice-write-btn" class="btn btn-sm" onclick="showNoticeWrite()" style="display:none">공지 작성</button>
</div>
<table class="board-table">
<thead><tr><th class="num">번호</th><th>제목</th><th class="author">작성자</th><th class="date">작성일</th></tr></thead>
<tbody id="notice-list-body"></tbody>
</table>
</div>
<!-- Notice Write -->
<div id="page-notice-write" class="page">
<div class="page-header"><div><h2>공지 작성</h2></div></div>
<div class="write-form">
<input type="text" id="notice-write-title" placeholder="제목을 입력하세요"/>
<textarea id="notice-write-content" placeholder="내용을 입력하세요"></textarea>
<div class="write-form-footer">
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">취소</button>
<button class="btn btn-sm" onclick="submitNotice()">등록</button>
</div>
</div>
</div>
<!-- Notice Detail -->
<div id="page-notice-detail" class="page">
<div class="page-header" style="margin-bottom:16px">
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">← 목록으로</button>
</div>
<div class="post-detail">
<div class="post-header">
<h2 id="notice-detail-title"></h2>
<div class="post-meta">
<span>✍️ <strong id="notice-detail-author"></strong></span>
<span>🕐 <span id="notice-detail-date"></span></span>
</div>
</div>
<div class="post-body" id="notice-detail-body"></div>
<div class="post-actions" id="notice-detail-actions"></div>
<div class="replies-section">
<div class="replies-title">💬 댓글</div>
<div id="notice-replies-list"></div>
<div class="reply-form">
<textarea id="notice-reply-input" placeholder="댓글을 입력하세요..."></textarea>
<div class="reply-form-footer">
<button class="btn btn-sm" onclick="submitNoticeReply()">댓글 등록</button>
</div>
</div>
</div>
</div>
</div>
<!-- Board List --> <!-- Board List -->
<div id="page-board" class="page"> <div id="page-board" class="page">
<div class="board-header"> <div class="board-header">
@@ -392,6 +446,7 @@ function showPage(name){
if(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active'); if(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active');
}); });
if(name==='my-pages')loadMyPages(); if(name==='my-pages')loadMyPages();
if(name==='notice')loadNotice();
if(name==='board')loadBoard(); if(name==='board')loadBoard();
if(name==='admin-pages')loadAdminPages(); if(name==='admin-pages')loadAdminPages();
if(name==='admin-users')loadAdminUsers(); if(name==='admin-users')loadAdminUsers();
@@ -521,6 +576,105 @@ function getFaviconUrl(url){
}catch{return '';} }catch{return '';}
} }
// ── Notice ───────────────────────────────────────────────
let currentNoticeId=null;
async function loadNotice(){
// 관리자면 작성 버튼 표시
const btn=document.getElementById('notice-write-btn');
if(btn)btn.style.display=currentUser&&currentUser.is_admin?'inline-block':'none';
const posts=await apiFetch('/notice/posts');
const tbody=document.getElementById('notice-list-body');
if(!posts||posts.length===0){
tbody.innerHTML='<tr><td colspan="4" style="text-align:center;padding:40px;color:var(--muted)">등록된 공지가 없습니다.</td></tr>';return;
}
tbody.innerHTML=posts.map((p,i)=>`
<tr onclick="openNotice(${p.id})">
<td class="num">${posts.length-i}</td>
<td class="title-cell">📢 ${escHtml(p.title)}</td>
<td class="author">${escHtml(p.author_name)}</td>
<td class="date">${fmtDate(p.created_at)}</td>
</tr>`).join('');
}
function showNoticeWrite(){
document.getElementById('notice-write-title').value='';
document.getElementById('notice-write-content').value='';
showPage('notice-write');
}
async function submitNotice(){
const title=document.getElementById('notice-write-title').value.trim();
const content=document.getElementById('notice-write-content').value.trim();
if(!title){alert('제목을 입력해주세요.');return;}
if(!content){alert('내용을 입력해주세요.');return;}
try{
await apiFetch('/notice/posts',{method:'POST',body:JSON.stringify({title,content})});
showPage('notice');
}catch(e){alert(e.message);}
}
async function openNotice(id){
currentNoticeId=id;
const data=await apiFetch('/notice/posts/'+id);
const p=data.post;
document.getElementById('notice-detail-title').textContent=p.title;
document.getElementById('notice-detail-author').textContent=p.author_name;
document.getElementById('notice-detail-date').textContent=fmtDate(p.created_at);
document.getElementById('notice-detail-body').textContent=p.content;
document.getElementById('notice-reply-input').value='';
const actions=document.getElementById('notice-detail-actions');
actions.innerHTML=currentUser&&currentUser.is_admin
?`<button class="btn btn-sm btn-danger" onclick="deleteNotice(${p.id})">공지 삭제</button>`:'';
renderNoticeReplies(data.replies);
showPage('notice-detail');
}
function renderNoticeReplies(replies){
const el=document.getElementById('notice-replies-list');
if(!replies||replies.length===0){el.innerHTML='<p style="color:var(--muted);font-size:.85rem;padding:8px 0">아직 댓글이 없습니다.</p>';return;}
el.innerHTML=replies.map(r=>{
const canDel=currentUser&&(currentUser.is_admin||(r.author_name===currentUser.username));
return `<div class="reply-item">
<div class="reply-meta">
<span class="reply-author">💬 ${escHtml(r.author_name)}</span>
<span style="display:flex;align-items:center;gap:8px">
<span class="reply-date">${fmtDate(r.created_at)}</span>
${canDel?`<button class="reply-del" onclick="deleteNoticeReply(${r.id})">삭제</button>`:''}
</span>
</div>
<div class="reply-content">${escHtml(r.content)}</div>
</div>`;
}).join('');
}
async function submitNoticeReply(){
const content=document.getElementById('notice-reply-input').value.trim();
if(!content){alert('댓글 내용을 입력해주세요.');return;}
try{
await apiFetch('/notice/posts/'+currentNoticeId+'/replies',{method:'POST',body:JSON.stringify({content})});
document.getElementById('notice-reply-input').value='';
const data=await apiFetch('/notice/posts/'+currentNoticeId);
renderNoticeReplies(data.replies);
}catch(e){alert(e.message);}
}
async function deleteNotice(id){
if(!confirm('공지를 삭제하시겠습니까?'))return;
try{await apiFetch('/notice/posts/'+id,{method:'DELETE'});showPage('notice');}
catch(e){alert(e.message);}
}
async function deleteNoticeReply(id){
if(!confirm('댓글을 삭제하시겠습니까?'))return;
try{
await apiFetch('/notice/replies/'+id,{method:'DELETE'});
const data=await apiFetch('/notice/posts/'+currentNoticeId);
renderNoticeReplies(data.replies);
}catch(e){alert(e.message);}
}
// ── Board ───────────────────────────────────────────────── // ── Board ─────────────────────────────────────────────────
async function loadBoard(){ async function loadBoard(){
const posts=await apiFetch('/board/posts'); const posts=await apiFetch('/board/posts');

View File

@@ -1,3 +1,6 @@
# IP당 로그인 요청 제한 (분당 5회)
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
@@ -7,6 +10,19 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# 로그인 API - rate limiting 적용
location /api/auth/login {
limit_req zone=login_limit burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/auth/login;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
location /api/ { location /api/ {
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/; proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;