Skip to content

Commit 967966e

Browse files
bbbang105claude
andauthored
refactor: 출석 스케줄러 분리 + 리포트/대시보드 개선 (#76)
* refactor: 출석 스케줄러 분리 + 리포트 이전 회차 기준 + 대시보드 문구 조건부 표시 - attendance-init (월 00:02): 회차 전환 + active 멤버 PENDING 생성 - attendance-absent (화 00:02): 이전 회차 결석 처리 + 벌금 부과 - round-start/round-report는 Discord 알림만 담당 (DB 로직 분리) - round-report: 이전 회차 기준으로 발송, 지각/결석 명단 제거 - 대시보드 마감 압박 문구: active + 미제출 유저만 표시 Co-Authored-By: Claude <noreply@anthropic.com> * fix: 리뷰 반영 — 수동 트리거 이전 회차 기준 + grace period 가드 + dead fields 정리 Co-Authored-By: Claude <noreply@anthropic.com> * fix: property test에서 제거된 late/absent 필드 참조 수정 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent daff8dc commit 967966e

8 files changed

Lines changed: 141 additions & 141 deletions

File tree

packages/bot/src/api-server.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import rateLimit from 'express-rate-limit';
88
import logger from './lib/logger';
99
import { Sentry } from './lib/sentry';
1010
import {
11-
getAttendanceChecker,
1211
getCurationCrawler,
1312
getDeadlineReminder,
1413
getFineReminder,
@@ -17,6 +16,10 @@ import {
1716
getRssPoller,
1817
getWeeklyRanking,
1918
} from './schedulers';
19+
import { getAttendanceService } from './services/attendance.service';
20+
import { getFineService } from './services';
21+
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded } from './services/round.service';
22+
import { AttendanceStatus } from '@blog-study/shared/db';
2023

2124
const BOT_API_SECRET = process.env.BOT_API_SECRET;
2225

@@ -79,14 +82,34 @@ export function createBotApiServer(): Express {
7982

8083
app.post('/api/trigger/attendance-check', authMiddleware, triggerLimiter, async (_req, res) => {
8184
try {
82-
const attendanceChecker = getAttendanceChecker();
85+
const currentRound = await getCurrentRound();
86+
const prevRound = await getRoundByNumber(currentRound.roundNumber - 1);
8387

84-
if (attendanceChecker.isChecking()) {
85-
return res.status(409).json({ error: '출석 체크가 이미 실행 중입니다' });
88+
if (!prevRound) {
89+
return res.status(400).json({ error: '이전 회차가 없습니다' });
8690
}
8791

88-
const result = await attendanceChecker.check();
89-
res.json({ success: true, result });
92+
if (!isGracePeriodEnded(prevRound)) {
93+
return res.status(400).json({ error: '이전 회차 유예 기간이 아직 종료되지 않았습니다' });
94+
}
95+
96+
const attendanceService = getAttendanceService();
97+
const fineService = getFineService();
98+
99+
// 이전 회차 PENDING → ABSENT 처리
100+
const processedRecords = await attendanceService.processGracePeriodEnd(prevRound.id);
101+
const absentRecords = processedRecords.filter(r => r.status === AttendanceStatus.ABSENT);
102+
103+
// 결석 벌금 부과
104+
for (const record of absentRecords) {
105+
try {
106+
await fineService.create(record.memberId, prevRound.id, 'absent');
107+
} catch (fineError) {
108+
logger.error({ memberId: record.memberId, error: fineError }, '🌐 [API] 결석 벌금 부과 실패');
109+
}
110+
}
111+
112+
res.json({ success: true, result: { roundNumber: prevRound.roundNumber, processedCount: absentRecords.length } });
90113
} catch (error) {
91114
Sentry.captureException(error);
92115
logger.error({ error }, '🌐 [API] 출석 체크 에러');

packages/bot/src/api/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import type {
77
PollingCycleResult,
8-
AttendanceCheckResult,
98
FineReminderResult,
109
RoundReportResult,
1110
CurationCycleResult,
@@ -44,7 +43,6 @@ export interface OperationInfo {
4443
*/
4544
export type OperationResult =
4645
| PollingCycleResult
47-
| AttendanceCheckResult
4846
| FineReminderResult
4947
| RoundReportResult
5048
| CurationCycleResult

packages/bot/src/scheduler-registry.ts

Lines changed: 67 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type { Client } from 'discord.js';
88
import axios from 'axios';
99
import { parseFeed } from 'feedsmith';
1010
import { getRssPoller } from './schedulers/rss-poller';
11-
import { getAttendanceChecker } from './schedulers/attendance-checker';
1211
import { getFineReminder } from './schedulers/fine-reminder';
1312
import { getRoundReporter } from './schedulers/round-reporter';
1413
import { getCurationCrawler } from './schedulers/curation-crawler';
@@ -20,20 +19,21 @@ import { getNotificationService } from './services/notification.service';
2019
import { getScoreService } from './services/score.service';
2120
import { getAttendanceService, getFineService } from './services';
2221

23-
import { ActivityScoreType, curationSources, getDb, members } from '@blog-study/shared/db';
24-
import { extractFirstImage, extractOgImage } from '@blog-study/shared/utils';
25-
import { getCurrentRound } from './services/round.service';
22+
import { ActivityScoreType, AttendanceStatus, curationSources, getDb } from '@blog-study/shared/db';
23+
import { extractFirstImage, extractOgImage, formatKSTDate } from '@blog-study/shared/utils';
24+
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded, setCurrentRound } from './services/round.service';
25+
import { Sentry } from './lib/sentry';
2626
import { eq } from 'drizzle-orm';
2727
import logger from './lib/logger';
28-
import { Sentry } from './lib/sentry';
2928

3029
/**
3130
* Job definitions with cron schedules
3231
*/
3332
// pg-boss cron은 UTC 기준. KST = UTC+9
3433
const JOB_DEFINITIONS = [
3534
{ name: 'rss-poll', cron: '*/5 * * * *' }, // 5분마다
36-
{ name: 'attendance-check', cron: '0 0 * * 2' }, // KST 화 09:00 (UTC 화 00:00)
35+
{ name: 'attendance-init', cron: '2 15 * * 0' }, // KST 월 00:02 (UTC 일 15:02) — 회차 시작일 출석 PENDING 생성
36+
{ name: 'attendance-absent', cron: '2 15 * * 1' }, // KST 화 00:02 (UTC 월 15:02) — PENDING → ABSENT + 벌금
3737
{ name: 'fine-reminder', cron: '0 0 * * *' }, // KST 매일 09:00 (UTC 00:00)
3838
{ name: 'round-report', cron: '0 23 * * 1' }, // KST 화 08:00 (UTC 월 23:00)
3939
{ name: 'round-start', cron: '0 23 * * 0' }, // KST 월 08:00 (UTC 일 23:00)
@@ -51,7 +51,6 @@ const JOB_DEFINITIONS = [
5151
export async function registerAllJobs(boss: PgBoss, client: Client): Promise<void> {
5252
// Initialize scheduler instances with Discord client
5353
const rssPoller = getRssPoller();
54-
const attendanceChecker = getAttendanceChecker();
5554
const fineReminder = getFineReminder();
5655
const roundReporter = getRoundReporter();
5756
const curationCrawler = getCurationCrawler();
@@ -181,40 +180,6 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
181180
}
182181
});
183182

184-
// P0 #4: 결석/지각 벌금 콜백 설정
185-
// 결석 콜백: 화요일 00:00에 결석자 판정 시 자동 벌금 부과
186-
attendanceChecker.setOnAbsentCallback(async (attendance, round) => {
187-
try {
188-
const db = getDb();
189-
const [member] = await db
190-
.select()
191-
.from(members)
192-
.where(eq(members.id, attendance.memberId))
193-
.limit(1);
194-
195-
if (!member) {
196-
logger.error({ memberId: attendance.memberId }, '✅ [출석] 멤버를 찾을 수 없음');
197-
return;
198-
}
199-
200-
// 결석 벌금 생성
201-
// DM 알림은 보내지 않음 — fine-reminder에서 화요일부터 리마인더로 발송
202-
const fine = await fineService.create(attendance.memberId, round.id, 'absent');
203-
204-
logger.info({
205-
member: member.name,
206-
round: round.roundNumber,
207-
amount: fine.amount,
208-
}, '✅ [출석] 결석 벌금 부과 완료');
209-
} catch (error) {
210-
Sentry.captureException(error);
211-
logger.error({
212-
memberId: attendance.memberId,
213-
error
214-
}, '✅ [출석] 결석 콜백 처리 실패');
215-
}
216-
});
217-
218183
// Set up curation crawl function: fetch RSS → parse → extract content → return CrawledContent[]
219184
// P1 #8: 큐레이션 데이터 품질 개선 - description, thumbnailUrl 추출
220185
curationCrawler.setCrawlFunction(async (url: string): Promise<CrawledContent[]> => {
@@ -276,8 +241,67 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
276241
await rssPoller.poll();
277242
});
278243

279-
await boss.work('attendance-check', { batchSize: 1 }, async () => {
280-
await attendanceChecker.check();
244+
// 새 큐 생성 (기존에 없는 큐는 명시적으로 생성 필요)
245+
await boss.createQueue('attendance-init');
246+
await boss.createQueue('attendance-absent');
247+
248+
// 회차 시작일 00:02 KST — 다음 회차 전환 + active 멤버 출석 PENDING 레코드 생성
249+
await boss.work('attendance-init', { batchSize: 1 }, async () => {
250+
try {
251+
const currentRound = await getCurrentRound();
252+
const todayStr = formatKSTDate(new Date());
253+
const nextRound = await getRoundByNumber(currentRound.roundNumber + 1);
254+
255+
if (!nextRound || todayStr !== nextRound.startDate) {
256+
logger.info(`✅ [출석 초기화] 오늘(${todayStr})은 다음 회차 시작일이 아님, 스킵`);
257+
return;
258+
}
259+
260+
// 회차 전환
261+
await setCurrentRound(nextRound.roundNumber);
262+
logger.info(`✅ [출석 초기화] ${nextRound.roundNumber}회차로 전환 완료`);
263+
264+
// 출석 PENDING 레코드 생성
265+
const created = await attendanceService.createForRound(nextRound.id);
266+
logger.info(`✅ [출석 초기화] ${nextRound.roundNumber}회차 ${created.length}명 PENDING 레코드 생성`);
267+
} catch (error) {
268+
Sentry.captureException(error);
269+
logger.error({ error }, '✅ [출석 초기화] 에러');
270+
}
271+
});
272+
273+
// 화요일 00:02 KST — 이전 회차 PENDING → ABSENT + 결석 벌금 부과
274+
await boss.work('attendance-absent', { batchSize: 1 }, async () => {
275+
try {
276+
const currentRound = await getCurrentRound();
277+
const prevRound = await getRoundByNumber(currentRound.roundNumber - 1);
278+
279+
if (!prevRound) {
280+
logger.info('✅ [결석 처리] 이전 회차 없음, 스킵');
281+
return;
282+
}
283+
284+
if (!isGracePeriodEnded(prevRound)) {
285+
logger.info(`✅ [결석 처리] ${prevRound.roundNumber}회차 유예 기간 미종료, 스킵`);
286+
return;
287+
}
288+
289+
const processedRecords = await attendanceService.processGracePeriodEnd(prevRound.id);
290+
const absentRecords = processedRecords.filter(r => r.status === AttendanceStatus.ABSENT);
291+
logger.info(`✅ [결석 처리] ${prevRound.roundNumber}회차 ${absentRecords.length}명 결석 처리`);
292+
293+
for (const record of absentRecords) {
294+
try {
295+
await fineService.create(record.memberId, prevRound.id, 'absent');
296+
} catch (fineError) {
297+
Sentry.captureException(fineError);
298+
logger.error({ memberId: record.memberId, error: fineError }, '✅ [결석 처리] 벌금 부과 실패');
299+
}
300+
}
301+
} catch (error) {
302+
Sentry.captureException(error);
303+
logger.error({ error }, '✅ [결석 처리] 에러');
304+
}
281305
});
282306

283307
await boss.work('fine-reminder', { batchSize: 1 }, async () => {

packages/bot/src/schedulers/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// 모든 스케줄러를 내보냅니다.
33

44
export * from './rss-poller';
5-
export * from './attendance-checker';
65
export * from './fine-reminder';
76
export * from './round-reporter';
87
export * from './curation-crawler';

packages/bot/src/schedulers/round-reporter.ts

Lines changed: 27 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import { Client } from 'discord.js';
77
import { and, count, eq } from 'drizzle-orm';
88
import logger from '../lib/logger';
9-
import { attendance, getDb, members, posts, type Round, rounds, } from '@blog-study/shared/db';
10-
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded, setCurrentRound, } from '../services/round.service';
9+
import { attendance, getDb, members, posts, type Round } from '@blog-study/shared/db';
10+
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded } from '../services/round.service';
1111
import {
1212
type AttendanceSummary,
1313
calculateRoundReportData,
@@ -112,51 +112,52 @@ export class RoundReporter {
112112
try {
113113
const currentRound = await getCurrentRound();
114114

115+
// 현재 회차의 이전 회차(종료된 회차)를 가져와서 리포트 발송
116+
const prevRound = await getRoundByNumber(currentRound.roundNumber - 1);
117+
118+
if (!prevRound) {
119+
logger.info('📊 [회차 리포트] 이전 회차 없음, 건너뜀');
120+
return {
121+
timestamp: startTime,
122+
roundNumber: 0,
123+
reportSent: false,
124+
newRoundStarted: false,
125+
newRoundNumber: null,
126+
errors: ['이전 회차 없음'],
127+
};
128+
}
129+
115130
// grace period 체크 (수동 트리거 시 건너뜀)
116-
if (!force && !isGracePeriodEnded(currentRound)) {
117-
logger.info(`📊 [회차 리포트] ${currentRound.roundNumber}회차 지각 기간 미종료, 건너뜀`);
131+
if (!force && !isGracePeriodEnded(prevRound)) {
132+
logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 지각 기간 미종료, 건너뜀`);
118133
return {
119134
timestamp: startTime,
120-
roundNumber: currentRound.roundNumber,
135+
roundNumber: prevRound.roundNumber,
121136
reportSent: false,
122137
newRoundStarted: false,
123138
newRoundNumber: null,
124139
errors: ['지각 기간 미종료'],
125140
};
126141
}
127142

128-
logger.info(`📊 [회차 리포트] ${currentRound.roundNumber}회차 리포트 생성 중...`);
143+
logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 리포트 생성 중...`);
129144

130-
const reportData = await buildRoundReportDataForRound(currentRound);
145+
const reportData = await buildRoundReportDataForRound(prevRound);
131146
const notificationService = getNotificationService();
132147
const sent = await notificationService.sendRoundReport(reportData);
133148

134149
if (!sent) {
135150
errors.push('리포트 발송 실패');
136151
}
137152

138-
logger.info(`📊 [회차 리포트] ${currentRound.roundNumber}회차 리포트 ${sent ? '발송 완료 ✅' : '발송 실패 ❌'}`);
139-
140-
// 다음 회차로 전환
141-
const nextRound = await getRoundByNumber(currentRound.roundNumber + 1);
142-
if (nextRound) {
143-
await setCurrentRound(nextRound.roundNumber);
144-
logger.info(`📊 [회차 리포트] 현재 회차 → ${nextRound.roundNumber}회차로 전환`);
145-
} else {
146-
const db = getDb();
147-
await db
148-
.update(rounds)
149-
.set({ isCurrent: false })
150-
.where(eq(rounds.id, currentRound.id));
151-
logger.info(`📊 [회차 리포트] 다음 회차 없음, ${currentRound.roundNumber}회차 종료`);
152-
}
153+
logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 리포트 ${sent ? '발송 완료 ✅' : '발송 실패 ❌'}`);
153154

154155
return {
155156
timestamp: startTime,
156-
roundNumber: currentRound.roundNumber,
157+
roundNumber: prevRound.roundNumber,
157158
reportSent: sent,
158-
newRoundStarted: nextRound !== null,
159-
newRoundNumber: nextRound?.roundNumber ?? null,
159+
newRoundStarted: false,
160+
newRoundNumber: null,
160161
errors,
161162
};
162163
} catch (error) {
@@ -203,6 +204,7 @@ export class RoundReporter {
203204
const todayStr = formatKSTDate(new Date());
204205
const isTodayRoundStart = todayStr === currentRound.startDate;
205206

207+
// attendance-init(00:02)에서 이미 회차 전환 완료 — 현재 회차 기준으로 알림만 발송
206208
if (force || isTodayRoundStart) {
207209
logger.info(`🚀 [회차 시작] ${currentRound.roundNumber}회차 시작 알림 발송 중...`);
208210

@@ -225,30 +227,6 @@ export class RoundReporter {
225227
};
226228
}
227229

228-
// 다음 회차 시작일인지 확인
229-
const nextRound = await getRoundByNumber(currentRound.roundNumber + 1);
230-
231-
if (nextRound && (force || todayStr === nextRound.startDate)) {
232-
await setCurrentRound(nextRound.roundNumber);
233-
logger.info(`🚀 [회차 시작] ${nextRound.roundNumber}회차 시작, 회차 전환 완료`);
234-
235-
const notificationService = getNotificationService();
236-
const sent = await notificationService.sendRoundStartAnnouncement(nextRound);
237-
238-
if (!sent) {
239-
errors.push('회차 시작 알림 발송 실패');
240-
}
241-
242-
return {
243-
timestamp: startTime,
244-
roundNumber: nextRound.roundNumber,
245-
reportSent: false,
246-
newRoundStarted: sent,
247-
newRoundNumber: nextRound.roundNumber,
248-
errors,
249-
};
250-
}
251-
252230
logger.info(`🚀 [회차 시작] 오늘(${todayStr})은 회차 시작일이 아님, 건너뜀`);
253231

254232
return {

packages/bot/src/services/notification.service.property.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,8 @@ describe('NotificationService Property Tests', () => {
344344
const expectedAbsent = summaries.filter(s => s.status === AttendanceStatus.ABSENT).length;
345345

346346
expect(reportData.submitted.length).toBe(expectedSubmitted);
347-
expect(reportData.late.length).toBe(expectedLate);
348-
expect(reportData.absent.length).toBe(expectedAbsent);
347+
expect(reportData.lateRate).toBeCloseTo(expectedLate / summaries.length, 5);
348+
expect(reportData.absentRate).toBeCloseTo(expectedAbsent / summaries.length, 5);
349349
}
350350
),
351351
{ numRuns: 100 }
@@ -476,8 +476,6 @@ describe('NotificationService Property Tests', () => {
476476
const reportData = calculateRoundReportData(round, []);
477477

478478
expect(reportData.submitted).toHaveLength(0);
479-
expect(reportData.late).toHaveLength(0);
480-
expect(reportData.absent).toHaveLength(0);
481479
expect(reportData.mvps).toHaveLength(0);
482480
expect(reportData.totalMembers).toBe(0);
483481
expect(reportData.submissionRate).toBe(0);

0 commit comments

Comments
 (0)