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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 (이벤트 핸들러만) |
| `packages/bot/src/job-queue.ts` | pg-boss 싱글톤 (시작/종료/조회) |
| `packages/bot/src/scheduler-registry.ts` | 잡 등록 + RSS→Post→Notification 파이프라인 |
| `packages/bot/src/services/score.service.ts` | 활동 점수 계산/부여 |
| `packages/bot/src/services/score.service.ts` | 활동 점수 계산/부여 (봇: blog_post만) |
| `packages/web/src/lib/score.ts` | 웹 활동 점수 부여 (board_post, post_comment, board_comment, post_view) |
| `packages/web/src/lib/score-config.ts` | 활동 점수 타입별 메타데이터 (Single Source of Truth: 라벨, 이모지, 배점, 뱃지 컬러) |
| `packages/web/src/app/(user)/profile/activity/page.tsx` | 활동 내역 페이지 (타입별 필터, 무한 로드) |
| `packages/web/src/lib/board-auth.ts` | 게시판 인증 헬퍼 (`getBoardAuth`) |
| `packages/web/src/lib/board-config.ts` | 게시판 카테고리/뱃지 설정 |
| `packages/web/src/lib/api-error.ts` | API 표준 응답/에러 헬퍼 (`successResponse`, `Errors`, `withCache`) |
Expand Down
2 changes: 1 addition & 1 deletion docs/26-03-06-schema-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
| `FineType` | `late`, `absent` | 벌금 사유 |
| `FineStatus` | `unpaid`, `paid`, `waived` | 벌금 납부 |
| `CurationCategory` | `conference`, `article` | 큐레이션 분류 |
| `ActivityScoreType` | `blog_post`, `discord_message`, `discord_thread`, `discord_reaction`, `admin_manual`, `post_view` | 활동 점수 |
| `ActivityScoreType` | `blog_post`, `board_post`, `post_comment`, `board_comment`, `admin_manual`, `post_view` | 활동 점수 |
| `BoardCategory` | `notice`, `suggestion`, `review`, `knowledge`, `daily`, `etc` | 게시판 카테고리 (const object) |

## 테이블
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Blog Study Admin - 시스템 아키텍처

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

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

