Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456
SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456
# SENTRY_AUTH_TOKEN=sntrys_xxx (CI/Vercel 환경변수로만 설정)

# Bot API Authentication (shared secret between web and bot)
BOT_API_SECRET=your_bot_api_secret_here
BOT_API_URL=http://localhost:3001

# Application
APP_URL=http://localhost:3000
NODE_ENV=development
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,19 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/web/src/app/(admin)/error.tsx` | 관리자 에러 바운더리 |
| `packages/web/src/components/ui/member-avatar.tsx` | 재사용 아바타 컴포넌트 (링크+관리자뱃지) |
| `packages/web/src/components/board/tiptap-editor.tsx` | Tiptap 리치 에디터 (H1-H3, 구분선, 코드블록, 링크, 한글 IME 대응) |
| `packages/web/src/components/layout/bottom-nav.tsx` | 모바일 하단 탭 바 (사용자: 랭킹/포스트/홈/게시판/스터디원, 관리자 모드 미표시) |
| `packages/web/src/components/layout/bottom-nav.tsx` | 모바일 하단 탭 바 (사용자: 랭킹/포스트/홈/게시판/스터디원, 관리자: 멤버/회차/출석/벌금/점수/봇) |
| `packages/web/src/components/layout/notice-banner.tsx` | 글로벌 공지 배너 (제목+내용 미리보기, 접기/닫기) |
| `packages/web/src/components/layout/pull-to-refresh.tsx` | Pull-to-Refresh 컴포넌트 (PWA 터치 제스처) |
| `packages/web/src/hooks/use-pull-to-refresh.ts` | Pull-to-Refresh 훅 (`window.location.reload()` 기반) |
| `packages/web/src/app/api/notice-banner/route.ts` | 활성 공지 배너 조회 API |
| `packages/web/src/app/(admin)/admin/bot-operations/page.tsx` | 봇 수동 실행 대시보드 (관리자 전용) |
| `packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts` | 봇 작업 트리거 프록시 (web → bot HTTP API, 30s 타임아웃) |
| `packages/web/src/app/(admin)/admin/rounds/page.tsx` | 회차 관리 페이지 (CRUD + 현재 회차 설정) |
| `packages/web/src/app/api/profile/withdraw/route.ts` | 유저 자체 탈퇴 API |
| `packages/bot/src/scripts/rss-collect.ts` | 수동 RSS 수집 스크립트 (봇 없이 독립 실행) |
| `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 |
| `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 |
| `packages/bot/src/api-server.ts` | 봇 HTTP API 서버 (Express, 수동 트리거 엔드포인트, rate limit 10/min) |
| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation 채널) |
| `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) |
| `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) |
Expand Down Expand Up @@ -146,7 +149,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **폰트**: Pretendard Variable
- **기본 아바타**: DiceBear `fun-emoji` 스타일 (`getDefaultAvatar()` in `utils.ts`)
- **아바타 리소스**: [DiceBear](https://www.dicebear.com/styles/) - 30+ 스타일, seed 기반 결정적 아바타 생성, API: `https://api.dicebear.com/9.x/{style}/svg?seed={seed}`
- **레이아웃**: 데스크톱 사이드바 + 모바일 하단 탭 바 (사용자 전용, 관리자 모드 미표시)
- **레이아웃**: 데스크톱 사이드바 + 모바일 하단 탭 바 (사용자/관리자 모드별 탭 자동 전환)
- **사이드바**: 모드 전환은 헤더 프로필 드롭다운에서만 가능 (사이드바에 토글 없음)
- **큐레이션**: 현재 네비게이션에서 숨김 (TODO: 나중에 활성화 예정), 페이지/로직은 유지
- **포스트**: 최신순/인기순 탭, 무한 스크롤, 썸네일(OG 이미지)+그라디언트 폴백, 인기순 상위 3개 금/은/동 메달
Expand Down
42 changes: 42 additions & 0 deletions docs/26-03-06-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,45 @@ import { MemberAvatar } from '@/components/ui/member-avatar';
| `showName` | boolean | 이름 표시 + 링크 포함 |
| `noLink` | boolean | 링크 비활성화 |
| `isAdmin` | boolean | 관리자 뱃지 표시 |

## 봇 작업 프록시 패턴

웹 관리자 대시보드에서 봇 스케줄러를 수동 트리거하는 프록시 API:

