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 @@ -16,6 +16,7 @@ public class ApplicationListResponseDto {
private Long applicationId;

// μ§€μ›μž κ°„λž΅ 정보
private Long applicantId;
private String applicantName;
private String applicantNickname;
private String applicantProfileImage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public interface ApplicationMapper {
ApplicationResponseDto toResponseDto(Application application);

@Mapping(source = "id", target = "applicationId")
@Mapping(source = "applicant.id", target = "applicantId")
@Mapping(source = "applicant.name", target = "applicantName")
@Mapping(source = "applicant.nickname", target = "applicantNickname")
@Mapping(source = "applicant.profileImage", target = "applicantProfileImage")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,12 @@ public ResponseEntity<RecruitmentDetailResponseDto> getRecruitment(@PathVariable
*/
@GetMapping("/recruitments")
public ResponseEntity<Page<RecruitmentListResponseDto>> searchRecruitments(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) RecruitmentStatus status,
@RequestParam(required = false) List<RecruitmentField> field,
@PageableDefault(size = 10) Pageable pageable
) {
Page<RecruitmentListResponseDto> response = recruitmentService.searchRecruitments(status, field, pageable);
Page<RecruitmentListResponseDto> response = recruitmentService.searchRecruitments(keyword, status, field, pageable);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
indexes = {
@Index(name = "idx_recruitment_status", columnList = "status"),
@Index(name = "idx_recruitment_deadline", columnList = "end_at"),
@Index(name = "idx_recruitment_recruiter", columnList = "recruiter_id")
@Index(name = "idx_recruitment_recruiter", columnList = "recruiter_id"),
@Index(name = "idx_recruitment_status_end_at", columnList = "status, end_at"),
@Index(name = "idx_recruitment_created_at", columnList = "created_at")
})
@Getter
@Setter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

public interface RecruitmentCustomRepository {
Page<Recruitment> searchRecruitments(
String keyword,
RecruitmentStatus status,
List<RecruitmentField> fields,
Pageable pageable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
Expand All @@ -18,6 +18,7 @@ public interface RecruitmentRepository extends JpaRepository<Recruitment, Long>,
List<Recruitment> findAllByStatusAndEndAtBefore(RecruitmentStatus status, LocalDateTime now);

// λ‚΄ 곡고 λͺ©λ‘ 쑰회
@EntityGraph(attributePaths = {"user"})
Page<Recruitment> findByRecruiterId(Long recruiterId, Pageable pageable);

// μƒνƒœ 기반 쑰회(μΆ”μ²œ μ‹œ)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
package com.teamEWSN.gitdeun.Recruitment.repository;

import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment;
import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField;
import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static com.teamEWSN.gitdeun.Recruitment.entity.QRecruitment.recruitment;

@Slf4j
@Repository
@RequiredArgsConstructor
public class RecruitmentRepositoryImpl implements RecruitmentCustomRepository {
Expand All @@ -25,37 +33,160 @@ public class RecruitmentRepositoryImpl implements RecruitmentCustomRepository {

@Override
public Page<Recruitment> searchRecruitments(
RecruitmentStatus status, List<RecruitmentField> fields, Pageable pageable
String keyword, RecruitmentStatus status, List<RecruitmentField> fields, Pageable pageable
) {
// 1. 데이터 쑰회 (μ—”ν‹°ν‹° 자체λ₯Ό 쑰회)
List<Recruitment> content = queryFactory
.selectFrom(recruitment)
.distinct() // 쀑볡 제거
.leftJoin(recruitment.fieldTags).fetchJoin() // fetch join으둜 N+1 문제 λ°©μ§€
.leftJoin(recruitment.languageTags).fetchJoin() // fetch join
.where(statusEq(status), fieldIn(fields))
// ν‚€μ›Œλ“œ μ „μ²˜λ¦¬ ν›„ Full-Text Search ν™œμš© μ—¬λΆ€ 확인
String processed = preprocessKeyword(keyword);

// ν‚€μ›Œλ“œ μ—†μœΌλ©΄: ν‚€μ›Œλ“œ 쑰건 없이 status/fields만으둜 νŽ˜μ΄μ§€ 쑰회
boolean hasKeyword = (processed != null);
boolean useFullTextSearch = hasKeyword && isFullTextSearchAvailable(processed);

BooleanExpression keywordExpr = null;
if (hasKeyword) {
keywordExpr = useFullTextSearch ? titleFullTextSearch(processed)
: fallbackContains(processed);
}

// μ—”ν‹°ν‹° 자체 데이터 쑰회
// id νŽ˜μ΄μ§€λ‹
List<Long> ids = queryFactory.select(recruitment.id).distinct()
.from(recruitment)
.where(keywordExpr, statusEq(status), fieldOrFilter(fields))
.orderBy(useFullTextSearch ? scoreOrder(processed) : recruitment.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(recruitment.id.desc())
.fetch();

// 2. 전체 카운트 쑰회 (쑰건은 λ™μΌν•˜κ²Œ)
JPAQuery<Long> countQuery = queryFactory
.select(recruitment.count())
.from(recruitment)
.where(statusEq(status), fieldIn(fields));
if (ids.isEmpty()) return Page.empty(pageable);

// λ‚΄μš© λ‘œλ”© + μˆœμ„œ 볡원
String idCsv = ids.stream().map(String::valueOf).collect(Collectors.joining(","));
List<Recruitment> content = queryFactory.selectFrom(recruitment)
.where(recruitment.id.in(ids))
.orderBy(Expressions.numberTemplate(Integer.class,
"FIELD({0}, " + idCsv + ")", recruitment.id).asc())
.fetch();

Long total = countQuery.fetchOne();
// μΉ΄μš΄νŒ…
Long total = queryFactory.select(recruitment.id.countDistinct())
.from(recruitment)
.where(keywordExpr, statusEq(status), fieldOrFilter(fields))
.fetchOne();

return new PageImpl<>(content, pageable, total != null ? total : 0L);
}

// ν‚€μ›Œλ“œ μ „μ²˜λ¦¬
private String preprocessKeyword(String keyword) {
if (!StringUtils.hasText(keyword)) return null;

// μœ λ‹ˆμ½”λ“œ μ •κ·œν™”
String s = java.text.Normalizer.normalize(keyword, java.text.Normalizer.Form.NFKC);

// μ œμ–΄λ¬Έμž 제거: NULL(0x00), Ctrl-Z(0x1A), κ·Έ μ™Έ μ œμ–΄λ¬Έμž(νƒ­/κ°œν–‰ μ œμ™Έ)
s = s.replace("\u0000", "").replace("\u001A", "");
s = s.replaceAll("[\\p{Cntrl}&&[^\\t\\n\\r]]", "");

// 곡백 μ •κ·œν™”
s = s.trim().replaceAll("\\s+", " ");

// ν—ˆμš© 문자만 남기기 (ν•œκΈ€, 영문, 숫자, 곡백, + - * " ( ) )
s = s.replaceAll("[^κ°€-힣A-Za-z0-9\\s\\+\\-\\*\\\"\\(\\)]", "");

// λ”°μ˜΄ν‘œ κ· ν˜• 보정
int quoteCount = s.length() - s.replace("\"", "").length();
if ((quoteCount % 2) != 0) {
int last = s.lastIndexOf('"');
if (last >= 0) s = s.substring(0, last) + s.substring(last + 1);
}

// 길이 μ œν•œ
if (s.length() < 2) return null;
if (s.length() > 30) s = s.substring(0, 30);

return s;
}

// Full Text Search 쑰건 (μ „μ²˜λ¦¬ 이후 λ™μž‘)
private boolean isFullTextSearchAvailable(String keyword) {
if (!StringUtils.hasText(keyword)) {
return false;
}
return keyword.split("\\s+").length <= 5;
}

/**
* MySQL Full-Text Searchλ₯Ό μ‚¬μš©ν•œ 검색
* MATCH ... AGAINST ꡬ문 ν™œμš©
*/
private BooleanExpression titleFullTextSearch(String keyword) {
String booleanQuery = buildBooleanQuery(keyword);
return Expressions.booleanTemplate(
"MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)",
recruitment.title, recruitment.content, booleanQuery
);
}

/**
* κΈ°λ³Έ λΆ€λΆ„ λ¬Έμžμ—΄ 검색
*/
private BooleanExpression fallbackContains(String keyword) {
String k = keyword.trim();
return recruitment.title.containsIgnoreCase(k)
.or(recruitment.content.containsIgnoreCase(k));
}

// BOOLEAN MODE 정렬식
private OrderSpecifier<Double> scoreOrder(String keyword) {
String tpl = "MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)";
return Expressions.numberTemplate(Double.class, tpl,
recruitment.title, recruitment.content, buildBooleanQuery(keyword))
.desc();
}

// λ”°μ˜΄ν‘œ 보쑴 ν† ν¬λ‚˜μ΄μ € + λΉŒλ”
private static final Pattern TOKEN_PATTERN = Pattern.compile("\"([^\"]+)\"|(\\S+)");
private List<String> tokenizeRespectingQuotes(String s) {
List<String> tokens = new ArrayList<>();
Matcher m = TOKEN_PATTERN.matcher(s);
while (m.find()) {
String phrase = m.group(1);
String single = m.group(2);
if (phrase != null) tokens.add("\"" + phrase + "\"");
else if (single != null) tokens.add(single);
}
return tokens;
}
private String buildBooleanQuery(String keyword) {
List<String> parts = tokenizeRespectingQuotes(keyword.trim());
StringBuilder sb = new StringBuilder();
for (String p : parts) {
if (p.isBlank()) continue;

// μ™€μΌλ“œμΉ΄λ“œ κ³Όλ‹€ λ°©μ§€: μ€‘κ°„μ˜ **** β†’ * 둜 μΆ•μ•½, 토큰 쀑간 *λŠ” ν—ˆμš©
String normalized = p.replaceAll("\\*{2,}", "*");

boolean hasOpPrefix = p.startsWith("+") || p.startsWith("-") || p.startsWith("(");
boolean isPhrase = normalized.startsWith("\"") && normalized.endsWith("\"");

// 단독 "*" 같은 λ…Έμ΄μ¦ˆ 토큰 차단
if ("*".equals(normalized)) continue;

if (hasOpPrefix || isPhrase) sb.append(normalized).append(' ');
else sb.append('+').append(normalized).append(' ');
}
return sb.toString().trim();
}


private BooleanExpression statusEq(RecruitmentStatus status) {
return status != null ? recruitment.status.eq(status) : null;
}

private BooleanExpression fieldIn(List<RecruitmentField> fields) {
return !CollectionUtils.isEmpty(fields) ? recruitment.fieldTags.any().in(fields) : null;
private BooleanExpression fieldOrFilter(List<RecruitmentField> fields) {
return CollectionUtils.isEmpty(fields)
? null
: recruitment.fieldTags.any().in(fields);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,16 @@ public RecruitmentDetailResponseDto getRecruitment(Long recruitmentId) {

/**
* μƒνƒœ(status)와 λͺ¨μ§‘ λΆ„μ•Ό(field)λ₯Ό κΈ°μ€€μœΌλ‘œ λͺ¨μ§‘ 곡고λ₯Ό ν•„ν„°λ§ν•˜μ—¬ κ²€μƒ‰ν•©λ‹ˆλ‹€.
* @param status 검색할 λͺ¨μ§‘ μƒνƒœ (선택 사항)
* @param fields 검색할 λͺ¨μ§‘ λΆ„μ•Ό λͺ©λ‘ (선택 사항)
* @param keyword 제λͺ© 검색 ν‚€μ›Œλ“œ (선택 사항) - λΆ€λΆ„ λ¬Έμžμ—΄ λ§€μΉ­
* @param status 검색할 λͺ¨μ§‘ μƒνƒœ (선택 사항)
* @param fields 검색할 λͺ¨μ§‘ λΆ„μ•Ό λͺ©λ‘ (선택 사항)
* @param pageable νŽ˜μ΄μ§• 정보
* @return νŽ˜μ΄μ§• 처리된 검색 κ²°κ³Ό λͺ©λ‘
*/
@Transactional(readOnly = true)
public Page<RecruitmentListResponseDto> searchRecruitments(RecruitmentStatus status, List<RecruitmentField> fields, Pageable pageable) {
return recruitmentRepository.searchRecruitments(status, fields, pageable).map(recruitmentMapper::toListResponseDto);
public Page<RecruitmentListResponseDto> searchRecruitments(String keyword, RecruitmentStatus status, List<RecruitmentField> fields, Pageable pageable) {
return recruitmentRepository.searchRecruitments(keyword, status, fields, pageable)
.map(recruitmentMapper::toListResponseDto);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public enum ErrorCode {
// λ§ˆμΈλ“œλ§΅ κ΄€λ ¨
MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "μš”μ²­ν•œ λ§ˆμΈλ“œλ§΅μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."),

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

// 멀버 κ΄€λ ¨
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-001", "ν•΄λ‹Ή 멀버λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."),
MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER-002", "이미 λ§ˆμΈλ“œλ§΅μ— λ“±λ‘λœ λ©€λ²„μž…λ‹ˆλ‹€."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto;
import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto;
import com.teamEWSN.gitdeun.mindmap.entity.MindmapType;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Qualifier;
Expand Down Expand Up @@ -38,10 +37,24 @@ public LocalDateTime getRepositoryLastCommitTime(String repoUrl, String authoriz
}

/**
* FastAPI μ„œλ²„μ— 리포지토리 뢄석을 μš”μ²­ (AI 기반)
* FastAPI μ„œλ²„μ— 리포지토리 ν”„λ‘¬ν”„νŠΈ 기반 λ§ˆμΈλ“œλ§΅ 뢄석
*/
public AnalysisResultDto analyzeWithAi(String repoUrl, String prompt, MindmapType type, String authorizationHeader) {
AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt, type);
public AnalysisResultDto analyzeWithPrompt(String repoUrl, String prompt, String authorizationHeader) {
AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt);
return webClient.post()
.uri("/mindmap/analyze-prompt")
.header("Authorization", authorizationHeader)
.body(Mono.just(requestBody), AnalysisRequest.class)
.retrieve()
.bodyToMono(AnalysisResultDto.class)
.block();
}

/**
* κΈ°λ³Έ λ§ˆμΈλ“œλ§΅ 뢄석 (ν”„λ‘¬ν”„νŠΈ X)
*/
public AnalysisResultDto analyzeDefault(String repoUrl, String authorizationHeader) {
AnalysisRequest requestBody = new AnalysisRequest(repoUrl, null);
return webClient.post()
.uri("/mindmap/analyze-ai")
.header("Authorization", authorizationHeader)
Expand Down Expand Up @@ -113,8 +126,7 @@ public void deleteMindmapData(String repoUrl, String authorizationHeader) {
@AllArgsConstructor
private static class AnalysisRequest {
private String repo_url;
private String prompt;
private MindmapType mode; // FastAPI λͺ¨λΈμ˜ ν•„λ“œλͺ…(mode)κ³Ό 일치
private String prompt; // nullable
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
package com.teamEWSN.gitdeun.common.fastapi.dto;

import com.teamEWSN.gitdeun.mindmap.entity.MindmapType;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@Builder
public class AnalysisResultDto {
// Repo κ΄€λ ¨ 정보
private String defaultBranch;
private LocalDateTime githubLastUpdatedAt;

// Mindmap κ΄€λ ¨ 정보
private String mapData; // JSON ν˜•νƒœμ˜ λ§ˆμΈλ“œλ§΅ 데이터
private String field;
// private MindmapType mode;
// private String prompt;
private String title; // ν”„λ‘¬ν”„νŠΈ 및 mindmap 정보 μš”μ•½
private String errorMessage; // μ‹€νŒ¨ μ‹œ 전달될 μ—λŸ¬λ©”μ„Έμ§€
// TODO: FastAPI 응닡에 맞좰 ν•„λ“œ μ •μ˜
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
public class InvitationResponseDto {

private Long invitationId;
private String mindmapName;
private String mindmapTitle;
private String inviteeName;
private String inviteeEmail;
private MindmapRole role;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
public interface InvitationMapper {

@Mapping(source = "id", target = "invitationId")
@Mapping(source = "mindmap.field", target = "mindmapName")
@Mapping(source = "mindmap.title", target = "mindmapTitle")
@Mapping(source = "invitee.name", target = "inviteeName")
@Mapping(source = "invitee.email", target = "inviteeEmail", defaultExpression = "java(\"링크 μ΄ˆλŒ€\")")
InvitationResponseDto toResponseDto(Invitation invitation);
Expand Down
Loading