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
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public class Recruitment extends AuditedEntity {
@Column(name = "team_size_total", nullable = false)
private Integer teamSizeTotal;

// 남은 λͺ¨μ§‘ 인원(μž…λ ₯ ν•„μš”) – μ‹ μ²­ μ‹œ 1 κ°μ†Œ, 철회/거절 μ‹œ 1 증가(볡원)
// 남은 λͺ¨μ§‘ 인원(μž…λ ₯ ν•„μš”) – μ‹ μ²­ μ‹œ 1 κ°μ†Œ
@Column(name = "recruit_quota", nullable = false)
private Integer recruitQuota;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment;
import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField;
Expand Down Expand Up @@ -119,11 +120,12 @@ private boolean isFullTextSearchAvailable(String keyword) {
/**
* MySQL Full-Text Searchλ₯Ό μ‚¬μš©ν•œ 검색
* MATCH ... AGAINST ꡬ문 ν™œμš©
* WHERE 절: 점수 > 0 νŒλ‹¨μ‹
*/
private BooleanExpression titleFullTextSearch(String keyword) {
String booleanQuery = buildBooleanQuery(keyword);
String booleanQuery = buildBooleanQuery(keyword); // κΈ°μ‘΄ μ „μ²˜λ¦¬/ν† ν¬λ‚˜μ΄μ € μ‚¬μš©
return Expressions.booleanTemplate(
"MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)",
"function('match_against_boolean', {0}, {1}, {2}) > 0",
recruitment.title, recruitment.content, booleanQuery
);
}
Expand All @@ -137,10 +139,10 @@ private BooleanExpression fallbackContains(String keyword) {
.or(recruitment.content.containsIgnoreCase(k));
}

// BOOLEAN MODE 정렬식
// ORDER BY 절: 점수 desc
private OrderSpecifier<Double> scoreOrder(String keyword) {
String tpl = "MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)";
return Expressions.numberTemplate(Double.class, tpl,
return Expressions.numberTemplate(Double.class,
"function('match_against_boolean', {0}, {1}, {2})",
recruitment.title, recruitment.content, buildBooleanQuery(keyword))
.desc();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;

Expand All @@ -47,10 +48,14 @@ public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCr
User recruiter = userRepository.findById(userId)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID));

validateRecruitmentDates(requestDto.getStartAt(), requestDto.getEndAt());
// λ§ˆκ°μΌμ„ ν•΄λ‹Ή λ‚ μ§œμ˜ 23:59:59둜 μ„€μ •
LocalDateTime adjustedEndAt = requestDto.getEndAt().with(LocalTime.of(23, 59, 59));

validateRecruitmentDates(requestDto.getStartAt(), adjustedEndAt);

Recruitment recruitment = recruitmentMapper.toEntity(requestDto);
recruitment.setRecruiter(recruiter);
recruitment.setEndAt(adjustedEndAt);

RecruitmentStatus initialStatus = requestDto.getStartAt().isAfter(LocalDateTime.now()) ?
RecruitmentStatus.FORTHCOMING : RecruitmentStatus.RECRUITING;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package com.teamEWSN.gitdeun.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import java.lang.reflect.Method;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

/**
* 비동기 처리 및 νŠΈλžœμž­μ…˜ μ„€μ •
*
* 마치 λŒ€ν˜• 곡μž₯의 생산라인을 μ„€κ³„ν•˜λŠ” 것과 κ°™μŠ΅λ‹ˆλ‹€.
* - ThreadPool: μž‘μ—…μžλ“€μ˜ νŒ€ (μ μ ˆν•œ μΈμ›μˆ˜λ‘œ νš¨μœ¨μ„± κ·ΉλŒ€ν™”)
* - Transaction: ν’ˆμ§ˆ 검사 체크포인트 (문제 λ°œμƒμ‹œ 이전 λ‹¨κ³„λ‘œ λ‘€λ°±)
* - Exception Handler: 비상 λŒ€μ‘νŒ€ (μ˜ˆμ™Έ 상황 λ°œμƒμ‹œ μ μ ˆν•œ 쑰치)
*/
@Slf4j
@Configuration
@EnableAsync
@EnableTransactionManagement
public class AsyncTransactionConfig implements AsyncConfigurer {

@Value("${app.async.core-pool-size:10}")
private int corePoolSize;

@Value("${app.async.max-pool-size:50}")
private int maxPoolSize;

@Value("${app.async.queue-capacity:100}")
private int queueCapacity;

@Value("${app.async.keep-alive-seconds:60}")
private int keepAliveSeconds;

/**
* λ§ˆμΈλ“œλ§΅ μ „μš© 비동기 μ‹€ν–‰μž
* νŠΉμ§•:
* - μ½”μ–΄ ν’€ 크기: 10개 (항상 ν™œμ„± μƒνƒœμ˜ μŠ€λ ˆλ“œ)
* - μ΅œλŒ€ ν’€ 크기: 50개 (피크 μ‹œκ°„ λŒ€μ‘)
* - 큐 μš©λŸ‰: 100개 (λŒ€κΈ° 쀑인 μž‘μ—… 수)
* - κ±°λΆ€ μ •μ±…: CallerRuns (호좜자 μŠ€λ ˆλ“œμ—μ„œ 직접 μ‹€ν–‰)
*/
@Bean(name = "mindmapExecutor")
public Executor mindmapTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setKeepAliveSeconds(keepAliveSeconds);
executor.setThreadNamePrefix("Mindmap-Async-");

// κ±°λΆ€ μ •μ±…: 큐가 가득 μ°° λ•Œ 호좜자 μŠ€λ ˆλ“œμ—μ„œ μ‹€ν–‰
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

// μŠ€λ ˆλ“œ ν’€ μ΄ˆκΈ°ν™” λŒ€κΈ°
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);

executor.initialize();

log.info("λ§ˆμΈλ“œλ§΅ 비동기 μ‹€ν–‰μž μ΄ˆκΈ°ν™” μ™„λ£Œ - μ½”μ–΄: {}, μ΅œλŒ€: {}, 큐: {}",
corePoolSize, maxPoolSize, queueCapacity);

return executor;
}

/**
* 일반적인 비동기 μž‘μ—…μš© μ‹€ν–‰μž
*
* μ•Œλ¦Ό 전솑, 둜그 처리 λ“± κ°€λ²Όμš΄ μž‘μ—…μ— μ‚¬μš©
*/
@Bean(name = "generalExecutor")
public Executor generalTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(50);
executor.setKeepAliveSeconds(30);
executor.setThreadNamePrefix("General-Async-");

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);

