Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
20a1719
ãcs 스케줄러 및 테스트코드
HwangMinSeon Oct 25, 2025
17a6fac
fix: 테스트 환경변수 설정변경
HwangMinSeon Oct 25, 2025
7feb4a6
test: TodayCsScheduler 유닛 테스트 작성
HwangMinSeon Oct 25, 2025
b689abc
fix: 전체 주석 처리
HwangMinSeon Oct 25, 2025
ee53f65
fix: 큐 파일 정리
HwangMinSeon Oct 25, 2025
be75380
setting: resume id 추가
yuripbong Nov 12, 2025
0e7ea94
feat: 이력서 크롤링 실패 처리
yuripbong Nov 12, 2025
d262f2c
feat: 이력서 작업 처리 상태 감지
yuripbong Nov 12, 2025
60d8487
feat: 이력서 크롤링 실패 분류
yuripbong Nov 12, 2025
35c6b58
feat: 이력서 Id 추출
yuripbong Nov 12, 2025
71f0b9b
feat: 이력서 처리 작업 모니터링
yuripbong Nov 12, 2025
cc08fab
feat: 이력서 크롤링 실패 슬랙 알림
yuripbong Nov 12, 2025
1ddd007
style: 포맷팅
yuripbong Nov 13, 2025
c2924e6
setting: 도커 이미지 변경
yuripbong Nov 14, 2025
dd9fef0
fix: 이미지 수정
yuripbong Nov 14, 2025
9422ed3
setting: 도커 이미지 변경
yuripbong Nov 14, 2025
1c817c0
Merge branch 'develop' of https://github.com/Techeer-Hogwarts/backend…
HwangMinSeon Nov 15, 2025
f9ec269
fix: 알림 중복 발송 방지
yuripbong Nov 15, 2025
2029291
fix: 레디스 조회 null 검사
yuripbong Nov 15, 2025
189e336
refactor: SCAN 기반 조회 적용
yuripbong Nov 20, 2025
619a937
fix: 알림 발송 완료 태스크 정리
yuripbong Nov 20, 2025
e8eb606
fix : 스케줄러에서 이모지 제거
HwangMinSeon Nov 24, 2025
311d037
fix : 도커파일 수정
HwangMinSeon Nov 24, 2025
9d12337
fix : 도커파일 수정, Runtime jre로 수정. builder는 유지
HwangMinSeon Nov 24, 2025
a8e141b
fix : 함수명 소문자로 수정
HwangMinSeon Nov 24, 2025
2f30cee
fix : instanceOf 제거
HwangMinSeon Nov 24, 2025
98dd1fd
Merge pull request #126 from Techeer-Hogwarts/BACKEND-179
HwangMinSeon Nov 24, 2025
ebda8e4
fix: 부트캠프 기간 생성 LocalDate 의존성 제거
dongwooooooo Nov 25, 2025
f9812d2
fix: BootcampMapper 정적 메서드로 변경
dongwooooooo Nov 25, 2025
8689b53
BootcampPermissionEvaluator 구현 및 CustomUserPrincipal 수정
dongwooooooo Nov 25, 2025
d09c2eb
BootcampService 검증 로직 추가, 기타 적용사항
dongwooooooo Nov 25, 2025
57ea6e2
User에 bootcamp로직 수정
dongwooooooo Nov 25, 2025
42d97fc
Test: BootcampTest 코드 작성
dongwooooooo Nov 25, 2025
2b28068
기타 변경사항, SpotlessApply
dongwooooooo Nov 25, 2025
38f4432
커서 룰 하나 추가
dongwooooooo Nov 25, 2025
739e1c8
카멜케이스, 리터럴 중복 해결
dongwooooooo Nov 25, 2025
edffe27
LocalDate 빈으로 주입
dongwooooooo Nov 29, 2025
1081397
DelegatingPermissionEvaluator long 형변환
dongwooooooo Nov 29, 2025
5c8c1f4
DelegatingPermissionEvaluator long 형변환
dongwooooooo Nov 29, 2025
47f01bc
순환참조 수정
dongwooooooo Nov 29, 2025
5ac4200
evaluator 수정
dongwooooooo Nov 29, 2025
a1c660c
spotlessApply
dongwooooooo Nov 29, 2025
160859c
ci test 주석 해제
dongwooooooo Nov 30, 2025
f6797b7
순환참조 해제
dongwooooooo Nov 30, 2025
3780770
외부 환경변수 테스트환경에서 격리
dongwooooooo Nov 30, 2025
3ac8de2
테스트 컨테이너 적용
dongwooooooo Nov 30, 2025
2f99b6b
테스트컨테이너 변경 적
dongwooooooo Nov 30, 2025
3b69ed3
프로젝트 멤버 테스트코드 작성
dongwooooooo Nov 30, 2025
fb2bdee
spotlessApply
dongwooooooo Nov 30, 2025
dbd1da5
Merge branch 'develop' of https://github.com/Techeer-Hogwarts/backend…
dongwooooooo Nov 30, 2025
ae82e00
test.properties 추가
dongwooooooo Nov 30, 2025
af5b3e3
ci jacoco 종류 변경
dongwooooooo Nov 30, 2025
536bb73
코더레빗 리뷰 적용
dongwooooooo Nov 30, 2025
1f66bc1
Merge pull request #128 from Techeer-Hogwarts/BACKEND-187
dongwooooooo Nov 30, 2025
aa2484b
zoom header debugging
dongwooooooo Dec 1, 2025
8dd61d1
zoom testConnection 체
dongwooooooo Dec 1, 2025
e79f956
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
ebffde4
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
11ac775
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
7c4744c
zoom event notification endpoint url validate api
dongwooooooo Dec 6, 2025
55aaf53
zoom 안 쓰는 코드 정리
dongwooooooo Dec 6, 2025
800d96a
zoom 안 쓰는 코드 정리
dongwooooooo Dec 6, 2025
042258d
zoom api 토큰 설정
dongwooooooo Dec 6, 2025
0523e24
zoom api 토큰 설정
dongwooooooo Dec 6, 2025
8a398df
zoom 웹훅 api 수정 및 정리
dongwooooooo Dec 6, 2025
fc22dee
zoom webhook exception
dongwooooooo Dec 6, 2025
59ec0d9
spotlessApply
dongwooooooo Dec 6, 2025
276a716
zoomWebhook 인증 리팩터링
dongwooooooo Dec 6, 2025
9694580
spotlessApply
dongwooooooo Dec 6, 2025
a080e48
래빗 리뷰
dongwooooooo Dec 6, 2025
86c87ef
zoom 인증 경로 설정
dongwooooooo Dec 7, 2025
f953881
zoom 웹훅 이벤트 body 실종 수정
dongwooooooo Dec 7, 2025
5591e9a
spotlessApply
dongwooooooo Dec 7, 2025
be72a68
zoom 인증 경로 설정
dongwooooooo Dec 7, 2025
5213a47
zoomWebhook filter에서 aop로 인증 변경
dongwooooooo Dec 7, 2025
dc53254
spotlessApply
dongwooooooo Dec 7, 2025
0b812e2
zoom 12시 이후로 나가면 조회 못하는 에러 해결
dongwooooooo Dec 10, 2025
02288ea
spotlessApply
dongwooooooo Dec 10, 2025
695f534
Merge pull request #132 from Techeer-Hogwarts/BACKEND-193
dongwooooooo Dec 10, 2025
8f5c10b
feat: 부트캠프 목록 조회 응답값 수정
kimzini Dec 21, 2025
adaa34b
feat: 부트캠프 목록 조회 응답값 수정
kimzini Dec 21, 2025
4da0d5a
setting: resume id 추가
yuripbong Nov 12, 2025
38f3f52
feat: 이력서 크롤링 실패 처리
yuripbong Nov 12, 2025
e03446c
feat: 이력서 작업 처리 상태 감지
yuripbong Nov 12, 2025
1c4058b
feat: 이력서 크롤링 실패 분류
yuripbong Nov 12, 2025
e407487
feat: 이력서 Id 추출
yuripbong Nov 12, 2025
97b0cf7
feat: 이력서 처리 작업 모니터링
yuripbong Nov 12, 2025
841726c
feat: 이력서 크롤링 실패 슬랙 알림
yuripbong Nov 12, 2025
f9c0570
style: 포맷팅
yuripbong Nov 13, 2025
44ea627
setting: 도커 이미지 변경
yuripbong Nov 14, 2025
94bef45
fix: 알림 중복 발송 방지
yuripbong Nov 15, 2025
7cf8f14
fix: 레디스 조회 null 검사
yuripbong Nov 15, 2025
4c23268
refactor: SCAN 기반 조회 적용
yuripbong Nov 20, 2025
c8c3d00
fix: 알림 발송 완료 태스크 정리
yuripbong Nov 20, 2025
b8ad0a8
Merge branch 'BACKEND-166' of https://github.com/Techeer-Hogwarts/bac…
yuripbong Jan 7, 2026
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: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM openjdk:21-slim AS builder
FROM openjdk:21-jdk-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y findutils wget
COPY ./techeerzip/ /app/
Expand All @@ -8,7 +8,7 @@ RUN ./gradlew clean spotlessApply bootJar --no-daemon -x test
# OpenTelemetry Java Agent 다운로드
RUN wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.5.0/opentelemetry-javaagent.jar

