feat: 텔레그램 알림 봇 연동 — 학생 가입·과제·코딩 제출을 멘토에게 즉시 알림
- TelegramNotifier: 봇 API sendMessage 호출. 두 가지 원칙 — · 알림 실패가 본 기능(가입·제출)을 절대 막지 않는다: 예외 전부 삼킴 + @Async 비동기 · 시크릿은 env로만: TELEGRAM_BOT_TOKEN/CHAT_ID 비어 있으면 기능이 조용히 꺼짐(로컬 무설정 OK) · 에러 로그에 예외 종류만 남김 — HTTP 에러 메시지의 URL에 토큰이 포함되는 유출 경로 차단 - AsyncConfig: @EnableAsync (없으면 @Async가 조용히 무시되는 함정 문서화) - 연결 지점 3곳: AuthService.signup(가입) / CodingService.submit(코딩) / SubmissionService(과제) - 테스트: 생성자에 mock 주입 + 가입 시 알림 발송 검증 1건 추가 → 전체 17개 통과 운영 검증: 봇(@awesomedevmirim_bot)→'2026 현장실습 사전 과제' 그룹 발송 확인, 실가입 E2E로 알림 발송 확인. 토큰은 서버 .env에만 존재(git 미포함). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0b6c2a15a7
commit
a52999d81d
@ -0,0 +1,18 @@
|
||||
package dev.awesomedev.mirim.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* @Async 애노테이션을 켠다(@EnableAsync가 없으면 @Async는 그냥 무시된다 — 조용히!).
|
||||
*
|
||||
* 학습 포인트: 스프링의 많은 기능이 이렇게 "애노테이션 + Enable 스위치" 짝으로 돼 있다.
|
||||
* @Async/@EnableAsync, @Scheduled/@EnableScheduling, @Cacheable/@EnableCaching…
|
||||
* 스위치를 빼먹으면 에러도 없이 기능만 조용히 안 돌아서, 꽤 오래 헤매게 되는 함정이다.
|
||||
* 현재 @Async를 쓰는 곳: TelegramNotifier(알림 전송을 요청 처리와 분리).
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
}
|
||||
@ -26,13 +26,16 @@ public class AuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final StudentProfileRepository studentProfileRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
public AuthService(UserRepository userRepository,
|
||||
StudentProfileRepository studentProfileRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
PasswordEncoder passwordEncoder,
|
||||
TelegramNotifier telegramNotifier) {
|
||||
this.userRepository = userRepository;
|
||||
this.studentProfileRepository = studentProfileRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.telegramNotifier = telegramNotifier;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -103,6 +106,11 @@ public class AuthService {
|
||||
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(),
|
||||
java.time.Instant.now()));
|
||||
|
||||
// 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다).
|
||||
telegramNotifier.send("🎓 새 수습생 가입\n"
|
||||
+ user.getName() + " (" + ("DESIGN".equals(user.getTrack()) ? "디자인" : "개발") + " 트랙)\n"
|
||||
+ req.companyEmailLocal() + "@" + StudentProfile.COMPANY_DOMAIN);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@ -25,11 +25,14 @@ public class CodingService {
|
||||
|
||||
private final CodingProblemRepository codingProblemRepository;
|
||||
private final CodingSubmissionRepository codingSubmissionRepository;
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
public CodingService(CodingProblemRepository codingProblemRepository,
|
||||
CodingSubmissionRepository codingSubmissionRepository) {
|
||||
CodingSubmissionRepository codingSubmissionRepository,
|
||||
TelegramNotifier telegramNotifier) {
|
||||
this.codingProblemRepository = codingProblemRepository;
|
||||
this.codingSubmissionRepository = codingSubmissionRepository;
|
||||
this.telegramNotifier = telegramNotifier;
|
||||
}
|
||||
|
||||
/** 전체 문제 목록 (정렬 순서대로) */
|
||||
@ -81,13 +84,23 @@ public class CodingService {
|
||||
Integer total = problem.isAutoGraded() ? totalCount : null;
|
||||
String results = problem.isAutoGraded() ? resultsJson : null;
|
||||
|
||||
return codingSubmissionRepository.findByProblemAndUser(problem, user)
|
||||
CodingSubmission submission = codingSubmissionRepository.findByProblemAndUser(problem, user)
|
||||
.map(existing -> {
|
||||
existing.submit(code, passed, total, results);
|
||||
return existing;
|
||||
})
|
||||
.orElseGet(() -> codingSubmissionRepository.save(
|
||||
new CodingSubmission(problem, user, code, passed, total, results)));
|
||||
|
||||
// 멘토에게 텔레그램 알림 (실패해도 제출은 성공)
|
||||
String grading = problem.isAutoGraded()
|
||||
? submission.getPassedCount() + "/" + submission.getTotalCount() + " 통과"
|
||||
: "멘토 리뷰 대기";
|
||||
telegramNotifier.send("💻 코딩 문제 제출\n"
|
||||
+ user.getName() + " — [" + problem.getLanguage() + "] " + problem.getTitle()
|
||||
+ "\n" + grading + " (" + submission.getAttempts() + "번째 시도)");
|
||||
|
||||
return submission;
|
||||
}
|
||||
|
||||
/** 멘토 피드백 등록 — 없으면 404 */
|
||||
|
||||
@ -23,11 +23,14 @@ public class SubmissionService {
|
||||
|
||||
private final SubmissionRepository submissionRepository;
|
||||
private final AssignmentRepository assignmentRepository;
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
public SubmissionService(SubmissionRepository submissionRepository,
|
||||
AssignmentRepository assignmentRepository) {
|
||||
AssignmentRepository assignmentRepository,
|
||||
TelegramNotifier telegramNotifier) {
|
||||
this.submissionRepository = submissionRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.telegramNotifier = telegramNotifier;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,12 +49,21 @@ public class SubmissionService {
|
||||
HttpStatus.NOT_FOUND, "과제를 찾을 수 없습니다: " + assignmentId));
|
||||
|
||||
Optional<Submission> existing = submissionRepository.findByAssignmentAndUser(assignment, user);
|
||||
if (existing.isPresent()) {
|
||||
Submission submission = existing.get();
|
||||
Submission submission;
|
||||
boolean isResubmit = existing.isPresent();
|
||||
if (isResubmit) {
|
||||
submission = existing.get();
|
||||
submission.resubmit(content, link);
|
||||
return submission; // 학습 포인트: 트랜잭션 안에서 엔티티를 바꾸면 JPA가 커밋 시 자동으로 UPDATE 한다(더티 체킹).
|
||||
// 학습 포인트: 트랜잭션 안에서 엔티티를 바꾸면 JPA가 커밋 시 자동으로 UPDATE 한다(더티 체킹).
|
||||
} else {
|
||||
submission = submissionRepository.save(new Submission(assignment, user, content, link));
|
||||
}
|
||||
return submissionRepository.save(new Submission(assignment, user, content, link));
|
||||
|
||||
// 멘토에게 텔레그램 알림 (실패해도 제출은 성공)
|
||||
telegramNotifier.send("📝 과제 " + (isResubmit ? "재제출" : "제출") + "\n"
|
||||
+ user.getName() + " — " + assignment.getTitle());
|
||||
|
||||
return submission;
|
||||
}
|
||||
|
||||
/** 내 제출물을 최신순으로 조회한다. */
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 텔레그램 봇으로 멘토(대표)에게 알림을 보낸다 — 학생 가입, 과제·코딩 제출 등.
|
||||
*
|
||||
* 학습 포인트 ① — 외부 API 연동의 첫 원칙: "부가 기능이 본 기능을 죽이면 안 된다".
|
||||
* 알림은 있으면 좋은 것(nice to have)이고, 가입·제출은 반드시 돼야 하는 것이다.
|
||||
* 텔레그램이 점검 중이라고 학생이 가입을 못 하면 주객전도다. 그래서
|
||||
* (1) 모든 예외를 여기서 삼키고(로그만 남긴다), (2) @Async로 딴 스레드에서 보낸다 —
|
||||
* 텔레그램 응답이 늦어도 학생의 요청 처리 시간에는 영향이 없다.
|
||||
*
|
||||
* 학습 포인트 ② — 시크릿은 코드가 아니라 환경변수에.
|
||||
* 봇 토큰이 코드나 git에 들어가면 저장소를 보는 모든 사람이 봇을 탈취할 수 있다.
|
||||
* 토큰은 운영 서버의 .env 파일에만 있고(TELEGRAM_BOT_TOKEN), 값이 비어 있으면
|
||||
* 이 클래스는 조용히 아무것도 안 한다 — 로컬 개발에서 토큰 없이도 앱이 그대로 돈다.
|
||||
*/
|
||||
@Service
|
||||
public class TelegramNotifier {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TelegramNotifier.class);
|
||||
|
||||
private final String botToken;
|
||||
private final String chatId;
|
||||
// RestClient: 스프링의 현대식 HTTP 클라이언트 (RestTemplate의 후속).
|
||||
private final RestClient restClient = RestClient.create();
|
||||
|
||||
public TelegramNotifier(@Value("${telegram.bot-token:}") String botToken,
|
||||
@Value("${telegram.chat-id:}") String chatId) {
|
||||
this.botToken = botToken;
|
||||
this.chatId = chatId;
|
||||
if (!isEnabled()) {
|
||||
log.info("텔레그램 알림 비활성 상태입니다 (TELEGRAM_BOT_TOKEN/CHAT_ID 미설정).");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEnabled() {
|
||||
return !botToken.isBlank() && !chatId.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 한 건을 보낸다. 실패해도 예외를 밖으로 던지지 않는다.
|
||||
*
|
||||
* 학습 포인트: @Async — 이 메서드는 호출한 요청 스레드가 아니라 별도 스레드에서 돈다.
|
||||
* 호출한 쪽은 기다리지 않고 바로 다음 줄로 넘어간다(fire-and-forget).
|
||||
* 주의: @Async는 "다른 빈에서 호출할 때"만 동작한다. 같은 클래스 안에서
|
||||
* this.send()로 부르면 프록시를 안 거쳐서 그냥 동기 실행된다 — 스프링 AOP의 대표 함정.
|
||||
*/
|
||||
@Async
|
||||
public void send(String text) {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
restClient.post()
|
||||
.uri("https://api.telegram.org/bot{token}/sendMessage", botToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("chat_id", chatId, "text", text))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
} catch (Exception e) {
|
||||
// 학습 포인트: e.getMessage()를 그대로 찍지 않는다 — HTTP 에러 메시지에는
|
||||
// 요청 URL이 포함되는데, 우리 URL에는 봇 토큰이 들어 있다.
|
||||
// "로그에 시크릿이 새는" 흔한 경로라서, 예외의 종류만 남긴다.
|
||||
log.warn("텔레그램 알림 전송 실패(무시하고 계속): {}", e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,14 @@ server:
|
||||
secure: ${COOKIE_SECURE:false} # HTTPS로만 쿠키 전송(운영은 true). 로컬(http)은 false여야 로그인됨
|
||||
same-site: strict # 다른 사이트에서 온 요청엔 쿠키를 안 붙인다 → CSRF 방어의 핵심
|
||||
|
||||
# 텔레그램 알림 (TelegramNotifier) — 학생 가입·제출을 멘토 텔레그램으로.
|
||||
# 학습 포인트: 둘 다 비어 있으면 알림 기능이 통째로 꺼진다. 로컬 개발에선 아무것도
|
||||
# 설정하지 않아도 앱이 그대로 돈다 — "설정이 없으면 기능이 꺼지는" 안전한 기본값 설계.
|
||||
# 실제 토큰은 운영 서버 .env에만 둔다(절대 git에 넣지 않는다!).
|
||||
telegram:
|
||||
bot-token: ${TELEGRAM_BOT_TOKEN:}
|
||||
chat-id: ${TELEGRAM_CHAT_ID:}
|
||||
|
||||
logging:
|
||||
level:
|
||||
# 학습 포인트: 개발은 DEBUG로 자세히, 운영은 INFO로. 로그도 자원이고, 과한 로그는 비용·노이즈다.
|
||||
|
||||
@ -34,6 +34,7 @@ class AuthServiceTest {
|
||||
private UserRepository userRepository;
|
||||
private StudentProfileRepository studentProfileRepository;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private TelegramNotifier telegramNotifier;
|
||||
private AuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@ -41,7 +42,8 @@ class AuthServiceTest {
|
||||
userRepository = mock(UserRepository.class);
|
||||
studentProfileRepository = mock(StudentProfileRepository.class);
|
||||
passwordEncoder = new BCryptPasswordEncoder(); // 진짜 인코더
|
||||
authService = new AuthService(userRepository, studentProfileRepository, passwordEncoder);
|
||||
telegramNotifier = mock(TelegramNotifier.class); // 테스트에선 진짜 알림을 보내지 않는다
|
||||
authService = new AuthService(userRepository, studentProfileRepository, passwordEncoder, telegramNotifier);
|
||||
}
|
||||
|
||||
/** 테스트용 사용자 하나 만들기. User엔 id setter가 없어 리플렉션으로 넣는다. */
|
||||
@ -141,6 +143,8 @@ class AuthServiceTest {
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(dev.awesomedev.mirim.domain.StudentProfile.class);
|
||||
verify(studentProfileRepository).save(captor.capture());
|
||||
assertNotNull(captor.getValue().getPrivacyConsentAt());
|
||||
// 가입 성공 시 멘토에게 텔레그램 알림이 나간다
|
||||
verify(telegramNotifier).send(org.mockito.ArgumentMatchers.contains("새 수습생 가입"));
|
||||
}
|
||||
|
||||
// ─────────────────────── 멘토 재설정 권한 ───────────────────────
|
||||
|
||||
@ -38,7 +38,8 @@ class CodingServiceTest {
|
||||
void setUp() {
|
||||
codingProblemRepository = mock(CodingProblemRepository.class);
|
||||
codingSubmissionRepository = mock(CodingSubmissionRepository.class);
|
||||
codingService = new CodingService(codingProblemRepository, codingSubmissionRepository);
|
||||
codingService = new CodingService(codingProblemRepository, codingSubmissionRepository,
|
||||
mock(TelegramNotifier.class)); // 테스트에선 진짜 알림을 보내지 않는다
|
||||
student = new User("student1", "해시", "수습생1", "STUDENT", "DEV");
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,9 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
COOKIE_SECURE: "true" # 운영은 HTTPS(Caddy)이므로 세션 쿠키를 HTTPS 전용으로
|
||||
LOG_LEVEL: INFO # 운영 로그는 INFO
|
||||
# 텔레그램 알림 (값은 .env에 — 비어 있으면 알림만 꺼지고 앱은 정상)
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user