executor.initialize();

log.info("일반 비동기 μ‹€ν–‰μž μ΄ˆκΈ°ν™” μ™„λ£Œ");
return executor;
}

/**
* κΈ°λ³Έ 비동기 μ‹€ν–‰μž (AsyncConfigurer μΈν„°νŽ˜μ΄μŠ€ κ΅¬ν˜„)
*/
@Override
public Executor getAsyncExecutor() {
return mindmapTaskExecutor();
}

/**
* 비동기 μ˜ˆμ™Έ 처리기
*
* λΉ„μœ : 곡μž₯의 μ•ˆμ „ κ΄€λ¦¬μž
* μž‘μ—… 쀑 μ˜ˆμ™Έκ°€ λ°œμƒν•˜λ©΄ μ¦‰μ‹œ νŒŒμ•…ν•˜κ³  κΈ°λ‘ν•˜μ—¬
* ν–₯ν›„ κ°œμ„ μ μ„ λ„μΆœν•  수 μžˆλ„λ‘ ν•©λ‹ˆλ‹€.
*
* μ‹€μ œ μ˜ˆμ‹œ:
* GitHub API 호좜 μ‹€νŒ¨, λ©”λͺ¨λ¦¬ λΆ€μ‘±, λ„€νŠΈμ›Œν¬ νƒ€μž„μ•„μ›ƒ λ“±
* λ‹€μ–‘ν•œ μ˜ˆμ™Έ 상황을 적절히 λ‘œκΉ…ν•˜κ³  λͺ¨λ‹ˆν„°λ§ν•©λ‹ˆλ‹€.
*/
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}