Expand Down Expand Up @@ -205,7 +205,7 @@ flowchart TD
C -->|No| A
C -->|Yes| D["posts 테이블 저장"]
D --> E["출석 상태 업데이트"]
E --> F["활동 점수 부여"]
E --> F["활동 점수 부여 (봇: blog_post)"]
F --> G["Discord 채널 알림"]
```

Expand Down
17 changes: 9 additions & 8 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,18 @@ pnpm --filter @blog-study/shared build

봇이 Discord 서버에서 실시간으로 감지하는 이벤트들입니다.

**Activity Handler** (`handlers/activity-handler.ts`):
Discord 활동을 감지해서 자동으로 점수를 부여합니다.
**웹 활동 점수** (`web/src/lib/score.ts`):
웹에서 활동 시 자동으로 점수를 부여합니다. (v2: 디스코드 → 웹 활동으로 전환, 2026-03-16)

| 이벤트 | 조건 | 점수 |
|--------|------|------|
| `MessageCreate` (채널) | 10자 이상, 봇 아님, 스터디원 | +2 (DISCORD_MESSAGE) |
| `MessageCreate` (스레드) | 10자 이상, 봇 아님, 스터디원 | +3 (DISCORD_THREAD) |
| `MessageReactionAdd` | 봇 아님, 자기 글 아님, 스터디원 | +1 (DISCORD_REACTION) |
| 활동 | 조건 | 점수 | 일일 상한 |
|------|------|------|----------|
| 게시판 글 작성 | 인증된 사용자 | +10 (BOARD_POST) | 20 |
| 블로그 글 댓글 | 인증된 사용자 | +5 (POST_COMMENT) | 20 |
| 게시판 댓글 | 인증된 사용자 | +2 (BOARD_COMMENT) | 10 |
| 글 조회 (타인 글) | 중복 불가, 본인 글 제외 | +3 (POST_VIEW) | 15 |

- Discord ID → `members.discord_id` 매칭으로 스터디원 여부 확인
- 일일 상한 초과 시 점수 미부여 (원자적 CTE 쿼리로 race condition 방지)
- 점수 부여 실패해도 본 기능(글/댓글 작성)에는 영향 없음 (fire-and-forget)

**DM Handler** (`handlers/dm-handler.ts`):
벌금 납부 확인을 위한 DM 대화 처리입니다.
Expand Down
69 changes: 43 additions & 26 deletions docs/plans/26-02-26-activity-scoring-design.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,55 @@
# 디스코드 활동 점수 시스템 디자인
# 활동 점수 시스템 디자인

## 목표
스터디원 간 경쟁심리 자극 + 스터디 종료 시 Top 3 보상 기반. 블로그 포스팅이 핵심, 디스코드 활동이 보조.
스터디원 간 경쟁심리 자극 + 스터디 종료 시 Top 3 보상 기반. 블로그 포스팅이 핵심, 활동이 보조.

## 점수 체계

| 카테고리 | 활동 | 점수 | 일일 상한 |
|---------|------|------|----------|
| 블로그 | 포스트 발행 | +30점 | 60점 (2편) |
| 디스코드 | 메시지 작성 (10자+) | +2점 | 10점 (5개) |
| 디스코드 | 스레드 댓글 | +3점 | 9점 (3개) |
| 디스코드 | 리액션 달기 | +1점 | 5점 (5개) |
| 관리자 | 수동 부여/차감 | 자유 | 없음 |
| 카테고리 | 활동 | 점수 | 일일 상한 | 부여 위치 |
|---------|------|------|----------|----------|
| 블로그 | 포스트 발행 (RSS 수집) | +30점 | 60점 (2편) | 봇 |
| 웹 | 게시판 글 작성 | +10점 | 20점 (2편) | 웹 API |
| 웹 | 블로그 글 댓글 작성 | +5점 | 20점 (4개) | 웹 API |
| 웹 | 게시판 댓글 작성 | +2점 | 10점 (5개) | 웹 API |
| 웹 | 글 조회 (타인 글) | +3점 | 15점 (5개) | 웹 API |
| 관리자 | 수동 부여/차감 | 자유 | 없음 | 웹 API |

디스코드 일일 최대: 24점. 블로그 1편(30점) > 디스코드 1일분(24점).
일일 최대: 65점. 블로그 1편(30점)이 여전히 핵심.

## 어뷰징 방지
- 봇 메시지/자기 리액션 무시
- 메시지 최소 10자
- 일일 상한으로 도배 방지
## 변경 이력

### v2 (2026-03-16): 디스코드 → 웹 활동으로 전환
- **제거**: `discord_message` (+2), `discord_thread` (+3), `discord_reaction` (+1) — 봇 이벤트 핸들러 기반
- **추가**: `board_post` (+10), `post_comment` (+5), `board_comment` (+2) — 웹 API에서 직접 DB write
- **변경**: `post_view` 2→3점, 상한 10→15점
- **이유**: 디스코드 활동 추적은 MessageContent Intent 필요 + DB 반영 지연. 웹에서 직접 점수 부여하면 즉시 반영 + 구현 단순화

### v1 (2026-02-26): 최초 설계
- 디스코드 활동(메시지/스레드/리액션) 기반 점수 체계

## DB 스키마: activity_scores 테이블
- id, memberId, type (blog_post/discord_message/discord_thread/discord_reaction/admin_manual)
- id, memberId, type (`blog_post`/`board_post`/`post_comment`/`board_comment`/`post_view`/`admin_manual`)
- points, description, date, createdAt
- memberId+type+date에 대한 일일 상한 체크용 인덱스

## 기능
1. **봇**: 디스코드 이벤트 감지 → 점수 자동 적립 (일일 상한 체크)
2. **봇**: 블로그 포스트 RSS 수집 시 → 점수 자동 적립
3. **웹 관리자**: 수동 점수 부여/차감 + 사유 입력
4. **웹 사용자/관리자**: 점수 내역 조회
5. **랭킹**: 기존 랭킹에 총점 반영

## 구현 범위
- shared: DB 스키마 + 마이그레이션
- bot: intent 추가 + 이벤트 핸들러 + RSS 콜백 연동
- web: 관리자 점수 부여 UI + API, 점수 내역 조회 UI + API, 랭킹 총점 반영
## 어뷰징 방지
- 본인 글 조회 점수 미부여
- 같은 글 중복 조회 불가 (post_views UNIQUE)
- 일일 상한으로 도배 방지

## 점수 부여 위치
1. **봇**: 블로그 포스트 RSS 수집 시 → `blog_post` 점수 자동 적립
2. **웹 API**: 게시판 글 작성 시 → `board_post` 점수 부여 (`/api/board` POST)
3. **웹 API**: 블로그 글 댓글 작성 시 → `post_comment` 점수 부여 (`/api/posts/[id]/comments` POST)
4. **웹 API**: 게시판 댓글 작성 시 → `board_comment` 점수 부여 (`/api/board/[id]/comments` POST)
5. **웹 API**: 글 조회 시 → `post_view` 점수 부여 (`/api/posts/[id]/view` POST)
6. **웹 관리자**: 수동 점수 부여/차감 + 사유 입력

## 구현 파일
- `packages/shared/src/db/schema.ts` — ActivityScoreType enum
- `packages/bot/src/services/score.service.ts` — SCORE_CONFIG (봇용)
- `packages/web/src/lib/score.ts` — grantWebScore (웹용)
- `packages/web/src/app/api/board/route.ts` — 게시판 글 점수
- `packages/web/src/app/api/posts/[id]/comments/route.ts` — 블로그 댓글 점수
- `packages/web/src/app/api/board/[id]/comments/route.ts` — 게시판 댓글 점수
- `packages/web/src/app/api/posts/[id]/view/route.ts` — 글 조회 점수
4 changes: 0 additions & 4 deletions packages/bot/src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ export function createBotClient(): Client {
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.DirectMessages,
// MessageContent Intent 없이 버튼/인터랙션 방식으로 동작
// 봇이 100개 미만 서버라 Intent 활성화 불가능
// GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessageReactions,
],
});

Expand Down
72 changes: 0 additions & 72 deletions packages/bot/src/handlers/activity-handler.ts

This file was deleted.

47 changes: 0 additions & 47 deletions packages/bot/src/handlers/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Client, Events } from 'discord.js';
import { setupActivityHandler } from './activity-handler';
import { setupDMHandler } from './dm-handler';

// Mock dependencies
Expand All @@ -14,13 +13,6 @@ vi.mock('@blog-study/shared/db', () => ({
fines: {},
}));

vi.mock('../services/score.service', () => ({
getScoreService: vi.fn(() => ({
getMemberIdByDiscordId: vi.fn(() => Promise.resolve('member-123')),
grantScore: vi.fn(() => Promise.resolve()),
})),
}));

vi.mock('../services', () => ({
getFineService: vi.fn(() => ({
markPaid: vi.fn(() => Promise.resolve()),
Expand All @@ -29,45 +21,6 @@ vi.mock('../services', () => ({
}));

describe('Handlers Integration Tests', () => {
describe('setupActivityHandler', () => {
let mockClient: Client;
let onSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
onSpy = vi.fn();
mockClient = {
on: onSpy,
} as unknown as Client;
});

afterEach(() => {
vi.clearAllMocks();
});

describe('Property 1: 이벤트 리스너 등록', () => {
it('should register MessageCreate event listener', () => {
setupActivityHandler(mockClient);
expect(onSpy).toHaveBeenCalledWith(Events.MessageCreate, expect.any(Function));
});

it('should register MessageReactionAdd event listener', () => {
setupActivityHandler(mockClient);
expect(onSpy).toHaveBeenCalledWith(Events.MessageReactionAdd, expect.any(Function));
});

it('should register exactly 2 event listeners', () => {
setupActivityHandler(mockClient);
expect(onSpy).toHaveBeenCalledTimes(2);
});
});

describe('Property 2: 핸들러 동작 확인', () => {
it('should call handler function without throwing', () => {
expect(() => setupActivityHandler(mockClient)).not.toThrow();
});
});
});

describe('setupDMHandler', () => {
let mockClient: Client;
let onSpy: ReturnType<typeof vi.fn>;
Expand Down
3 changes: 1 addition & 2 deletions packages/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { loadBotEnv } from '@blog-study/shared';
import { createBotClient, setupEventHandlers, setupGracefulShutdown, startBot } from './bot';
import { startJobQueue, stopJobQueue } from './job-queue';
import { registerAllJobs } from './scheduler-registry';
import { setupActivityHandler } from './handlers/activity-handler';

import { setupDMHandler } from './handlers/dm-handler';
import { initNotificationService } from './services/notification.service';
import { startBotApiServer } from './api-server';
Expand All @@ -28,7 +28,6 @@ async function main(): Promise<void> {

// Setup event handlers
setupEventHandlers(client);
setupActivityHandler(client);
setupDMHandler(client);
logger.debug('Event handlers configured');

Expand Down
16 changes: 7 additions & 9 deletions packages/bot/src/schedulers/weekly-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,18 @@ async function getMemberRankings(): Promise<MemberRanking[]> {
.groupBy(members.id);

// Get activity scores for each member
// P0 #5: 이번 주 기간 점수만 필터링
// 이번 주 기간 점수만 필터링 (date 컬럼 사용 — KST 날짜 문자열)
const weekDates = getWeekDates();
const weekStartDate = weekDates.startDate + 'T00:00:00.000+09:00';
const weekEndDate = weekDates.endDate + 'T23:59:59.999+09:00';

const scoreStats = await db
.select({
memberId: activityScores.memberId,
totalScore: sql<number>`COALESCE(SUM(${activityScores.points}), 0)`,
discordScore: sql<number>`COALESCE(SUM(CASE WHEN ${activityScores.type} IN (${ActivityScoreType.DISCORD_MESSAGE}, ${ActivityScoreType.DISCORD_THREAD}, ${ActivityScoreType.DISCORD_REACTION}) THEN ${activityScores.points} ELSE 0 END), 0)`,
discordScore: sql<number>`COALESCE(SUM(CASE WHEN ${activityScores.type} IN (${ActivityScoreType.BOARD_POST}, ${ActivityScoreType.POST_COMMENT}, ${ActivityScoreType.BOARD_COMMENT}, ${ActivityScoreType.POST_VIEW}) THEN ${activityScores.points} ELSE 0 END), 0)`,
})
.from(activityScores)
.where(
sql`${activityScores.createdAt} >= ${weekStartDate} AND ${activityScores.createdAt} <= ${weekEndDate}`
sql`${activityScores.date} >= ${weekDates.startDate} AND ${activityScores.date} <= ${weekDates.endDate}`
)
.groupBy(activityScores.memberId);

Expand Down Expand Up @@ -174,8 +172,8 @@ function createRankingEmbed(rankings: MemberRanking[]): EmbedBuilder {
const r = top3[i]!;
const medal = medals[i] ?? '🏅';
const name = r.nickname || r.name;
const discordScore = r.discordScore > 0 ? ` | 디스코드 ${r.discordScore}점` : '';
podiumText += `${medal} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${discordScore})\n`;
const webActivity = r.discordScore > 0 ? ` | 활동 ${r.discordScore}점` : '';
podiumText += `${medal} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${webActivity})\n`;
}

embed.addFields({
Expand All @@ -192,8 +190,8 @@ function createRankingEmbed(rankings: MemberRanking[]): EmbedBuilder {
for (const r of displayRankings) {
const name = r.nickname || r.name;
const rankDisplay = r.rank <= 3 ? ['🥇', '🥈', '🥉'][r.rank - 1] ?? `${r.rank}위` : `${r.rank}위`;
const discordScore = r.discordScore > 0 ? ` | 디스코드 ${r.discordScore}점` : '';
rankingText += `${rankDisplay} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${discordScore})\n`;
const webActivity = r.discordScore > 0 ? ` | 활동 ${r.discordScore}점` : '';
rankingText += `${rankDisplay} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${webActivity})\n`;
}

if (rankings.length > 15) {
Expand Down
2 changes: 1 addition & 1 deletion packages/bot/src/services/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export function buildRoundStartEmbed(round: Round, activeCount: number): EmbedBu
.setTitle(`🚀 ${round.roundNumber}회차 시작!`)
.setDescription([
`이번 회차에 **${activeCount}명**이 함께합니다.`,
'이번 회차도 화이팅! 좋은 글 기대하고 있을게요 🔥',
'이번 회차도 화이팅! 짧은 글이라도 괜찮아요 😌',
].join('\n'))
.addFields(
{
Expand Down
10 changes: 5 additions & 5 deletions packages/bot/src/services/score.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ export const SCORE_CONFIG: Record<
{ points: number; dailyCap: number }
> = {
[ActivityScoreType.BLOG_POST]: { points: 30, dailyCap: 60 },
[ActivityScoreType.DISCORD_MESSAGE]: { points: 2, dailyCap: 10 },
[ActivityScoreType.DISCORD_THREAD]: { points: 3, dailyCap: 9 },
[ActivityScoreType.DISCORD_REACTION]: { points: 1, dailyCap: 5 },
[ActivityScoreType.ADMIN_MANUAL]: { points: 0, dailyCap: Infinity },
[ActivityScoreType.POST_VIEW]: { points: 2, dailyCap: 10 },
[ActivityScoreType.BOARD_POST]: { points: 10, dailyCap: 20 },
[ActivityScoreType.POST_COMMENT]: { points: 5, dailyCap: 20 },
[ActivityScoreType.BOARD_COMMENT]: { points: 2, dailyCap: 10 },
[ActivityScoreType.ADMIN_MANUAL]: { points: 0, dailyCap: Infinity }, // points는 무시됨 — 실제 값은 호출자가 지정
[ActivityScoreType.POST_VIEW]: { points: 3, dailyCap: 15 },
};

function getTodayDateString(): string {
Expand Down
Loading
Loading