FROM openjdk:21-slim
FROM openjdk:21-jre-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package backend.techeerzip.domain.resume.alert;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import backend.techeerzip.infra.slack.event.SlackEvent;
import backend.techeerzip.infra.slack.util.SlackChannelType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class ResumeAlertNotifier {
private static final String ALERT_SENT_KEY_PREFIX = "resume_alert_sent:";

private final ApplicationEventPublisher eventPublisher;
private final RedisTemplate<String, String> redisTemplate;

public void notify(
String reason, String stage, String taskId, Long userId, Long resumeId, String detail) {
if (resumeId == null) {
log.warn("resumeId가 null이어서 알림 발송 불가 - taskId: {}", taskId);
return;
}

final String alertKey = ALERT_SENT_KEY_PREFIX + resumeId;

Boolean alreadySent = redisTemplate.hasKey(alertKey);
if (Boolean.TRUE.equals(alreadySent)) {
log.debug("이미 알림이 발송된 이력서 - resumeId: {}, taskId: {}", resumeId, taskId);
return;
}

final String message =
String.format(
"❗️ 이력서 크롤링 실패\n- reason: %s\n- stage: %s\n- taskId: %s\n- userId: %s\n- resumeId: %s\n- detail: %s",
reason,
stage,
taskId,
String.valueOf(userId),
String.valueOf(resumeId),
detail != null ? detail : "-");

eventPublisher.publishEvent(
SlackEvent.Channel.builder()
.channelType(SlackChannelType.RESUME)
.message(message)
.build());

redisTemplate.opsForValue().set(alertKey, "sent");
log.info("이력서 실패 알림 발송 완료 - resumeId: {}, taskId: {}", resumeId, taskId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import backend.techeerzip.domain.resume.alert.ResumeAlertNotifier;
import backend.techeerzip.domain.resume.monitoring.ResumeMetricsRecorder;
import backend.techeerzip.domain.resume.support.ResumeFailureClassifier;
import backend.techeerzip.domain.resume.support.ResumeTaskContextExtractor;
import backend.techeerzip.domain.user.service.UserService;
import backend.techeerzip.infra.redis.RedisTaskProcessedEvent;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,6 +24,8 @@ public class ResumeStackExtractionSaveListener {

private final UserService userService;
private final ApplicationEventPublisher eventPublisher;
private final ResumeAlertNotifier alertNotifier;
private final ResumeMetricsRecorder metricsRecorder;

@Async("defaultExecutor")
@EventListener
Expand All @@ -30,10 +36,41 @@ public void handleResumeStackSaveProcess(ResumeStackExtractionSaveEvent event) {
event.getUserId(),
CONTEXT);
try {
// 총 시도 계측
metricsRecorder.markTotal();

// 다운로드 실패, 파싱/모델 오류 분류 및 알림
Optional<ResumeFailureClassifier.FailureInfo> classified =
ResumeFailureClassifier.classify(event.getResultData());
if (classified.isPresent()) {
final String stage = classified.get().stage();
final String reason = classified.get().reason();
final Long resumeId = ResumeTaskContextExtractor.extractResumeId(event.getTaskId());
metricsRecorder.markFail(reason, stage);
alertNotifier.notify(
reason,
stage,
event.getTaskId(),
event.getUserId(),
resumeId,
"classified-by-result");
return;
}

final Optional<ResumeStackExtractionResponse> response =
ResumeStackMapper.toExtractionResponse(
event.getUserId(), event.getResultData());
if (response.isEmpty()) {
final Long resumeId = ResumeTaskContextExtractor.extractResumeId(event.getTaskId());
final String reason = "result_json_invalid";
metricsRecorder.markFail(reason, "map_result");
alertNotifier.notify(
reason,
"map_result",
event.getTaskId(),
event.getUserId(),
resumeId,
"json-parse-failed");
return;
}
userService.updateTechStack(response.get());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package backend.techeerzip.domain.resume.monitoring;

import org.springframework.stereotype.Component;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class ResumeMetricsRecorder {
private final MeterRegistry meterRegistry;

public void markTotal() {
Counter.builder("resume_extraction_total").register(meterRegistry).increment();
}

public void markFail(String reason, String stage) {
Counter.builder("resume_extraction_fail_total")
.tag("reason", reason)
.tag("stage", stage)
.register(meterRegistry)
.increment();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package backend.techeerzip.domain.resume.scheduler;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Set;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import backend.techeerzip.domain.resume.alert.ResumeAlertNotifier;
import backend.techeerzip.domain.resume.monitoring.ResumeMetricsRecorder;
import backend.techeerzip.domain.resume.support.ResumeTaskContextExtractor;
import backend.techeerzip.infra.redis.RedisTaskReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class ResumeTaskWatchdog {
private final RedisTemplate<String, String> redisTemplate;
private final RedisTaskReader redisTaskReader;
private final ResumeAlertNotifier alertNotifier;
private final ResumeMetricsRecorder metrics;

private static final Duration TIMEOUT = Duration.ofMinutes(20);

@Scheduled(fixedRate = 60_000)
public void checkTimeouts() {
try {
// "resume_extraction-<userId>-<resumeId>" 형식
final Set<String> taskIds = redisTemplate.keys("resume_extraction-*");
if (taskIds == null || taskIds.isEmpty()) {
return;
}
final Instant now = Instant.now();

for (String taskId : taskIds) {
try {
final Map<String, Object> details = redisTaskReader.read(taskId);
final Long userId = parseLong(details.get("userId"));
final Long resumeId = ResumeTaskContextExtractor.extractResumeId(taskId);

final Instant createdAt = parseInstant(details.get("createdAt"));
final String status = String.valueOf(details.getOrDefault("status", ""));
final boolean processed =
"PROCESSED".equalsIgnoreCase(status)
|| "COMPLETED".equalsIgnoreCase(status);
final boolean hasResult = details.containsKey("result");

if (createdAt != null && !processed && createdAt.isBefore(now.minus(TIMEOUT))) {
metrics.markFail("worker_timeout", "worker_execute");
alertNotifier.notify(
"worker_timeout",
"worker_execute",
taskId,
userId,
resumeId,
"no-completion-within-20m");
continue;
}

if (processed && !hasResult) {
metrics.markFail("result_missing", "worker_execute");
alertNotifier.notify(
"result_missing",
"worker_execute",
taskId,
userId,
resumeId,
"processed-without-result");
}
} catch (Exception e) {
log.warn(
"Watchdog inspection failed - taskId: {}, err: {}",
taskId,
e.getMessage());
}
}
} catch (Exception e) {
log.warn("Watchdog scan failed - err: {}", e.getMessage());
}
}

private static Long parseLong(Object v) {
try {
if (v == null) return null;
return Long.parseLong(String.valueOf(v));
} catch (Exception e) {
return null;
}
}

private static Instant parseInstant(Object v) {
try {
if (v == null) return null;
long epochMillis = Long.parseLong(String.valueOf(v));
return Instant.ofEpochMilli(epochMillis);
} catch (Exception e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package backend.techeerzip.domain.resume.support;

import java.util.Optional;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class ResumeFailureClassifier {
private static final ObjectMapper mapper = new ObjectMapper();

public static Optional<FailureInfo> classify(String resultJson) {
try {
final JsonNode root = mapper.readTree(resultJson);
final String errorCode =
getFirstNonNullText(root, "errorCode", "error", "code", "status");
if (errorCode == null) return Optional.empty();

if (match(errorCode, "download_failed", "unauthorized_url", "not_found")) {
return Optional.of(new FailureInfo("worker_download", errorCode));
}
if (match(errorCode, "parsing_failed", "ocr_failed", "model_error")) {
return Optional.of(new FailureInfo("worker_parse", errorCode));
}
return Optional.empty();
} catch (Exception e) {
return Optional.empty();
}
}

private static boolean match(String code, String... candidates) {
for (String c : candidates) if (c.equalsIgnoreCase(code)) return true;
return false;
}

private static String getFirstNonNullText(JsonNode node, String... keys) {
for (String k : keys) {
if (node.has(k) && !node.get(k).isNull()) {
final String v = node.get(k).asText(null);
if (v != null && !v.isBlank()) return v;
}
}
return null;
}

public record FailureInfo(String stage, String reason) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package backend.techeerzip.domain.resume.support;

import backend.techeerzip.domain.task.dto.TaskIdInfo;
import backend.techeerzip.domain.task.util.TaskIdHandler;

public class ResumeTaskContextExtractor {
public static Long extractResumeId(String taskId) {
final TaskIdInfo info = TaskIdHandler.extractTaskId(taskId);
return info.getDomainId();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class SlackProperties {
@NotBlank private final String todayCsId;
@NotBlank private final String blogChallengeId;
@NotBlank private final String emergencyAlertId;
@NotBlank private final String resumeId;

@ConstructorBinding
public SlackProperties(
Expand All @@ -31,7 +32,8 @@ public SlackProperties(
String messageUrl,
String todayCsId,
String blogChallengeId,
String emergencyAlertId) {
String emergencyAlertId,
String resumeId) {
this.environment = environment;
this.channelUrl = channelUrl;
this.dmUrl = dmUrl;
Expand All @@ -40,5 +42,6 @@ public SlackProperties(
this.todayCsId = todayCsId;
this.blogChallengeId = blogChallengeId;
this.emergencyAlertId = emergencyAlertId;
this.resumeId = resumeId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
public enum SlackChannelType {
TBC(SlackProperties::getBlogChallengeId),
TC(SlackProperties::getTodayCsId),
EA(SlackProperties::getEmergencyAlertId);
EA(SlackProperties::getEmergencyAlertId),
RESUME(SlackProperties::getResumeId);

private final Function<SlackProperties, String> channelIdExtractor;

Expand Down
3 changes: 2 additions & 1 deletion techeerzip/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ springdoc.swagger-ui.tryItOutEnabled=true
swagger.username=${SWAGGER_USER}
swagger.password=${SWAGGER_PASSWORD}

https.server.url=${HTTPS_SERVER_URL}
https.server.url=${HTTPS_SERVER_URL}
staging.server.url=${STAGING_SERVER_URL}
x.api.key=${X_API_KEY}

Expand All @@ -83,6 +83,7 @@ slack.message-url=${SLACKBOT_MESSAGE_URL}
slack.today-cs-id=${SLACK_CHANNEL_TODAY_CS_ID}
slack.blog-challenge-id=${SLACK_CHANNEL_BLOG_CHALLENGE_ID}
slack.emergency-alert-id=${SLACK_CHANNEL_EMERGENCY_ALERT_ID}
slack.resume-id=${SLACK_CHANNEL_RESUME_ID}

# Flyway ??
spring.flyway.enabled=true
Expand Down
Loading