/**
* μ»€μŠ€ν…€ 비동기 μ˜ˆμ™Έ 처리기
*/
private static class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
String methodName = method.getDeclaringClass().getSimpleName() + "." + method.getName();

log.error("비동기 μž‘μ—… μ˜ˆμ™Έ λ°œμƒ - λ©”μ„œλ“œ: {}, λ§€κ°œλ³€μˆ˜: {}",
methodName, formatParams(params), ex);

// μ€‘μš”ν•œ μ˜ˆμ™Έμ˜ 경우 μΆ”κ°€ μ•Œλ¦Ό 처리
if (isCriticalException(ex)) {
handleCriticalException(ex, methodName, params);
}
}

private String formatParams(Object... params) {
if (params == null || params.length == 0) {
return "μ—†μŒ";
}

StringBuilder sb = new StringBuilder();
for (int i = 0; i < params.length; i++) {
if (i > 0) sb.append(", ");

Object param = params[i];
if (param == null) {
sb.append("null");
} else {
// 민감 정보 λ§ˆμŠ€ν‚Ή
String paramStr = param.toString();
if (paramStr.contains("password") || paramStr.contains("token")) {
sb.append("[MASKED]");
} else {
sb.append(paramStr.length() > 100 ?
paramStr.substring(0, 100) + "..." : paramStr);
}
}
}
return sb.toString();
}

private boolean isCriticalException(Throwable ex) {
return ex instanceof OutOfMemoryError ||
ex instanceof StackOverflowError ||
(ex.getMessage() != null && ex.getMessage().contains("database"));
}

