Skip to content

Commit 7b56501

Browse files
authored
Merge pull request #240 from eureca-final-capstone-project/develop
메인 배포
2 parents 944b2ba + d72838f commit 7b56501

9 files changed

Lines changed: 189 additions & 110 deletions

File tree

src/main/java/eureca/capstone/project/orchestrator/auth/service/impl/CustomOAuth2SuccessServiceImpl.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import eureca.capstone.project.orchestrator.user.repository.UserRepository;
88
import jakarta.servlet.http.HttpServletRequest;
99
import jakarta.servlet.http.HttpServletResponse;
10+
import jakarta.servlet.http.HttpSession;
1011
import lombok.RequiredArgsConstructor;
1112
import lombok.extern.slf4j.Slf4j;
1213
import org.springframework.security.core.Authentication;
@@ -41,6 +42,12 @@ public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpS
4142
return;
4243
}
4344

45+
// 현재 HTTP 세션 제거
46+
HttpSession session = httpServletRequest.getSession(false);
47+
if (session != null) {
48+
session.invalidate();
49+
}
50+
4451
// authCode와 함께 프론트엔드로 리다이렉트
4552
String redirectUrl = REDIRECT_URI + "?authCode=" + authCode;
4653
log.info("[onAuthenticationSuccess] 리다이렉트 URL: {}", redirectUrl);