```
[Web Admin UI] → POST /api/admin/bot-operations/{operationId}
→ withAdminAuth (관리자 인증)
→ fetch(BOT_API_URL + endpoint, { signal: AbortController(30s) })
→ [Bot Express API :3001] → /api/trigger/{operationId}
→ rate limit (10/min) → isRunning guard → 작업 실행
```

### 봇 API 서버 (`api-server.ts`)

```ts
// Express 서버 (포트 3001), 각 스케줄러에 대한 POST 트리거 엔드포인트
// rate limit: 10 requests/min per endpoint
// 각 핸들러는 isRunning/isSending 가드로 중복 실행 방지
// 응답: { success, message, data? } 또는 409 (이미 실행 중)
```

### 웹 프록시 라우트 (`[operationId]/route.ts`)

```ts
// OPERATION_ENDPOINT_MAP으로 operationId → bot endpoint 매핑
// AbortController 30초 타임아웃
// 에러 분류: AbortError(타임아웃), ECONNREFUSED(봇 미실행), 409(중복 실행), 기타
```

### 지원 작업 목록

| operationId | 설명 | 스케줄 |
|-------------|------|--------|
| `rss-poll` | RSS 피드 수집 | 매 30분 |
| `attendance-check` | 출석 체크 | 회차 종료 후 |
| `fine-reminder` | 미납 벌금 알림 | 매일 |
| `round-report` | 회차 리포트 | 회차 종료 후 |
| `round-start` | 회차 시작 알림 | 회차 시작일 |
| `curation-crawl` | 큐레이션 크롤링 | 매일 09:00 |
| `curation-share` | 큐레이션 공유 | 매일 10:05 |
| `weekly-ranking` | 주간 랭킹 | 매주 일요일 22:00 |
11 changes: 8 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Blog Study Admin - 시스템 아키텍처

> 최종 업데이트: 2026-03-13 (v6)
> 최종 업데이트: 2026-03-13 (v7)

블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다.

Expand Down Expand Up @@ -35,6 +35,7 @@ graph TB
SCH["Schedulers<br/>pg-boss"]
EVT["Event Handlers<br/>discord.js v14"]
SVC["Service Layer<br/>RSS · Fine · Curation · Score"]
BOT_API["Express API :3001<br/>수동 트리거 엔드포인트<br/>rate limit 10/min"]
SENTRY_BOT["Sentry SDK<br/>에러 모니터링 + PII 스크러빙"]
end

Expand Down Expand Up @@ -65,6 +66,8 @@ graph TB
SCH --> SVC
SVC --> DB
Bot -->|service_role key| TABLES
API -->|POST /api/trigger/*| BOT_API
BOT_API --> SCH

Web -->|HTTPS| DB
MW --> AUTH
Expand Down Expand Up @@ -243,6 +246,7 @@ flowchart TD
| Admin | `/admin/fines` | 벌금 관리 | 관리자 전용 |
| Admin | `/admin/scores` | 점수 관리 | 관리자 전용 |
| Admin | `/admin/curation` | 큐레이션 소스 관리 (현재 숨김) | 관리자 전용 |
| Admin | `/admin/bot-operations` | 봇 수동 실행 | 관리자 전용 |
| Admin | `/admin/settings` | 설정 | 관리자 전용 |

### 미들웨어 보호 로직
Expand Down Expand Up @@ -398,7 +402,7 @@ erDiagram
|--------|-----------|---------|
| Desktop (md+) | 좌측 고정 사이드바 (접기/펼치기) | `Sidebar` |
| Mobile 사용자 (<md) | 하단 고정 탭 바 (5개: 랭킹/포스트/홈/게시판/스터디원) | `BottomNav` |
| Mobile 관리자 (<md) | 하단 고정 탭 바 (5개: 멤버/회차/출석/벌금/점수) | `BottomNav` |
| Mobile 관리자 (<md) | 하단 고정 탭 바 (6개: 멤버/회차/출석/벌금/점수/봇) | `BottomNav` |

- **Header**: 로고(커스텀 SVG 픽토그램, 사용자→`/dashboard`, 관리자→`/admin`) + 다크모드 토글 + 프로필 드롭다운 (사용자↔관리자 전환)
- **랜딩 페이지**: Linear 스타일 다크 모드 원페이지 (7섹션: Nav/Hero/Stats/Bento/HowItWorks/Marquee/CTA). 큐시즘 블루 그라디언트 (`#0091FF→#004DFF`), Framer Motion 애니메이션, DB 스탯 ISR 60s
Expand All @@ -413,11 +417,12 @@ erDiagram
|------|------|------|
| RSS Poller | 5분 | active 멤버 RSS 피드 수집 |
| Attendance Checker | 매주 화 00:00 | 지각/결석 판정 |
| Fine Reminder | 매일 10:00 | 미납 벌금 DM 리마인드 |
| Fine Reminder | 매일 10:00 | 미납 벌금 DM 리마인드 (1일 간격) |
| Curation Crawler | 매일 09:00 | 외부 컨텐츠 크롤링 |
| Daily Content | 매일 10:00 | 큐레이션 컨텐츠 공유 |
| Round Reporter | 회차 종료 시 | 회차 리포트 자동 생성 → #공지사항 |
| Round Start | 매주 월 00:00 | 회차 시작 안내 + active 멤버 멘션 → #공지사항 |
| Weekly Ranking | 매주 일 22:00 | 주간 활동 점수 랭킹 → #주간-랭킹 |

## 보안

Expand Down
80 changes: 45 additions & 35 deletions packages/bot/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Express server for manual trigger endpoints from web dashboard
*/