private void handleCriticalException(Throwable ex, String methodName, Object... params) {
// 치λͺ…적 문제 λ°œμƒ μ•Œλ¦Ό
log.error("⚠️ 치λͺ…적 μ˜ˆμ™Έ λ°œμƒ - μ¦‰μ‹œ λŒ€μ‘ ν•„μš”: {} in {}", ex.getMessage(), methodName);


// TODO: λͺ¨λ‹ˆν„°λ§ μ‹œμŠ€ν…œ 연동
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.teamEWSN.gitdeun.common.config;

import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;
import org.hibernate.type.BasicType;
import org.hibernate.type.StandardBasicTypes;
import org.springframework.stereotype.Component;

@Component
public class MySqlFunctionContributor implements FunctionContributor {
@Override
public void contributeFunctions(FunctionContributions fc) {
BasicType<Double> doubleType =
fc.getTypeConfiguration().getBasicTypeRegistry().resolve(StandardBasicTypes.DOUBLE);

// match_against_boolean(title, content, query) -> MATCH(title, content) AGAINST (? IN BOOLEAN MODE)
fc.getFunctionRegistry().registerPattern(
"match_against_boolean",
"match(?1, ?2) against (?3 in boolean mode)",
doubleType
);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.teamEWSN.gitdeun.common.converter;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.*;
import java.io.IOException;
import java.time.*;
import java.time.format.DateTimeParseException;

public class IsoToLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String s = p.getText();
if (s == null || s.isBlank()) return null;

// 1) μ˜€ν”„μ…‹/Z 포함 β†’ UTC LDT둜
try {
return OffsetDateTime.parse(s).atZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
} catch (DateTimeParseException ignored) {}

// 2) Instant 순수 νŒŒμ‹± μ‹œλ„
try {
return Instant.parse(s).atZone(ZoneOffset.UTC).toLocalDateTime();
} catch (DateTimeParseException ignored) {}

// 3) μ˜€ν”„μ…‹ μ—†λŠ” LocalDateTime 포맷(λ ˆκ±°μ‹œ) λ°©μ–΄
return LocalDateTime.parse(s);
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ public enum ErrorCode {

// λ§ˆμΈλ“œλ§΅ κ΄€λ ¨
MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "μš”μ²­ν•œ λ§ˆμΈλ“œλ§΅μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."),
MINDMAP_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MINDMAP-002", "λ§ˆμΈλ“œλ§΅ 생성 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."),

// λ§ˆμΈλ“œλ§΅ μš”μ²­ 검증 κ΄€λ ¨
INVALID_USER_ID(HttpStatus.BAD_REQUEST, "VALIDATE-001", "μœ νš¨ν•˜μ§€ μ•Šμ€ μ‚¬μš©μž IDμž…λ‹ˆλ‹€."),
REPOSITORY_URL_REQUIRED(HttpStatus.BAD_REQUEST, "VALIDATE-002", "리포지토리 URL은 ν•„μˆ˜μž…λ‹ˆλ‹€."),
REPOSITORY_URL_TOO_LONG(HttpStatus.BAD_REQUEST, "VALIDATE-003", "리포지토리 URL이 λ„ˆλ¬΄ κΉλ‹ˆλ‹€. (1000자 이내)"),
MALICIOUS_URL_DETECTED(HttpStatus.BAD_REQUEST, "VALIDATE-004", "μ•…μ˜μ μΈ νŒ¨ν„΄μ΄ ν¬ν•¨λœ URLμž…λ‹ˆλ‹€."),
UNSUPPORTED_REPOSITORY_URL(HttpStatus.BAD_REQUEST, "VALIDATE-005", "μ§€μ›ν•˜μ§€ μ•ŠλŠ” 리포지토리 URL ν˜•μ‹μž…λ‹ˆλ‹€."),
INVALID_GITHUB_URL(HttpStatus.BAD_REQUEST, "VALIDATE-006", "μœ νš¨ν•˜μ§€ μ•Šμ€ GitHub URLμž…λ‹ˆλ‹€."),
INVALID_REPOSITORY_URL(HttpStatus.BAD_REQUEST, "VALIDATE-007", "μœ νš¨ν•˜μ§€ μ•Šμ€ 리포지토리 URLμž…λ‹ˆλ‹€."),
PROMPT_TOO_LONG(HttpStatus.BAD_REQUEST, "VALIDATE-008", "ν”„λ‘¬ν”„νŠΈκ°€ λ„ˆλ¬΄ κΉλ‹ˆλ‹€. (2000자 이내)"),
PROMPT_TOO_SHORT(HttpStatus.BAD_REQUEST, "VALIDATE-009", "ν”„λ‘¬ν”„νŠΈκ°€ λ„ˆλ¬΄ μ§§μŠ΅λ‹ˆλ‹€. (3자 이상)"),
MALICIOUS_PROMPT_DETECTED(HttpStatus.BAD_REQUEST, "VALIDATE-010", "μ•…μ˜μ μΈ νŒ¨ν„΄μ΄ ν¬ν•¨λœ ν”„λ‘¬ν”„νŠΈμž…λ‹ˆλ‹€."),
FORBIDDEN_REPOSITORY_PATTERN(HttpStatus.FORBIDDEN, "VALIDATE-011", "뢄석이 κΈˆμ§€λœ 리포지토리 νŒ¨ν„΄μž…λ‹ˆλ‹€. (예: test, demo)"),
SYSTEM_PROTECTED_REPOSITORY(HttpStatus.FORBIDDEN, "VALIDATE-012", "μ‹œμŠ€ν…œ 보호 λŒ€μƒμœΌλ‘œ μ§€μ •λœ λ¦¬ν¬μ§€ν† λ¦¬μž…λ‹ˆλ‹€."),

// ν”„λ‘¬ν”„νŠΈ νžˆμŠ€ν† λ¦¬ κ΄€λ ¨ (μ‹ κ·œ μΆ”κ°€)
PROMPT_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PROMPT-001", "μš”μ²­ν•œ ν”„λ‘¬ν”„νŠΈ νžˆμŠ€ν† λ¦¬λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."),
Expand Down
Loading