@@ -195,9 +195,85 @@ public void updateLocation(Member member, RegionDto regionDto) {
195195 }
196196
197197 /**
198- * 단일 회원에 대해 스케줄러 AI 예측 전송 로직 재사용
198+ * 단일 회원에 대해 AI 예측을 요청하고 결과를 저장합니다.
199+ * 응답이 없거나 처리 실패 시 Fallback 로직(지역명 업데이트)을 시도합니다.
200+ * @param member 대상 회원 객체
201+ * @param region 새로운 지역명
199202 */
200203 private void pushPredictionForSingleMember (Member member , String region ) {
204+ Long memberId = member .getId (); // memberId 변수 사용
205+ try {
206+ if (member .getCloset () == null ) {
207+ log .warn ("[AI 예측 생략] memberId={} 클로젯 정보 없음" , memberId );
208+ return ;
209+ }
210+
211+ AiPredictionRequestDto dto = aiInternalService .makePredictRequest (member );
212+ if (dto == null ) {
213+ log .warn ("[AI 예측 데이터 없음] memberId={}" , memberId );
214+ // AI 요청 데이터 생성 실패 시 Fallback 실행
215+ handleFallbackForMissingResponse (memberId , region );
216+ return ;
217+ }
218+
219+ // 날씨 정보 정렬
220+ List <WeatherPredictDto > sortedWeather = Optional .ofNullable (dto .getWeatherForecast ()).orElseGet (Collections ::emptyList )
221+ .stream ()
222+ .sorted (Comparator .comparingInt (WeatherPredictDto ::getHour ))
223+ .toList ();
224+ dto .setWeatherForecast (sortedWeather );
225+
226+ log .info ("[AI 예측 DTO 확인] memberId={}, bodyType={}, weatherForecast={}, clothingCombinations={}" ,
227+ memberId , dto .getBodyTypeLabel (), dto .getWeatherForecast (), dto .getClothingCombinations ());
228+
229+ // 데이터 암호화
230+ Map <String , String > encryptedData = encryptPredictionData (List .of (dto ));
231+ if (encryptedData == null || encryptedData .isEmpty ()) {
232+ log .warn ("[AI 예측 생략] memberId={} 데이터 암호화 실패 또는 결과 없음" , memberId );
233+ // 암호화 실패 시 Fallback 실행
234+ handleFallbackForMissingResponse (memberId , region );
235+ return ;
236+ }
237+
238+ // AI 서버 요청
239+ HttpHeaders headers = new HttpHeaders ();
240+ headers .setContentType (MediaType .APPLICATION_JSON );
241+ HttpEntity <Map <String , String >> requestEntity = new HttpEntity <>(encryptedData , headers );
242+ ResponseEntity <Map > response = restTemplate .postForEntity (aiServerUrl , requestEntity , Map .class );
243+
244+ // 응답 상태 확인
245+ if (!response .getStatusCode ().is2xxSuccessful ()) {
246+ log .warn ("[AI 서버 응답 실패] memberId={}, 상태코드={}, 본문={}" ,
247+ memberId , response .getStatusCode (), response .getBody ());
248+ // AI 서버 응답 실패 시 Fallback 실행
249+ handleFallbackForMissingResponse (memberId , region );
250+ return ;
251+ }
252+
253+ // 응답 본문 확인
254+ Map <String , String > encryptedResponseData = response .getBody ();
255+ log .info ("[AI 서버 응답 본문] memberId={}, body={}" , memberId , encryptedResponseData );
256+ if (encryptedResponseData == null || encryptedResponseData .isEmpty ()) {
257+ log .warn ("[AI 서버 응답 없음] memberId={}, 응답 본문 null/empty." , memberId );
258+ // 응답 본문 없을 시 Fallback 실행
259+ handleFallbackForMissingResponse (memberId , region );
260+ return ;
261+ }
262+
263+ saveRecommendationsInternal (encryptedResponseData , memberId , region ); // memberId 추가
264+
265+ } catch (Exception e ) {
266+ // AI 예측 요청/처리 중 발생한 모든 예외 처리
267+ log .error ("[AI 예측 처리 실패] memberId={}, message={}" , memberId , e .getMessage (), e );
268+ // ✨ 예측 처리 자체 실패 시에도 Fallback 실행 ✨
269+ handleFallbackForMissingResponse (memberId , region );
270+ }
271+ }
272+
273+ /**
274+ * 단일 회원에 대해 스케줄러 AI 예측 전송 로직 재사용
275+ */
276+ private void pushOldPredictionForSingleMember (Member member , String region ) {
201277 try {
202278 if (member .getCloset () == null ) {
203279 log .warn ("[AI 예측 생략] memberId={} 클로젯 정보 없음" , member .getId ());
@@ -301,8 +377,187 @@ private Map<String, String> encryptPredictionData(List<AiPredictionRequestDto> d
301377 }
302378 }
303379
380+ /**
381+ * AI 서버로부터 받은 암호화된 추천 데이터를 복호화하고 저장합니다.
382+ * JSON 파싱 오류 발생 시, 에러 로그만 남기고 마치 'AI 결과 없음' 시나리오처럼
383+ * 기존 데이터의 지역명을 업데이트하고 정상 종료합니다.
384+ *
385+ * @param encryptedData 암호화된 데이터 Map ("iv", "payload" 포함)
386+ * @param memberId 대상 사용자 ID (Fallback 시 사용)
387+ * @param newRegionName 업데이트할 새로운 지역명
388+ */
389+ @ Transactional
390+ public void saveRecommendationsInternal (Map <String , String > encryptedData , Long memberId , String newRegionName ) { // memberId 파라미터 추가
391+ // --- 1. 입력 데이터 기본 유효성 검사 ---
392+ if (encryptedData == null || encryptedData .isEmpty ()) {
393+ log .warn ("[saveInternal] memberId={} 전달된 데이터 맵 null/empty. 처리 중단." , memberId );
394+ return ;
395+ }
396+ if (!encryptedData .containsKey ("iv" ) || !encryptedData .containsKey ("payload" )) {
397+ log .warn ("[saveInternal] memberId={} 필수 키 'iv'/'payload' 없음. 응답: {}. 처리 중단." , memberId , encryptedData );
398+ return ;
399+ }
400+ String iv = encryptedData .get ("iv" ); String payload = encryptedData .get ("payload" );
401+ if (iv == null || iv .isBlank () || payload == null || payload .isBlank ()) {
402+ log .warn ("[saveInternal] memberId={} 'iv'/'payload' 값 null/blank. 응답: {}. 처리 중단." , memberId , encryptedData );
403+ return ;
404+ }
405+
406+ String decryptedJson = null ; // 로그용 변수
407+ boolean errorOccurred = false ; // 에러 발생 여부 플래그
408+ boolean dataProcessed = false ; // DB 변경 발생 여부 플래그
409+
410+ try {
411+ // --- 2. 복호화 ---
412+ log .info ("[saveInternal] memberId={} 복호화 시도..." , memberId );
413+ decryptedJson = aesCipher .decrypt (encryptedData );
414+ log .debug ("[saveInternal] memberId={} 복호화된 JSON: {}" , memberId , decryptedJson );
415+
416+ // --- 3. JSON 파싱 시도 (별도 try-catch) ---
417+ ModelClothingRecommendationDto [] dtoArray ;
418+ try {
419+ if (decryptedJson .startsWith ("\" " ) && decryptedJson .endsWith ("\" " )) {
420+ decryptedJson = objectMapper .readValue (decryptedJson , String .class );
421+ }
422+ dtoArray = objectMapper .readValue (
423+ decryptedJson , ModelClothingRecommendationDto [].class );
424+ log .info ("[saveInternal] memberId={} 파싱 성공 ({}개 항목)" , memberId , dtoArray .length );
425+
426+ } catch (Exception parsingException ) {
427+ // ✨ 파싱 실패 시: 로그 남기고 에러 플래그 설정 후 catch 블록 밖으로 나감 ✨
428+ log .error ("[saveInternal] memberId={} JSON 파싱 실패. Fallback 로직 실행 예정. 에러: {}" , memberId , parsingException .getMessage ());
429+ if (decryptedJson != null ) {
430+ log .error ("[saveInternal] memberId={} 파싱 실패 시 원본 JSON: {}" , memberId , decryptedJson );
431+ }
432+ errorOccurred = true ; // 에러 발생 플래그 설정
433+ dtoArray = new ModelClothingRecommendationDto [0 ]; // 빈 배열로 초기화 (아래 로직 진행 위함)
434+ }
435+
436+ // --- 4. 데이터 처리 (파싱 성공 시) 또는 Fallback 실행 ---
437+ if (!errorOccurred ) {
438+ // 파싱 성공 시 기존 로직 실행
439+ LocalDate today = LocalDate .now ();
440+ for (ModelClothingRecommendationDto dto : dtoArray ) {
441+ // userId 일치 확인 등 기존 로직 수행...
442+ if (!memberId .equals (dto .getUserId ())) {
443+ log .warn ("[saveInternal] DTO userId({}) 불일치 (대상 memberId={}). 건너<0xEB><0x9B><0x84>." , dto .getUserId (), memberId );
444+ continue ;
445+ }
446+ Member member = memberRepository .findById (dto .getUserId ())
447+ .orElseThrow (() -> new CustomException (ErrorCode .ID_NOT_FOUND ));
448+
449+ List <ModelRecommendationResult > results = Optional .ofNullable (dto .getResult ()).orElseGet (ArrayList ::new );
450+
451+ if (!results .isEmpty ()) {
452+ // 새 추천 결과 저장
453+ recommendationRepository .deleteByMemberIdAndDate (member .getId (), today );
454+ for (ModelRecommendationResult r : results ) {
455+ ClothingRecommendation entity = convertToEntityWithBuilder (r , member .getId (), newRegionName );
456+ recommendationRepository .save (entity );
457+ }
458+ log .info ("[추천 데이터 저장 완료] memberId={}" , member .getId ());
459+ dataProcessed = true ;
460+ } else {
461+ // AI 결과 비었을 때 기존 데이터 지역명 업데이트
462+ dataProcessed = handleEmptyAiResult (member , newRegionName ) || dataProcessed ;
463+ }
464+ } // End of for loop
465+ } else { // ✨ errorOccurred가 true일 때 (파싱 실패 시) ✨
466+ // Fallback 실행: "AI 결과 없음" 시나리오와 동일하게 기존 데이터 지역명 업데이트 시도
467+ log .info ("[Fallback] memberId={} 파싱 실패로 기존 데이터 지역명 업데이트 시도." , memberId );
468+ Member member = memberRepository .findById (memberId ).orElse (null ); // memberId로 Member 조회
469+ if (member != null ) {
470+ // handleEmptyAiResult 호출하여 지역명 업데이트
471+ dataProcessed = handleEmptyAiResult (member , newRegionName ) || dataProcessed ; // 여기도 dataProcessed 업데이트
472+ } else {
473+ log .warn ("[Fallback] memberId={} 사용자 정보를 찾을 수 없어 지역명 업데이트 불가." , memberId );
474+ }
475+ }
476+
477+ // DB 변경이 있었을 경우 flush/clear
478+ if (dataProcessed ) {
479+ recommendationRepository .flush ();
480+ entityManager .clear ();
481+ }
482+
483+ } catch (CustomException e ) {
484+ // ID_NOT_FOUND 등 CustomException은 그대로 던짐
485+ log .error ("[saveInternal] 처리 중 CustomException 발생. memberId={} message={}" , memberId , e .getMessage (), e );
486+ throw e ;
487+ } catch (Exception e ) {
488+ // 복호화 실패 등 파싱 외의 예상 못한 Exception 처리
489+ log .error ("[saveInternal] 처리 중 예상 못한 Exception 발생. memberId={} message={}" , memberId , e .getMessage (), e );
490+ throw new CustomException (ErrorCode .UNKNOWN_ERROR , "추천 데이터 처리 중 오류 발생: " + e .getMessage ());
491+ }
492+ }
493+
494+ /**
495+ * (Helper) AI 결과가 비었거나 파싱 실패 시 기존 추천 데이터의 지역명만 업데이트하는 로직
496+ * @return 업데이트 성공(변경 발생) 여부
497+ */
498+ private boolean handleEmptyAiResult (Member member , String newRegionName ) {
499+ LocalDate today = LocalDate .now ();
500+ List <ClothingRecommendation > existingData = recommendationRepository .findByMemberIdAndDate (member .getId (), today );
501+ boolean updated = false ; // 업데이트 발생 여부 플래그
502+ if (!existingData .isEmpty ()) {
503+ for (ClothingRecommendation rec : existingData ) {
504+ // 지역명이 이미 동일하면 업데이트 건너뛰기
505+ if (newRegionName .equals (rec .getRegionName ())) {
506+ log .debug ("[handleEmptyAiResult] memberId={} 지역명({}) 이미 최신. 업데이트 불필요." , member .getId (), newRegionName );
507+ continue ; // 다음 레코드로
508+ }
509+ // 엔티티 빌더를 사용하여 업데이트 객체 생성 (null 방어 포함)
510+ ClothingRecommendation updatedEntity = ClothingRecommendation .builder ()
511+ .id (rec .getId ()) // 기존 ID 유지
512+ .memberId (rec .getMemberId ())
513+ .regionName (newRegionName ) // 새 지역명 설정
514+ .tops (Optional .ofNullable (rec .getTops ()).orElseGet (ArrayList ::new ))
515+ .outers (Optional .ofNullable (rec .getOuters ()).orElseGet (ArrayList ::new ))
516+ .predictionMap (Optional .ofNullable (rec .getPredictionMap ()).orElseGet (HashMap ::new ))
517+ .date (today ) // 날짜 유지
518+ .build ();
519+ recommendationRepository .save (updatedEntity ); // 업데이트 실행
520+ updated = true ; // 업데이트 발생 표시
521+ }
522+ if (updated ) {
523+ log .info ("[handleEmptyAiResult] 기존 데이터 지역명 업데이트 완료. memberId={}" , member .getId ());
524+ // flush/clear는 호출한 쪽에서 관리
525+ }
526+ } else {
527+ log .info ("[handleEmptyAiResult] 업데이트할 기존 데이터 없음. memberId={}" , member .getId ());
528+ }
529+ return updated ; // 업데이트 발생 여부 반환
530+ }
531+
532+ /**
533+ * (Helper) AI 서버 응답이 없거나 처리 중 예외 발생 시 Fallback 처리 (기존 데이터 지역명 업데이트)
534+ * @param memberId 사용자 ID
535+ * @param newRegionName 새로운 지역명
536+ */
537+ private void handleFallbackForMissingResponse (Long memberId , String newRegionName ) {
538+ try {
539+ log .warn ("[Fallback 시도] memberId={} AI 응답 없거나 처리 실패. 기존 데이터 지역명 업데이트 시도." , memberId );
540+ Member member = memberRepository .findById (memberId ).orElse (null );
541+ if (member != null ) {
542+ boolean updated = handleEmptyAiResult (member , newRegionName ); // 기존 헬퍼 재사용
543+ if (updated ) {
544+ recommendationRepository .flush (); // DB 반영
545+ entityManager .clear (); // 영속성 컨텍스트 초기화
546+ log .info ("[Fallback 시도] memberId={} 기존 데이터 지역명 업데이트 성공." , memberId );
547+ } else {
548+ log .info ("[Fallback 시도] memberId={} 기존 데이터 지역명 업데이트 불필요(이미 최신) 또는 대상 없음." , memberId );
549+ }
550+ } else {
551+ log .warn ("[Fallback 실패] memberId={} 사용자 정보를 찾을 수 없음." , memberId );
552+ }
553+ } catch (Exception fallbackEx ) {
554+ // Fallback 로직 자체 실패 시 로그만 남김 (예외 숨김)
555+ log .error ("[Fallback 실패] memberId={} 기존 데이터 업데이트 중 오류: {}" , memberId , fallbackEx .getMessage (), fallbackEx );
556+ }
557+ }
558+
304559 @ Transactional
305- public void saveRecommendationsInternal (Map <String , String > encryptedData , String newRegionName ) {
560+ public void saveOldRecommendationsInternal (Map <String , String > encryptedData , String newRegionName ) {
306561 if (encryptedData == null || encryptedData .isEmpty ()) {
307562 log .warn ("[추천 데이터 처리 중단] 전달된 데이터 맵이 null이거나 비어있습니다." );
308563 return ;
0 commit comments