Skip to content

Commit cf4d54b

Browse files
authored
Merge pull request #134 from KB-Hackerton/feat/82
feat: 홈화면 공고추천
2 parents 21667de + f7b3b6d commit cf4d54b

File tree

13 files changed

+307
-5
lines changed

13 files changed

+307
-5
lines changed

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,7 @@ dependencies {
9090
tasks.named('test') {
9191
useJUnitPlatform()
9292
}
93+
94+
springBoot {
95+
mainClass = 'kb_hack.backend.DemoApplication'
96+
}

src/main/java/kb_hack/backend/domain/announce/mapper/AnnounceMapper.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
import org.apache.ibatis.annotations.Param;
77

88
import java.util.List;
9+
import java.util.Optional;
910

1011
@Mapper
1112
public interface AnnounceMapper {
1213
void insertAnnounce(Announce announce);
14+
1315
List<Announce> findAll();
16+
1417
Announce findById(@Param("announceId") Long announceId);
1518

19+
Optional<Announce> findById2(@Param("id") Long id);
20+
1621
List<AnnounceRankingDTO> findTopAnnounces(@Param("limit") int limit);
1722

1823
AnnounceRankingDTO findByAnnounceId(Long announceId);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package kb_hack.backend.domain.business;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
10+
11+
@Data
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Builder
15+
public class BusinessPlus {
16+
private Long businessId;
17+
private Long businessClassId;
18+
private String businessNm;
19+
private String businessAddr;
20+
private String businessAddrDetail;
21+
private String businessCode;
22+
private LocalDate businessOpenDate;
23+
private LocalDateTime createdAt;
24+
private Long memberId;
25+
26+
// These fields are for mapping data from the JOIN query in the mapper
27+
private String businessClassMajorName;
28+
private String businessClassMiddleName;
29+
private String businessClassMinorName;
30+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
package kb_hack.backend.domain.business.mapper;
22

3+
import kb_hack.backend.domain.business.BusinessPlus;
34
import kb_hack.backend.domain.business.dto.BusinessDTO;
45
import kb_hack.backend.domain.member.dto.request.MemberInfoRequestDTO;
56
import org.apache.ibatis.annotations.Mapper;
67
import org.apache.ibatis.annotations.Param;
78

9+
import java.util.Optional;
10+
811
@Mapper
912
public interface BusinessMapper {
1013
int insertBusiness(BusinessDTO businessVO);
1114
long findBusinessClassIdByMinorname(String minorName);
1215
int updateBusiness(@Param("dto") MemberInfoRequestDTO dto,
1316
@Param("businessId") Long businessId);
1417
String findMinorNameByBusinessId(Long businessId);
18+
19+
// 📢 AI 추천 서비스에서 사용할 새로운 메서드
20+
Optional<BusinessPlus> findBusinessAndClassInfoByMemberId(@Param("memberId") Long memberId);
1521
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package kb_hack.backend.domain.openAI.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import kb_hack.backend.domain.announce.Announce;
7+
import kb_hack.backend.domain.openAI.service.AiRecommendationService;
8+
import kb_hack.backend.global.common.exception.enums.SuccessStatusCode;
9+
import kb_hack.backend.global.common.response.success.SuccessResponse;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.web.bind.annotation.GetMapping;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
import org.springframework.web.bind.annotation.RequestParam;
14+
import org.springframework.web.bind.annotation.RestController;
15+
import reactor.core.publisher.Mono;
16+
17+
@RestController
18+
@RequestMapping("/api/recommendation")
19+
@RequiredArgsConstructor
20+
@Tag(name = "AI 추천 API", description = "AI 기반으로 맞춤형 지원사업을 추천합니다.")
21+
public class AiRecommendationController {
22+
23+
private final AiRecommendationService aiRecommendationService;
24+
25+
@Operation(
26+
summary = "AI 기반 공고 추천",
27+
description = "사용자 정보에 기반하여 AI가 가장 적합한 공고 1개를 추천합니다.",
28+
security = @SecurityRequirement(name = "bearerAuth")
29+
)
30+
@GetMapping
31+
public SuccessResponse<Announce> getRecommendedAnnounce(@RequestParam Long memberId) {
32+
// Service는 동기적으로 Announce 객체를 반환합니다.
33+
Announce recommendedAnnounce = aiRecommendationService.recommendAnnounce(memberId);
34+
35+
if (recommendedAnnounce == null) {
36+
// 추천 실패 시 적절한 예외 또는 응답 처리
37+
throw new RuntimeException("AI 추천에 적합한 공고를 찾을 수 없습니다.");
38+
}
39+
40+
return SuccessResponse.makeResponse(
41+
SuccessStatusCode.RECOMMENDATION_SUCCESS,
42+
recommendedAnnounce
43+
);
44+
}
45+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package kb_hack.backend.domain.openAI.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Data;
5+
6+
import java.util.List;
7+
import java.util.Map;
8+
9+
@Data
10+
@Builder
11+
public class AiRecommendationRequestDto {
12+
private String model;
13+
private List< Map<String, String>> input;
14+
//role , content
15+
private float temperature;
16+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package kb_hack.backend.domain.openAI.service;
2+
3+
import kb_hack.backend.domain.announce.Announce;
4+
import kb_hack.backend.domain.announce.mapper.AnnounceMapper;
5+
import kb_hack.backend.domain.business.BusinessPlus;
6+
import kb_hack.backend.domain.business.mapper.BusinessMapper;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Service;
10+
import reactor.core.publisher.Flux;
11+
import reactor.core.publisher.Mono;
12+
import reactor.util.retry.Retry;
13+
14+
import java.time.Duration;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import java.util.stream.Collectors;
18+
19+
@Slf4j
20+
@Service
21+
@RequiredArgsConstructor
22+
public class AiRecommendationService {
23+
24+
private final AiService openAiService;
25+
private final BusinessMapper businessMapper; // 사업장 정보
26+
private final AnnounceMapper announceMapper; // 전체 공고 목록
27+
28+
// DB 조회
29+
public Announce recommendAnnounce(Long memberId){
30+
BusinessPlus userBusiness = businessMapper.findBusinessAndClassInfoByMemberId(memberId)
31+
.orElseThrow(() -> new IllegalArgumentException("사용자 사업장 정보를 찾을 수 없습니다."));
32+
33+
List<Announce> activeAnnouncements = announceMapper.findAll();
34+
35+
String businessInfo = String.format(
36+
"사용자 사업장 : %s , %s",
37+
userBusiness.getBusinessNm(),
38+
userBusiness.getBusinessAddr()
39+
);
40+
41+
String announcesInfo = activeAnnouncements.stream()
42+
.map(a -> {
43+
// DB 데이터가 길기 때문에, 공고 설명을 HTML 제거 후 50자 이내로 요약
44+
String plainDesc = a.getDescription().replaceAll("<[^>]*>", "").replaceAll("\\s+", " ").trim();
45+
if (plainDesc.length() > 50) {
46+
plainDesc = plainDesc.substring(0, 50) + "...";
47+
}
48+
return String.format("ID:%d, 제목:%s, 요약설명:%s", a.getAnnounceId(), a.getAnnounceTitle(), plainDesc);
49+
})
50+
.collect(Collectors.joining("\n"));
51+
log.info(announcesInfo);
52+
53+
String aiPrompt = String.join("\n",
54+
"사업장 정보", businessInfo,
55+
" 지원사업 목록 ", announcesInfo
56+
);
57+
58+
String recommendedIdString = openAiService.getAiResponseId(aiPrompt);
59+
60+
// 4. 최종 ID로 DB에서 공고 객체 조회 및 반환
61+
if (recommendedIdString != null) {
62+
try {
63+
Long finalId = Long.parseLong(recommendedIdString.trim());
64+
return announceMapper.findById2(finalId) // 최종 공고 조회
65+
.orElse(null);
66+
} catch (NumberFormatException e) {
67+
log.error("AI 응답이 유효한 ID 숫자가 아닙니다: {}", recommendedIdString, e);
68+
}
69+
}
70+
71+
return null;
72+
}
73+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package kb_hack.backend.domain.openAI.service;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import kb_hack.backend.domain.openAI.dto.AiRecommendationRequestDto;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Qualifier;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.web.reactive.function.client.WebClient;
11+
import reactor.core.publisher.Mono;
12+
13+
import java.util.HashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
17+
//webclinet => openAI 요청 => 동기적 결과 기다림
18+
@Service
19+
@Slf4j
20+
public class AiService {
21+
22+
@Value("${openai.api.model}")
23+
private String apiModel;
24+
25+
private final WebClient webClient;
26+
27+
public AiService(@Qualifier("openAiWebClient") WebClient webClient, @Value("${openai.api.model}") String apiModel) {
28+
this.webClient = webClient;
29+
this.apiModel = apiModel;
30+
}
31+
32+
public String getAiResponseId(String fullPrompt) {
33+
34+
Map<String, String> systemMsg = Map.of(
35+
"role", "system", "content",
36+
" 당신은 소상공인 지원사업 전문 컨설턴트입니다. 사용자 사업장 정보와 제공된 공고지원사업 목록을 분석하여 가장 적합한 공고 하나를 찾아 그 공고의 id만 답변해 주세요. 다른 말은 필요없고 id만 숫자로 반환하세요."
37+
);
38+
Map<String, String> userMsg = Map.of("role", "user", "content", fullPrompt);
39+
40+
//요청 DTO 생성
41+
AiRecommendationRequestDto request = AiRecommendationRequestDto.builder()
42+
.model(apiModel)
43+
.input(List.of(systemMsg, userMsg))
44+
.temperature(0.5f)
45+
.build();
46+
47+
try{
48+
Map responseBody = webClient.post()
49+
.bodyValue(request) //DTO -> JSON 으로 변환해서 전송
50+
.retrieve()
51+
.bodyToMono(Map.class)
52+
.block(); // 동기적 기다림
53+
// 응답 파싱
54+
if(responseBody.containsKey("output") && responseBody !=null){
55+
List<Map<String,Object>> output =
56+
(List<Map<String, Object>>) responseBody.get("output");
57+
if(output != null && output.size() > 0){
58+
List<Map<String, Object>> contentList = (List<Map<String, Object>>) output.get(0).get("content");
59+
60+
String responseId = (String) contentList.get(0).get("text");
61+
log.info("⭐추천 id응답: {}", responseId);
62+
return responseId.trim();
63+
}
64+
}
65+
}catch (Exception e){
66+
log.error("OpenaI 호출 중 오류 발생", e);
67+
return null;
68+
}
69+
return null;}
70+
}

src/main/java/kb_hack/backend/global/common/exception/enums/SuccessStatusCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ public enum SuccessStatusCode {
3636

3737
ALARM_SETTING_GET_SUCCESS(HttpStatus.OK,"알림 선호 설정 데이터 불러오기 성공!"),
3838
ALARM_SETTING_PATCH_SUCCESS(HttpStatus.OK,"알림 선호 알림 설정 성공!"),
39-
ALARM_LIST_GET_SUCCESS(HttpStatus.OK,"알림리스트 불러오기 성공!");
39+
ALARM_LIST_GET_SUCCESS(HttpStatus.OK,"알림리스트 불러오기 성공!"),
4040

41+
RECOMMENDATION_SUCCESS(HttpStatus.OK, "AI 불러오기 성공!");
4142
private final HttpStatus httpStatus;
4243
private final String message;
4344

src/main/java/kb_hack/backend/global/security/config/SecurityConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ public SecurityFilterChain filterChain (HttpSecurity http, JwtUsernamePasswordAu
9696
.requestMatchers("/profile-image/**").hasRole("Member")
9797
.requestMatchers("/connect/**").permitAll()
9898
.requestMatchers(HttpMethod.POST, "/alarm/admin/**").hasRole("Admin")
99+
.requestMatchers(HttpMethod.GET,"/api/recommendation").permitAll()
99100
.anyRequest().authenticated()
100101
);
101102

0 commit comments

Comments
 (0)