Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9d67bcb
[Feat] #204 OCR 전용 ErrorCode, 예외 클래스 구현
jeong1112 Mar 26, 2026
8ef6754
[Refactor] #204 OCR 과정에서 발생한 예외가 ClovaException을 던지도록 수정
jeong1112 Mar 26, 2026
62ff2d8
[Feat] #204 문서 처리용 예외 클래스, errorCode 구현
jeong1112 Mar 26, 2026
89d1164
[Refactor] #204 문서 처리 담당 클래스 패키지 이동
jeong1112 Mar 26, 2026
5934326
[Refactor] #204 문서에서 텍스트 추출 파일 형식에 따라 분기처리
jeong1112 Mar 26, 2026
293a86a
[Feat] #204 VisaType에 매핑 메서드 추가
jeong1112 Mar 26, 2026
f5e3273
[Feat] #204 비자 문서로부터 비자 정보 추출 로직 구현
jeong1112 Mar 26, 2026
121f292
[Feat] #204 OCR을 통한 비자 정보 추출 로직 구현
jeong1112 Mar 26, 2026
b9ad8e6
[Fix] #204 날짜 파싱 오류 수정
jeong1112 Mar 26, 2026
3e2a3c5
[Refactor] #204 OCR 메서드 모든 이미지 유형에 적용
jeong1112 Mar 26, 2026
126562c
[Feat] #204 텍스트 파싱 시에 사용되는 유틸 함수 클래스로 분리
jeong1112 Mar 26, 2026
2d9b876
[Chore] #204 문서 처리 시 예외를 하위 클래스로 위임
jeong1112 Mar 26, 2026
4460f27
[Feat] #204 MRZ 규격에 맞는 나라 이름으로 매핑하는 클래스 구현
jeong1112 Mar 26, 2026
7a0f587
[Feat] #204 여권에서 정보 추출 메서드 구현
jeong1112 Mar 26, 2026
d83ee73
[Feat] #204 여권을 통해 사용자 정보 내려주는 로직 구현
jeong1112 Mar 26, 2026
909e6bb
[Chore] #204 불필요한 로그 제거
jeong1112 Mar 26, 2026
0e035dc
[Refactor] #204 컨트롤러에서 예외 던지지 않도록 수정
jeong1112 Mar 26, 2026
acc18ce
[Chore] #204 생년월일 파싱 시 대문자 변환 로직 제거
jeong1112 Mar 26, 2026
a302bce
[Refactor] #204 생년월일 계산 로직 수정
jeong1112 Mar 26, 2026
a136e09
[Refactor] #204 OCR 안정성을 위한 입력 포맷 제한/정책 변경
jeong1112 Mar 26, 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
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import lombok.RequiredArgsConstructor;
import org.sopt.kareer.domain.jobposting.exception.JobPostingException;
import org.sopt.kareer.global.external.clova.service.DocumentProcessingService;
import org.sopt.kareer.global.document.service.DocumentProcessingService;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.util.List;