src/main/java/eureca/capstone/project/orchestrator/common/component/RewardSelector.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,16 @@ public int selectTodayReward(Long userId) {
2626
int reward;
2727
if (rand < 30.0) {
2828
reward = 1; // 30%
29-
} else if (rand < 55.0) {
30-
reward = 100; // 25%
31-
} else if (rand < 75.0) {
32-
reward = 1_000; // 20%
33-
} else if (rand < 90.0) {
34-
reward = 10_000; // 15%
35-
} else if (rand < 98.0) {
36-
reward = 100_000; // 8%
29+
} else if (rand < 65.0) {
30+
reward = 100; // 35%
31+
} else if (rand < 99.0) {
32+
reward = 1_000; // 34%
33+
} else if (rand < 99.9) {
34+
reward = 10_000; // 0.9%
35+
} else if (rand < 99.9999) {
36+
reward = 100_000; // 0.0999%
3737
} else {
38-
reward = 1_000_000; // 2%
38+
reward = 1_000_000; // 0.0001%
3939
}
4040

4141
log.info("[selectTodayReward] userId={}, rand={}, reward={}", userId, rand, reward);

src/main/java/eureca/capstone/project/orchestrator/common/config/AIConfig.java

Lines changed: 80 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,47 @@ public class AIConfig {
1313
public ChatClient createNickNameClient(ChatClient.Builder builder) {
1414
return builder
1515
.defaultSystem("""
16-
당신은 사용자에게 유니크하고 창의적인 한글 닉네임을 생성해주는 닉네임 생성기입니다.
17-
18-
다음 기준을 반드시 지키세요:
19-
1. 닉네임은 반드시 **한글**로 구성되어야 합니다.
20-
2. 글자 수는 **1글자 이상, 12글자 이하**로 제한합니다.
21-
3. 띄어쓰기나 간단한 특수문자(예: !, ~, -, *)는 포함해도 괜찮지만 전체 길이는 12자를 넘지 않아야 합니다.
22-
4. 흔하고 식상한 단어 조합은 피하고, 창의적이고 유쾌한 느낌을 주는 닉네임을 만드세요.
23-
5. 욕설, 비속어, 혐오, 성적 표현 등 부적절한 단어는 절대 포함하지 마세요.
24-
6. 오직 닉네임 **하나만** 출력하세요. 설명, 예시, 말머리, 마침표 등은 절대 붙이지 마세요.
25-
26-
예시 (출력 예시 아님):
27-
- 반짝콩이
28-
- 초코별님
29-
- 냥냥뿡!
30-
- 찰떡소년~
31-
- 설레는밤
32-
- 달려라 감자!
33-
- 뽀짝 여우
34-
- 초코송이 요정
35-
- 번개콩~!
36-
- 새벽비 구름
37-
38-
위 기준을 따라 닉네임 하나를 생성하세요.
16+
⚡️ **역할**
17+
당신은 “한글 닉네임 창조 AI”. 매 호출마다 **완전히 새로운** 닉네임 **한 줄**만 출력한다.
18+
19+
─────────────────────────────
20+
📜 **하드 룰 (위반 시 재생성!)**
21+
22+
1. **출력 형식**
23+
- 마크업·말머리·마침표·따옴표 없이 ⌜순수 텍스트 한 줄⌟.
24+
- 줄 바꿈 · 공백 앞뒤 여백 금지.
25+
26+
2. **길이**
27+
- 전체 1 – 12자(공백·특수문자 포함).
28+
- 특수문자는 `~ ! - *` 만 허용, 1개 이하.
29+
30+
3. **언어 & 단어 제한**
31+
- 완전한 **한글**어휘로 시작·끝. (이모지·영문·숫자 금지)
32+
- 흔한 조합/유치한 의태어 금지: ‘콩, 별, 요정, 감자, 밤, 여우, ㅋㅋ, ㅎㅎ’ 등 **절대 사용 불가**.
33+
34+
4. **중복 방지**
35+
- ( 모델 스스로 기억 ) **과거에 당신이 내놓았거나** 입력으로 전달받은 닉네임과
36+
**철자·단어·길이·어감** 어느 하나라도 유사하면 = 중복 → 즉시 폐기, 새로 생성.
37+
38+
5. **콘셉트**
39+
- - 상상력을 자극하는 의외의 조합: 의인화·상황형·온도차 반전 등 활용.
40+
- - 매번 **다른 톤**: 판타지·SF·추상·코믹·미니멀 등 스타일을 순환.
41+
- - “명사+형용사”·“동사+명사”·“감탄사+명사” 등 **서로 다른 구조**를 번갈아 써라.
42+
- 욕설·성적·혐오·정치·종교 금지.
43+
44+
─────────────────────────────
45+
💡 **창의 지침**
46+
47+
* 3단계 브레인스토밍(연상 → 비틀기 → 압축) 후 가장 독창적인 1개만 채택.
48+
* 익숙한 단어를 쓰더라도 **형태소를 변주**하거나 **새로운 어휘**와 혼합해 낯설게 만들 것.
49+
* 읽는 순간 “어, 이 조합 처음인데?”라는 반응이 나와야 성공.
50+
51+
─────────────────────────────
52+
🚦 **출력 예 (형식만 참고, 그대로 쓰지 말 것!)**
53+
깡총 회오리~
54+
멍때리는 해파리
55+
알쏭달쏭 숟가락!
56+
바삭한 참치별
3957
""")
4058
.build();
4159
}
@@ -45,26 +63,45 @@ public ChatClient createNickNameClient(ChatClient.Builder builder) {
4563
public ChatClient createQuizGeneratorClient(ChatClient.Builder builder) {
4664
return builder
4765
.defaultSystem("""
48-
당신은 사용자에게 흥미롭고 유익한 '오늘의 퀴즈'를 만들어주는 AI 퀴즈 생성기입니다.
49-
50-
다음 기준에 따라 하나의 퀴즈를 생성하세요:
51-
52-
1. **quizTitle**: 간결하면서도 호기심을 자극하는 제목 (예: "지구의 위성은?", "한국의 수도는?")
53-
2. **quizDescription**: 문제 설명, 상황을 유머 있게 제시해도 좋습니다. 1~2문장으로 구성하세요.
54-
3. **quizAnswer**: 정답만 출력 (예: "서울", "달", "물")
55-
4. **quizHint**: 사용자가 정답을 추론할 수 있도록 돕는 단서. 너무 직접적이면 안 됩니다. 하지만 구체적이고 정답과 연관성 있는 힌트를 주어야 합니다.
56-
57-
출력 형식은 다음 JSON과 정확히 일치시켜 주세요:
58-
59-
{
60-
"quizTitle": "string",
61-
"quizDescription": "string",
62-
"quizAnswer": "string",
63-
"quizHint": "string"
64-
}
65-
66-
단 하나의 퀴즈만 생성하세요. 설명이나 말머리 없이 JSON만 응답하세요.
67-
""")
66+
당신은 사용자에게 흥미롭고 유익한 '오늘의 퀴즈'를 만들어주는 AI 퀴즈 생성기입니다.
67+
68+
다음 기준에 따라 하나의 퀴즈를 생성하세요:
69+
70+
1. **quizTitle**:
71+
- 사용자의 호기심을 자극하면서도 정보를 담고 있는 제목이어야 합니다.
72+
- 공백 포함 **20자 이상 40자 이하**의 자연스럽고 매끄러운 문장으로 구성하세요.
73+
- 단순한 명사 조합, 짧은 의문문(예: "지구의 위성은?")은 절대 금지입니다.
74+
- **같은 문장 구조나 주제를 반복하지 마세요.**
75+
- **항상 새로운 문체, 표현 방식, 주제를 사용하세요.**
76+
- ✅ 예시:
77+
- "하늘에서 내리는 물방울의 정체는 무엇일까요?"
78+
- "사람이 숨을 쉬는 데 꼭 필요한 기체는 무엇일까요?"
79+
- "세계에서 가장 오래된 문명은 어디에서 시작되었을까요?"
80+
81+
2. **quizDescription**: 퀴즈와 관련된 상황 설명을 유머나 흥미로운 어투로 1~2문장 작성하세요.
82+
83+
3. **quizAnswer**: 정답만 출력 (예: "서울", "달", "물")
84+
85+
4. **quizHint**: 사용자가 정답을 추론할 수 있도록 돕는 단서. 너무 직접적이면 안 되며, 정답과 연관된 구체적인 단서를 제공하세요.
86+
87+
**중요 지침**:
88+
89+
- 절대로 이전에 만들었던 퀴즈와 **표현, 구조, 주제**가 겹쳐서는 안 됩니다.
90+
- 매번 다른 주제를 선택하세요. 아래는 참고 가능한 주제들입니다:
91+
- 과학, 우주, 역사, 지리, 동물, 속담, 음식, 문화, 언어, 기술, 인물 등
92+
- **같은 주제나 패턴을 연속 사용하지 말고**, 항상 새로운 형식과 창의적인 시도를 하세요.
93+
94+
출력 형식은 아래 JSON과 정확히 일치시켜 주세요:
95+
96+
{
97+
"quizTitle": "string",
98+
"quizDescription": "string",
99+
"quizAnswer": "string",
100+
"quizHint": "string"
101+
}
102+
103+
**단 하나의 퀴즈만 생성하고, 설명이나 말머리 없이 JSON만 응답하세요.**
104+
""")
68105
.build();
69106
}
70107
}

src/main/java/eureca/capstone/project/orchestrator/common/controller/RedisController.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package eureca.capstone.project.orchestrator.common.controller;
22

3+
import eureca.capstone.project.orchestrator.common.dto.GetRankingResponseDto;
34
import eureca.capstone.project.orchestrator.common.dto.KeywordRankingDto;
45
import eureca.capstone.project.orchestrator.common.dto.base.BaseResponseDto;
56
import eureca.capstone.project.orchestrator.common.service.RedisService;
67
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
79
import lombok.RequiredArgsConstructor;
810
import lombok.extern.slf4j.Slf4j;
911
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,6 +15,7 @@
1315

1416
import java.util.List;
1517

18+
@Tag(name = "실시간 검색어 API")
1619
@Slf4j
1720
@RestController
1821
@RequiredArgsConstructor
@@ -67,8 +70,8 @@ public BaseResponseDto<Void> execute(@RequestParam String keyword) {
6770
"""
6871
)
6972
@GetMapping("/ranking")
70-
public BaseResponseDto<List<KeywordRankingDto>> getRanking() {
71-
List<KeywordRankingDto> trendingKeywords = redisService.getTopSearchKeywordsWithTrend(10);
73+
public BaseResponseDto<GetRankingResponseDto> getRanking() {
74+
GetRankingResponseDto trendingKeywords = redisService.getTopSearchKeywordsWithTrend(10);
7275
return BaseResponseDto.success(trendingKeywords);
7376
}
7477
}

src/main/java/eureca/capstone/project/orchestrator/common/dto/GetRankingResponseDto.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
@Data
99
@Builder
1010
public class GetRankingResponseDto {
11-
private List<Object> top10;
11+
private String lastUpdatedAt;
12+
private List<KeywordRankingDto> top10;
1213
}

src/main/java/eureca/capstone/project/orchestrator/common/service/RedisService.java

Lines changed: 73 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package eureca.capstone.project.orchestrator.common.service;
22

3-
import com.fasterxml.jackson.core.type.TypeReference;
43
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import eureca.capstone.project.orchestrator.common.dto.GetRankingResponseDto;
55
import eureca.capstone.project.orchestrator.common.dto.KeywordRankingDto;
6+
import java.time.LocalDateTime;
7+
import java.time.format.DateTimeFormatter;
8+
import java.util.Collections;
9+
import java.util.UUID;
10+
import java.util.stream.Collectors;
611
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
713
import org.springframework.data.redis.core.RedisTemplate;
814
import org.springframework.stereotype.Service;
915

@@ -17,13 +23,30 @@
1723

1824
;
1925

26+
@Slf4j
2027
@Service
2128
@RequiredArgsConstructor
2229
public class RedisService {
2330

2431
private final RedisTemplate<String, Object> redisTemplate;
2532
private final ObjectMapper objectMapper;
2633

34+
private static final DateTimeFormatter KEY_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd:HH");
35+
private static final DateTimeFormatter UPDATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
36+
37+
private String generateRankingKey(LocalDateTime dateTime) {
38+
String timeBlock = dateTime.getMinute() < 30 ? "00" : "30";
39+
return "search_ranking:" + dateTime.format(KEY_DATE_FORMATTER) + "_" + timeBlock;
40+
}
41+
42+
private String getCurrentRankingKey() {
43+
return generateRankingKey(LocalDateTime.now());
44+
}
45+
46+
private String getPreviousRankingKey() {
47+
return generateRankingKey(LocalDateTime.now().minusMinutes(30));
48+
}
49+
2750
/**
2851
* 값을 저장 (TTL 없음)
2952
*/
@@ -80,7 +103,12 @@ public boolean hasKey(String key) {
80103
public void increaseSearchKeyword(String keyword) {
81104
if (keyword == null || keyword.isBlank()) return;
82105
keyword = sanitizeKeyword(keyword);
83-
redisTemplate.opsForZSet().incrementScore(SEARCH_RANKING_KEY, keyword.toLowerCase(), 1);
106+
String key = getCurrentRankingKey();
107+
108+
redisTemplate.opsForZSet().incrementScore(key, keyword.toLowerCase(), 1);
109+
if (redisTemplate.getExpire(key) == null || redisTemplate.getExpire(key) < 0) {
110+
redisTemplate.expire(key, 24, TimeUnit.HOURS);
111+
}
84112
}
85113

86114
public String sanitizeKeyword(String rawKeyword) {
@@ -114,61 +142,53 @@ public void clearAll() {
114142
redisTemplate.delete(SEARCH_RANKING_KEY);
115143
}
116144

117-
public List<KeywordRankingDto> getTopSearchKeywordsWithTrend(int topN) {
145+
public GetRankingResponseDto getTopSearchKeywordsWithTrend(int topN) {
146+
LocalDateTime now = LocalDateTime.now();
147+
String currentKey = getCurrentRankingKey();
148+
String prevKey = getPreviousRankingKey();
118149

119-
/* 1. 현재 TopN 조회 */
120-
Set<Object> currentSet = redisTemplate.opsForZSet()
121-
.reverseRange(SEARCH_RANKING_KEY, 0, topN - 1);
122-
List<String> current = currentSet == null ? List.of()
123-
: currentSet.stream().map(Object::toString).toList();
124-
125-
/* 2. 이전 랭킹 JSON 문자열 안전하게 파싱 */
126-
List<String> prev;
127-
Object rawPrev = redisTemplate.opsForValue().get(SEARCH_RANKING_KEY + ":prev");
128-
129-
if (rawPrev instanceof String json) {
130-
try {
131-
prev = objectMapper.readValue(json, new TypeReference<>() {
132-
});
133-
} catch (Exception e) { // 포맷이 잘못됐거나, 과거에 List 그대로 저장돼 있을 경우
134-
prev = List.of(); // 깨끗이 초기화
135-
}
136-
} else {
137-
/* 이전에 List 그대로 저장돼 있을 가능성 → 일단 버리고 초기화 */
138-
prev = List.of();
139-
}
150+
// 1. 임시 키 생성
151+
String tempKey = "temp_ranking:" + UUID.randomUUID().toString();
140152

141-
/* 3. 현재·이전 비교 → DTO 생성 */
142-
List<KeywordRankingDto> result = new ArrayList<>();
143-
for (int i = 0; i < current.size(); i++) {
144-
String kw = current.get(i);
145-
int prevIdx = prev.indexOf(kw);
146-
147-
String trend;
148-
Integer gap = null;
149-
150-
if (prevIdx == -1) {
151-
trend = "NEW";
152-
} else if (prevIdx > i) {
153-
trend = "UP";
154-
gap = prevIdx - i;
155-
} else if (prevIdx < i) {
156-
trend = "DOWN";
157-
gap = i - prevIdx;
158-
} else {
159-
trend = "SAME";
160-
gap = 0;
153+
try {
154+
// 2. 이전 키와 현재 키를 합산하여 임시 키에 저장
155+
// 동일 멤버는 점수가 합산됨
156+
redisTemplate.opsForZSet().unionAndStore(prevKey, Collections.singletonList(currentKey), tempKey);
157+
158+
// 3. 임시 키에서 최종 TopN 조회
159+
Set<Object> currentSet = redisTemplate.opsForZSet().reverseRange(tempKey, 0, topN - 1);
160+
List<String> currentHybridRank = (currentSet == null) ? List.of() : currentSet.stream().map(Object::toString).toList();
161+
162+
// 4. 순위 비교 기준은 '이전 시간대의 랭킹'으로 설정
163+
Set<Object> prevSet = redisTemplate.opsForZSet().reverseRange(prevKey, 0, topN - 1);
164+
List<String> prevRank = (prevSet == null) ? List.of() : prevSet.stream().map(Object::toString).toList();
165+
166+
// 5. DTO 생성
167+
List<KeywordRankingDto> rankingList = new ArrayList<>();
168+
for (int i = 0; i < currentHybridRank.size(); i++) {
169+
String kw = currentHybridRank.get(i);
170+
int prevIdx = prevRank.indexOf(kw);
171+
String trend;
172+
Integer gap = null;
173+
174+
if (prevIdx == -1) trend = "NEW";
175+
else if (prevIdx > i) { trend = "UP"; gap = prevIdx - i; }
176+
else if (prevIdx < i) { trend = "DOWN"; gap = i - prevIdx; }
177+
else { trend = "SAME"; gap = 0; }
178+
179+
rankingList.add(new KeywordRankingDto(kw, i + 1, trend, gap));
161180
}
162-
result.add(new KeywordRankingDto(kw, i + 1, trend, gap));
163-
}
164181

165-
/* 4. 이번 TOP10 을 JSON 문자열로 저장 (다음 비교용) */
166-
try {
167-
String json = objectMapper.writeValueAsString(current);
168-
redisTemplate.opsForValue().set(SEARCH_RANKING_KEY + ":prev", json);
169-
} catch (Exception ignored) {
170-
}
182+
// 6. 갱신 시각 생성 및 최종 DTO 반환
183+
String lastUpdatedAt = now.withMinute(now.getMinute() < 30 ? 0 : 30).format(UPDATE_TIME_FORMATTER);
184+
return GetRankingResponseDto.builder()
185+
.lastUpdatedAt(lastUpdatedAt)
186+
.top10(rankingList)
187+
.build();
171188

172-
return result;
189+
} finally {
190+
// 7. 임시 키 삭제
191+
redisTemplate.delete(tempKey);
192+
}
173193
}
174194
}

src/main/java/eureca/capstone/project/orchestrator/common/service/impl/AIServiceImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public AIServiceImpl(@Qualifier("nicknameClient") ChatClient chatClient) {
1818
@Override
1919
public String generateNickname() {
2020
String nickName = chatClient.prompt()
21-
.user("닉네임 만들어줘")
21+
.user("프롬프트를 잘 참고해서 만들어줘")
2222
.call()
2323
.entity(String.class);
2424
log.info("[generateNickname] AI 를 통해 정상적으로 닉네임이 생성되었습니다. {}", nickName);

0 commit comments

Comments
 (0)