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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ dependencies {

//web
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.apache.poi:poi-ooxml:5.4.1'
implementation 'com.pgvector:pgvector:0.1.6'

//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.7'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@
public interface DetailClassificationRepository extends JpaRepository<DetailClassification, Long> {
List<DetailClassification> findAllByMiddleClassificationId(Long middleClassificationId);
Optional<DetailClassification> findByDetailName(String detailName);
long countByDetailName(String detailName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "DetailClassificationRepository.java"

Repository: JobDri-Developer/BackEnd

Length of output: 175


🏁 Script executed:

find . -type f -name "CorpusClassificationResolver.java"

Repository: JobDri-Developer/BackEnd

Length of output: 162


🏁 Script executed:

find . -type f -name "*.java" | grep -i "classification" | head -20

Repository: JobDri-Developer/BackEnd

Length of output: 1908


🏁 Script executed:

cat -n src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java

Repository: JobDri-Developer/BackEnd

Length of output: 2841


🏁 Script executed:

cat -n src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java

Repository: JobDri-Developer/BackEnd

Length of output: 4267


Use case-insensitive detail-name methods for fallback resolution.

countByDetailName at line 15 and findByDetailName at line 14 are case-sensitive derived queries. In CorpusClassificationResolver (lines 57-59), this causes the fallback resolution to incorrectly skip a unique match when only casing differs (e.g., Software Engineer stored but software engineer searched). The normalize() method only trims whitespace, not case.

Replace with case-insensitive variants:

Suggested fix
--- a/src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java
@@
-    Optional<DetailClassification> findByDetailName(String detailName);
-    long countByDetailName(String detailName);
+    Optional<DetailClassification> findByDetailNameIgnoreCase(String detailName);
+    long countByDetailNameIgnoreCase(String detailName);
--- a/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java
+++ b/src/main/java/com/jobdri/jobdri_api/domain/corpus/service/CorpusClassificationResolver.java
@@
-        if (detailClassificationRepository.countByDetailName(normalizedRole) == 1) {
-            return detailClassificationRepository.findByDetailName(normalizedRole);
+        if (detailClassificationRepository.countByDetailNameIgnoreCase(normalizedRole) == 1) {
+            return detailClassificationRepository.findByDetailNameIgnoreCase(normalizedRole);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/classification/repository/DetailClassificationRepository.java`
at line 15, The methods countByDetailName and findByDetailName in
DetailClassificationRepository are case-sensitive derived queries, which causes
the fallback resolution in CorpusClassificationResolver to miss matches when
only the casing differs (e.g., searching for "software engineer" when "Software
Engineer" is stored). Replace these case-sensitive derived query methods with
case-insensitive variants by using the appropriate JPA query syntax that
performs case-insensitive comparisons, ensuring that detail names are matched
regardless of letter casing.


@Query("""
SELECT dc
FROM DetailClassification dc
JOIN dc.middleClassification mc
JOIN mc.classification c
WHERE lower(c.bigName) = lower(:bigName)
AND lower(mc.middleName) = lower(:middleName)
AND lower(dc.detailName) = lower(:detailName)
""")
Optional<DetailClassification> findByHierarchyNames(
@Param("bigName") String bigName,
@Param("middleName") String middleName,
@Param("detailName") String detailName
);

@Query(value = """
SELECT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.jobdri.jobdri_api.domain.corpus.controller;

import com.jobdri.jobdri_api.domain.corpus.dto.request.CorpusEmbeddingSyncRequest;
import com.jobdri.jobdri_api.domain.corpus.dto.request.CorpusImportRequest;
import com.jobdri.jobdri_api.domain.corpus.dto.response.CorpusEmbeddingSyncResponse;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusEmbeddingSyncService;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusImportResult;
import com.jobdri.jobdri_api.domain.corpus.service.CorpusImportService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.nio.file.Path;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/corpus")
@Tag(name = "CorpusAdmin", description = "관리자용 corpus 적재/임베딩 API")
public class CorpusAdminController {

private final CorpusImportService corpusImportService;
private final CorpusEmbeddingSyncService corpusEmbeddingSyncService;

@Operation(summary = "corpus 엑셀 적재", description = "관리자가 xlsx 파일 경로를 넘겨 corpus 원본 테이블에 적재합니다.")
@PostMapping("/import")
public ApiResponse<CorpusImportResult> importCorpus(
@Valid @RequestBody CorpusImportRequest request
) throws IOException {
return ApiResponse.onSuccess(
"corpus 엑셀 적재에 성공했습니다.",
corpusImportService.importFromXlsx(Path.of(request.filePath()))
);
Comment on lines +33 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Constrain import file paths to an approved base directory.

Line 38 accepts a raw server filesystem path from the request; this creates an arbitrary local file access surface for admin tokens. Enforce normalization and allowlist a configured import root.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/corpus/controller/CorpusAdminController.java`
around lines 33 - 39, The importCorpus method in CorpusAdminController accepts a
raw filesystem path directly from the request parameter request.filePath()
without validation, creating a path traversal vulnerability where admin tokens
could access arbitrary files on the server. Fix this by normalizing the provided
file path using Path.of().normalize() or similar canonicalization, then validate
that the normalized path is within an approved configured import root directory
(using a whitelist approach such as startsWith checks against the base
directory). Only proceed with corpusImportService.importFromXlsx() if the path
validation succeeds; otherwise, reject the request with an appropriate error
response.

}

@Operation(summary = "corpus 임베딩 동기화", description = "유효한 corpus 데이터를 읽어 pgvector 테이블에 임베딩을 저장합니다.")
@PostMapping("/embeddings/sync")
public ApiResponse<CorpusEmbeddingSyncResponse> syncEmbeddings(
@Valid @RequestBody CorpusEmbeddingSyncRequest request
) {
return ApiResponse.onSuccess(
"corpus 임베딩 동기화에 성공했습니다.",
corpusEmbeddingSyncService.syncAll(request.limit())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.jobdri.jobdri_api.domain.corpus.dto.request;

import jakarta.validation.constraints.Positive;

public record CorpusEmbeddingSyncRequest(
@Positive(message = "limit는 1 이상이어야 합니다.")
Integer limit
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.jobdri.jobdri_api.domain.corpus.dto.request;

import jakarta.validation.constraints.NotBlank;

public record CorpusImportRequest(
@NotBlank(message = "엑셀 파일 경로는 필수입니다.")
String filePath
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.jobdri.jobdri_api.domain.corpus.dto.response;

public record CorpusEmbeddingSyncResponse(
int jobPostingEmbeddingsUpserted,
int questionEmbeddingsUpserted,
String embeddingModel
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.jobdri.jobdri_api.domain.corpus.entity;

import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(access = AccessLevel.PRIVATE)
@Table(
name = "corpus_classification_mappings",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_corpus_classification_mapping_source_triplet",
columnNames = {"source_job_group_l1", "source_job_family_l2", "source_role_l3"}
)
}
)
public class CorpusClassificationMapping {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "source_job_group_l1", nullable = false, columnDefinition = "TEXT")
private String sourceJobGroupL1;

@Column(name = "source_job_family_l2", nullable = false, columnDefinition = "TEXT")
private String sourceJobFamilyL2;

@Column(name = "source_role_l3", nullable = false, columnDefinition = "TEXT")
private String sourceRoleL3;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "detail_classification_id", nullable = false)
private DetailClassification detailClassification;

@Column(nullable = false)
private LocalDateTime createdAt;

public static CorpusClassificationMapping create(
String sourceJobGroupL1,
String sourceJobFamilyL2,
String sourceRoleL3,
DetailClassification detailClassification
) {
return CorpusClassificationMapping.builder()
.sourceJobGroupL1(sourceJobGroupL1)
.sourceJobFamilyL2(sourceJobFamilyL2)
.sourceRoleL3(sourceRoleL3)
.detailClassification(detailClassification)
.createdAt(LocalDateTime.now())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.jobdri.jobdri_api.domain.corpus.entity;

import com.jobdri.jobdri_api.domain.company.entity.Company;
import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(access = AccessLevel.PRIVATE)
@Table(
name = "mock_job_posting_corpus",
indexes = {
@Index(name = "idx_mock_job_posting_corpus_source_analysis", columnList = "source_analysis_id"),
@Index(name = "idx_mock_job_posting_corpus_company", columnList = "company_id"),
@Index(name = "idx_mock_job_posting_corpus_classification", columnList = "job_group_l1, job_family_l2, role_l3")
}
)
public class MockJobPostingCorpus {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "source_analysis_id", nullable = false, length = 100, unique = true)
private String sourceAnalysisId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "company_id")
private Company company;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "detail_classification_id")
private DetailClassification detailClassification;

@Column(name = "company_name", columnDefinition = "TEXT")
private String companyName;

@Column(columnDefinition = "TEXT")
private String industry;

@Column(name = "job_group_l1", columnDefinition = "TEXT")
private String jobGroupL1;

@Column(name = "job_family_l2", columnDefinition = "TEXT")
private String jobFamilyL2;

@Column(name = "role_l3", columnDefinition = "TEXT")
private String roleL3;

@Column(columnDefinition = "TEXT")
private String skills;

@Column(name = "responsibilities", columnDefinition = "TEXT")
private String responsibilities;

@Column(name = "requirements", columnDefinition = "TEXT")
private String requirements;

@Column(name = "preferred", columnDefinition = "TEXT")
private String preferred;

@Column(name = "embedding_text", nullable = false, columnDefinition = "TEXT")
private String embeddingText;

@Column(name = "is_valid_for_embedding", nullable = false)
private boolean validForEmbedding;

@Column(name = "invalid_reason", columnDefinition = "TEXT")
private String invalidReason;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

public static MockJobPostingCorpus create(
String sourceAnalysisId,
Company company,
DetailClassification detailClassification,
String companyName,
String industry,
String jobGroupL1,
String jobFamilyL2,
String roleL3,
String skills,
String responsibilities,
String requirements,
String preferred,
String embeddingText,
boolean validForEmbedding,
String invalidReason
) {
return MockJobPostingCorpus.builder()
.sourceAnalysisId(sourceAnalysisId)
.company(company)
.detailClassification(detailClassification)
.companyName(companyName)
.industry(industry)
.jobGroupL1(jobGroupL1)
.jobFamilyL2(jobFamilyL2)
.roleL3(roleL3)
.skills(skills)
.responsibilities(responsibilities)
.requirements(requirements)
.preferred(preferred)
.embeddingText(embeddingText)
.validForEmbedding(validForEmbedding)
.invalidReason(invalidReason)
.createdAt(LocalDateTime.now())
.build();
}

public void assignCompany(Company company) {
this.company = company;
}

public void assignDetailClassification(DetailClassification detailClassification) {
this.detailClassification = detailClassification;
}

public void updateFromImport(
Company company,
DetailClassification detailClassification,
String companyName,
String industry,
String jobGroupL1,
String jobFamilyL2,
String roleL3,
String skills,
String responsibilities,
String requirements,
String preferred,
String embeddingText,
boolean validForEmbedding,
String invalidReason
) {
this.company = company;
this.detailClassification = detailClassification;
this.companyName = companyName;
this.industry = industry;
this.jobGroupL1 = jobGroupL1;
this.jobFamilyL2 = jobFamilyL2;
this.roleL3 = roleL3;
this.skills = skills;
this.responsibilities = responsibilities;
this.requirements = requirements;
this.preferred = preferred;
this.embeddingText = embeddingText;
this.validForEmbedding = validForEmbedding;
this.invalidReason = invalidReason;
}
}
Loading
Loading