import static org.sopt.kareer.domain.jobposting.exception.JobPostingErrorCode.RESUME_CONTEXT_FAILED;
Expand All @@ -32,24 +31,15 @@ public String buildContext(List<MultipartFile> files) {
sb.append("[RESUME_COVER_LETTER]\n");

for (MultipartFile file : files) {
File temp = null;

try {
temp = File.createTempFile("resume_", ".pdf");
file.transferTo(temp);

String text = documentProcessingService.extractTextWithOcr(temp);
String text = documentProcessingService.extractText(file);

sb.append("----- FILE START -----\n");
sb.append(text).append("\n");
sb.append("----- FILE END -----\n\n");

} catch (Exception e) {
throw new JobPostingException(RESUME_CONTEXT_FAILED, e.getMessage());
} finally {
if (temp != null && temp.exists()) {
temp.delete();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package org.sopt.kareer.domain.member.controller;


import static org.sopt.kareer.global.config.swagger.SwaggerResponseDescription.*;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -12,7 +10,9 @@
import org.sopt.kareer.domain.member.dto.request.MemberOnboardRequest;
import org.sopt.kareer.domain.member.dto.request.MypageRequest;
import org.sopt.kareer.domain.member.dto.response.*;
import org.sopt.kareer.domain.member.entity.constants.*;
import org.sopt.kareer.domain.member.entity.constants.Field;
import org.sopt.kareer.domain.member.entity.constants.Major;
import org.sopt.kareer.domain.member.entity.constants.University;
import org.sopt.kareer.domain.member.entity.enums.Country;
import org.sopt.kareer.domain.member.service.MemberService;
import org.sopt.kareer.domain.roadmap.dto.response.RoadmapTestResponse;
Expand All @@ -23,9 +23,13 @@
import org.sopt.kareer.global.config.swagger.SwaggerResponseDescription;
import org.sopt.kareer.global.response.BaseResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import static org.sopt.kareer.global.config.swagger.SwaggerResponseDescription.*;

@RestController
@RequiredArgsConstructor
Expand Down Expand Up @@ -155,4 +159,21 @@ public ResponseEntity<BaseResponse<Void>> deleteMember(@AuthenticationPrincipal
.body(BaseResponse.ok("회원 탈퇴에 성공하였습니다."));
}

@Operation(summary = "온보딩 비자 OCR API", description = "온보딩 과정에서 유저의 비자 문서를 분석하여 정보를 추출합니다.")
@PostMapping(value = "/onboard/ocr/visa", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<BaseResponse<OcrVisaResponse>> getVisaInfo(
@RequestPart("file") MultipartFile file){
return ResponseEntity.status(HttpStatus.OK)
.body(BaseResponse.ok(memberService.getVisaOcr(file), "사용자 비자 정보 추출에 성공했습니다."));
}

@Operation(summary = "온보딩 여권 OCR API", description = "온보딩 과정에서 유저의 여권을 분석하여 정보를 추출합니다.")
@PostMapping(value = "/onboard/ocr/passport", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<BaseResponse<OcrPassportResponse>> getPassportInfo(
@RequestPart("file") MultipartFile file){
return ResponseEntity.status(HttpStatus.OK)
.body(BaseResponse.ok(memberService.getPassportOcr(file), "사용자 여권 정보 추출에 성공했습니다."));
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.sopt.kareer.domain.member.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import org.sopt.kareer.domain.member.entity.enums.Country;
import org.sopt.kareer.domain.member.util.PassportOcrParser;

import java.time.LocalDate;

@Builder
public record OcrPassportResponse(
@Schema(description = "Full Name", example = "Hong Seungwon")
String fullName,

@Schema(description = "국가", example = "AFGHANISTAN")
Country country,

@Schema(description = "생년월일")
LocalDate birthDate
) {
public static OcrPassportResponse from(PassportOcrParser.PassportInfo passportInfo) {
return OcrPassportResponse.builder()
.fullName(passportInfo.fullName())
.country(passportInfo.country())
.birthDate(passportInfo.birthDate())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.sopt.kareer.domain.member.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import org.sopt.kareer.domain.member.entity.enums.VisaType;
import org.sopt.kareer.domain.member.util.VisaOcrParser;

import java.time.LocalDate;

@Builder
public record OcrVisaResponse(
@Schema(description = "비자 유형")
VisaType visaType,

@Schema(description = "비자 발급일")
LocalDate visaStartDate,

@Schema(description = "비자 만료일")
LocalDate visaExpiredAt
){
public static OcrVisaResponse from(VisaOcrParser.VisaInfo visaInfo) {
return OcrVisaResponse.builder()
.visaType(visaInfo.visaType())
.visaStartDate(visaInfo.visaStartDate())
.visaExpiredAt(visaInfo.visaExpiredAt())
.build();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,25 @@ public enum VisaType {
;

private final String description;

public static VisaType from(String originalText) {
if (originalText == null || originalText.isBlank()) {
return null;
}

String normalized = normalize(originalText);

for (VisaType visaType : values()) {
if (normalize(visaType.name()).equals(normalized)
|| normalize(visaType.description).equals(normalized)) {
return visaType;
}
}

return null;
}

private static String normalize(String value) {
return value.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
package org.sopt.kareer.domain.member.service;

import lombok.RequiredArgsConstructor;
import org.sopt.kareer.domain.member.dto.request.*;
import org.sopt.kareer.domain.member.dto.request.MemberOnboardRequest;
import org.sopt.kareer.domain.member.dto.request.MemberOnboardV2Request;
import org.sopt.kareer.domain.member.dto.response.*;
import org.sopt.kareer.domain.member.entity.*;
import org.sopt.kareer.domain.member.entity.Member;
import org.sopt.kareer.domain.member.entity.MemberVisa;
import org.sopt.kareer.domain.member.entity.enums.MemberStatus;
import org.sopt.kareer.domain.member.exception.*;
import org.sopt.kareer.domain.member.repository.*;
import org.sopt.kareer.domain.member.exception.MemberErrorCode;
import org.sopt.kareer.domain.member.exception.MemberException;
import org.sopt.kareer.domain.member.repository.MemberRepository;
import org.sopt.kareer.domain.member.repository.MemberVisaRepository;
import org.sopt.kareer.domain.member.service.dto.request.MypageCommand;
import org.sopt.kareer.domain.member.util.PassportOcrParser;
import org.sopt.kareer.domain.member.util.VisaOcrParser;
import org.sopt.kareer.global.document.exception.DocumentErrorCode;
import org.sopt.kareer.global.document.exception.DocumentException;
import org.sopt.kareer.global.document.service.DocumentProcessingService;
import org.sopt.kareer.global.exception.customexception.GlobalException;
import org.sopt.kareer.global.exception.errorcode.GlobalErrorCode;
import org.sopt.kareer.global.oauth.dto.OAuthAttributes;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
Expand All @@ -23,6 +33,9 @@ public class MemberService {
private final MemberRepository memberRepository;
private final MemberVisaRepository memberVisaRepository;
private final MemberDeletionService memberDeletionService;
private final DocumentProcessingService documentProcessingService;
private final VisaOcrParser visaOcrParser;
private final PassportOcrParser passportOcrParser;

public Member getById(Long memberId) {
return memberRepository.findById(memberId)
Expand Down Expand Up @@ -166,4 +179,35 @@ public void deleteMember(Long memberId) {
Member member = getById(memberId);
memberDeletionService.deleteMember(member);
}


public OcrVisaResponse getVisaOcr(MultipartFile file){
try {
String text = documentProcessingService.extractText(file);
VisaOcrParser.VisaInfo visaInfo = visaOcrParser.parse(text);

return OcrVisaResponse.from(visaInfo);
} catch (DocumentException e) {
throw e;
} catch(Exception e) {
throw new DocumentException(
DocumentErrorCode.OCR_PROCESSING_FAILED
);
}
}

public OcrPassportResponse getPassportOcr(MultipartFile file) {
try {
String text = documentProcessingService.extractText(file);
PassportOcrParser.PassportInfo passportInfo = passportOcrParser.parse(text);

return OcrPassportResponse.from(passportInfo);
} catch (DocumentException e) {
throw e;
} catch(Exception e) {
throw new DocumentException(
DocumentErrorCode.OCR_PROCESSING_FAILED
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.sopt.kareer.domain.member.util;

import org.sopt.kareer.domain.member.entity.enums.Country;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

@Component
public class CountryResolver {

private final Map<String, Country> ISO3_MAP;

public CountryResolver() {
ISO3_MAP = buildIso3Map();
}

private Map<String, Country> buildIso3Map() {
Map<String, Country> map = new HashMap<>();

for (Country country : Country.values()) {
try {
Locale locale = findLocaleByCountryName(country.getCountryName());

if (locale != null) {
String iso3 = locale.getISO3Country();
map.put(iso3, country);
}

} catch (Exception ignored) {
}
}

return map;
}

private Locale findLocaleByCountryName(String countryName) {
for (Locale locale : Locale.getAvailableLocales()) {
if (countryName.equalsIgnoreCase(locale.getDisplayCountry(Locale.ENGLISH))) {
return locale;
}
}
return null;
}

public Country resolveIso3(String iso3) {
if (iso3 == null) return null;
return ISO3_MAP.get(iso3.toUpperCase());
}
}
Loading
Loading