import express, { type Express } from 'express';
import express, { type Express, type Request, type Response, type NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import logger from './lib/logger';
import { Sentry } from './lib/sentry';
Expand All @@ -16,11 +16,32 @@ import {
getWeeklyRanking,
} from './schedulers';

const BOT_API_SECRET = process.env.BOT_API_SECRET;

/**
* Bearer token authentication middleware for trigger endpoints
*/
function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// 시크릿 미설정 시 인증 스킵 (로컬 개발용)
if (!BOT_API_SECRET) {
next();
return;
}

const token = req.headers.authorization?.replace('Bearer ', '');
if (token !== BOT_API_SECRET) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

next();
}

export function createBotApiServer(): Express {
const app = express();

// Middleware
app.use(express.json());
app.use(express.json({ limit: '10kb' }));

// Rate limiting for trigger endpoints (10 requests per minute)
const triggerLimiter = rateLimit({
Expand All @@ -36,8 +57,8 @@ export function createBotApiServer(): Express {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Operation trigger endpoints (with rate limiting)
app.post('/api/trigger/rss-poll', triggerLimiter, async (_req, res) => {
// Operation trigger endpoints (auth + rate limiting)
app.post('/api/trigger/rss-poll', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const rssPoller = getRssPoller();

Expand All @@ -50,13 +71,11 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] RSS poll error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/attendance-check', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/attendance-check', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const attendanceChecker = getAttendanceChecker();

Expand All @@ -69,13 +88,11 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Attendance check error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/fine-reminder', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/fine-reminder', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const fineReminder = getFineReminder();

Expand All @@ -88,13 +105,11 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Fine reminder error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/round-report', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/round-report', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const roundReporter = getRoundReporter();

Expand All @@ -107,27 +122,28 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Round report error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/round-start', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/round-start', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const roundReporter = getRoundReporter();

if (roundReporter.isReporting()) {
return res.status(409).json({ error: '회차 작업이 이미 실행 중입니다' });
}

const result = await roundReporter.sendRoundStartAnnouncement();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Round start error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/curation-crawl', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/curation-crawl', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const curationCrawler = getCurationCrawler();

Expand All @@ -140,13 +156,11 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Curation crawl error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/curation-share', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/curation-share', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const curationCrawler = getCurationCrawler();

Expand All @@ -159,13 +173,11 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Curation share error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/weekly-ranking', triggerLimiter, async (_req, res) => {
app.post('/api/trigger/weekly-ranking', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const weeklyRanking = getWeeklyRanking();

Expand All @@ -187,9 +199,7 @@ export function createBotApiServer(): Express {
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Weekly ranking error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

Expand Down
2 changes: 1 addition & 1 deletion packages/bot/src/scheduler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const JOB_DEFINITIONS = [
{ name: 'round-report', cron: '5 0 * * 2' },
{ name: 'round-start', cron: '0 0 * * 1' },
{ name: 'curation-crawl', cron: '0 23 * * *' },
{ name: 'curation-share', cron: '0 10 * * *' },
{ name: 'curation-share', cron: '5 10 * * *' },
{ name: 'weekly-ranking', cron: '0 13 * * 0' }, // 매주 일요일 22:00 KST
] as const;

Expand Down
Loading
Loading