Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7826076
feat: ์Šค์ผ€์ค„๋Ÿฌ ์ธํ”„๋ผ ๋„์ž… ๋ฐ KST ํƒ€์ž„์กด ์„ค์ •
johe00123 May 11, 2026
bd6ae47
feat: ์ฟ ํฐ ์ž๋™ ๋งŒ๋ฃŒ ์Šค์ผ€์ค„๋Ÿฌ ๊ตฌํ˜„
johe00123 May 11, 2026
21b07e1
feat: ์˜ˆ์•ฝ NOSHOW ์ž๋™ ์ „ํ™˜ ์Šค์ผ€์ค„๋Ÿฌ ๊ตฌํ˜„
johe00123 May 11, 2026
5a384d2
refactor: ์˜ˆ์•ฝ ๋ฆฌ๋งˆ์ธ๋“œ ์•Œ๋ฆผ์„ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ in-app ์•Œ๋ฆผ์œผ๋กœ ์ „ํ™˜
johe00123 May 11, 2026
abb4229
feat: ์‚ฌ์šฉ์ž ๋ฐฉ๋ฌธ ํ™•์ • API ์ถ”๊ฐ€
johe00123 May 11, 2026
e2e71b2
feat : ์ฑ—๋ด‡ ๊ธฐ๋Šฅ db ์ ‘์† ๊ตฌํ˜„(์˜ˆ์•ฝ ์ƒ์„ฑ ์—์ด์ „ํŠธ)
silkair May 11, 2026
a53807b
Merge branch 'develop' into feat/#50-chatbot-agent
silkair May 11, 2026
3c02248
๋งค์žฅ ์˜ˆ์•ฝ ์‹œ ์ด๋ฆ„ ์ •ํ™•ํ•˜๊ฒŒ ์ž…๋ ฅํ•˜๋„๋ก ์ˆ˜์ •
silkair May 11, 2026
c18a25e
feat : ์ฑ—๋ด‡์—์•ฝ ํŠธ๋žœ์ ์…˜ ์ถ”๊ฐ€ + ์•Œ๋ฆผ ์ƒ์„ฑ
silkair May 11, 2026
5d06c21
fix: ๋ฐฉ๋ฌธ ํ™•์ • ์‹คํŒจ ์‹œ ๋ถ€์ •ํ™•ํ•œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ˆ˜์ •
johe00123 May 11, 2026
7f47ff5
Merge pull request #58 from catchtable-clone/feat/#53-visit-api
johe00123 May 11, 2026
6827f7b
Merge branch 'develop' into feat/#50-chatbot-agent
silkair May 11, 2026
61f1ab7
Merge branch 'feat/#50-chatbot-agent' of https://github.com/catchtablโ€ฆ
silkair May 11, 2026
0b20279
fix: ๋ฆฌ๋งˆ์ธ๋“œ ์œˆ๋„์šฐ ์ž์ • ๊ฒฝ๊ณ„ ์ฒ˜๋ฆฌ ๋ฒ„๊ทธ ์ˆ˜์ •
johe00123 May 11, 2026
2213eb4
refactor: ์ฟ ํฐ ๋งŒ๋ฃŒ ์ฟผ๋ฆฌ์—์„œ ์„œ๋ธŒ์ฟผ๋ฆฌ ๋Œ€์‹  ๊ฒฝ๋กœ ํ‘œํ˜„์‹ ์‚ฌ์šฉ
johe00123 May 11, 2026
0fe65b8
refactor: NOSHOW ์ „ํ™˜ ๋กœ์ง์„ ๋ฒŒํฌ UPDATE๋กœ ๋ณ€๊ฒฝ
johe00123 May 11, 2026
4e81958
feat : ์ฑ—๋ด‡ ๊ธฐ๋Šฅ db ์ ‘์† ๊ตฌํ˜„(์˜ˆ์•ฝ ์ƒ์„ฑ ์—์ด์ „ํŠธ)
silkair May 12, 2026
e281310
feat : ์ฑ—๋ด‡ ์˜ˆ์•ฝ ์ฟ ํฐ ์„ค์ • ์ถ”๊ฐ€
silkair May 12, 2026
2a8fbeb
Refactor: ๋ณ„์ , ๋ฆฌ๋ทฐ ์ˆ˜ ๊ณ„์‚ฐ ์ˆ˜์ • / ์˜ˆ์•ฝ ์ƒํƒœ ์ˆ˜์ •
jaebeom79 May 13, 2026
dcc8553
Merge remote-tracking branch 'origin/develop' into feat/#49-image-upload
jaebeom79 May 13, 2026
2b1301e
Fix: ์•Œ๋ฆผ ์ค‘๋ณต ์ „์†ก ์ˆ˜์ •
jaebeom79 May 13, 2026
15a9f77
Refactor: ๋ณ„์ , ๋ฆฌ๋ทฐ ์ˆ˜ ๋น„๋™๊ธฐ ๊ฐฑ์‹ 
jaebeom79 May 13, 2026
a633717
Merge pull request #62 from catchtable-clone/feat/#49-image-upload
jaebeom79 May 13, 2026
dca8eb5
feat : ์ฑ—๋ด‡รฌ์˜ˆ์•ฝ ์ฟ ํฐ ์ ์šฉ ์„ค์ •
silkair May 13, 2026
47306fc
merge develop
silkair May 13, 2026
e108be4
test
silkair May 13, 2026
87a0758
Merge branch 'develop' of https://github.com/catchtable-clone/backendโ€ฆ
johe00123 May 13, 2026
04c0fa9
Merge pull request #57 from catchtable-clone/feat/#52-scheduler
johe00123 May 14, 2026
ae1cffd
Merge branch 'develop' into feat/#50-chatbot-agent
silkair May 14, 2026
28e0995
feat : ์ฑ—๋ด‡ ์˜ˆ์•ฝ ์ฟ ํฐ ์„ค์ • ์ถ”๊ฐ€
silkair May 14, 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ PRD.md
### Uploads ###
uploads/

### Python migration scripts (๋กœ์ปฌ ์ผํšŒ์„ฑ ์ž‘์—…์šฉ) ###
scripts/

### Static seed images (๋กœ์ปฌ์—์„œ๋งŒ ์‚ฌ์šฉ, ready_image.png๋Š” ์˜ˆ์™ธ) ###
src/main/resources/static/images/*
!src/main/resources/static/images/ready_image.png
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ RUN ./gradlew bootJar --no-daemon -x test

# โ”€โ”€ 2๋‹จ๊ณ„: ์‹คํ–‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FROM eclipse-temurin:21.0.6_7-jre-noble

ENV TZ=Asia/Seoul

WORKDIR /app

# HEALTHCHECK์šฉ wget ์„ค์น˜ (eclipse-temurin jre-noble ์ด๋ฏธ์ง€์— ๋ฏธํฌํ•จ)
Expand Down
8 changes: 7 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ java {
}
}

// spring-ai-bom์„ dependencyManagement ๋ธ”๋ก์œผ๋กœ ์ด๋™
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:1.1.4"
}
}

configurations {
compileOnly {
extendsFrom annotationProcessor
Expand All @@ -25,7 +32,6 @@ repositories {
}

dependencies {
implementation platform('org.springframework.ai:spring-ai-bom:1.1.4')
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
Expand Down
24 changes: 23 additions & 1 deletion data/init-data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS stores (
is_deleted BOOLEAN NOT NULL
);

DROP TABLE IF EXISTS temp_stores;

CREATE TEMP TABLE temp_stores (
store_name text, store_image text, category text,
latitude double precision, longitude double precision,
Expand All @@ -64,7 +66,7 @@ CREATE TEMP TABLE temp_stores (
COPY temp_stores FROM '/tmp/data/store_data.csv' WITH (FORMAT csv, HEADER true);

INSERT INTO stores (store_name, store_image, category, latitude, longitude, address, district, team, open_time,
close_time, status, review_count, bookmark_count, is_deleted, created_at)
close_time, status, review_count, bookmark_count, average_star, is_deleted, created_at)
SELECT store_name,
NULLIF(store_image, ''),
category,
Expand All @@ -78,6 +80,26 @@ SELECT store_name,
'ACTIVE',
0,
0,
0.0,
false,
NOW()
FROM temp_stores;


-- 1. ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ์ƒ์„ฑ (AI ํ…Œ์ŠคํŠธ์šฉ 5000์› ํ• ์ธ ์ฟ ํฐ)
INSERT INTO coupon_templates (id, coupon_name, amount, discount_rate, started_at, expired_at, remain, created_at, updated_at, is_deleted)
VALUES (100, 'AI ํ…Œ์ŠคํŠธ์šฉ ์ฟ ํฐ', 5000, 10, '2026-01-01 00:00:00', '2026-12-31 23:59:59', 100, NOW(), NOW(), false)
ON CONFLICT (id) DO NOTHING; -- ์ด๋ฏธ 100๋ฒˆ ํ…œํ”Œ๋ฆฟ์ด ์žˆ์œผ๋ฉด ๋ฌด์‹œ

-- 2. 4๋ฒˆ ์‚ฌ์šฉ์ž์—๊ฒŒ ์œ„ ํ…œํ”Œ๋ฆฟ์œผ๋กœ ์ฟ ํฐ ๋ฐœ๊ธ‰
INSERT INTO coupons (user_id, coupon_template_id, status, created_at, updated_at, is_deleted)
VALUES (4, 100, 'UNUSED', NOW(), NOW(), false)
ON CONFLICT DO NOTHING; -- ์ด๋ฏธ ํ•ด๋‹น ์œ ์ €๊ฐ€ ์ด ์ฟ ํฐ์„ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉด ๋ฌด์‹œ

-- (PostgreSQL ์‚ฌ์šฉ ์‹œ) ์‹œํ€€์Šค ๊ฐ’ ์—…๋ฐ์ดํŠธ
SELECT setval('coupon_templates_id_seq', (SELECT MAX(id) FROM coupon_templates));
SELECT setval('coupons_id_seq', (SELECT MAX(id) FROM coupons));


-- 4๋ฒˆ ์‚ฌ์šฉ์ž์˜ role์„ 'ADMIN'์œผ๋กœ ์—…๋ฐ์ดํŠธ
UPDATE users SET role = 'ADMIN' WHERE id = 4;
2 changes: 2 additions & 0 deletions src/main/java/com/catchtable/CatchtableApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableAsync
@EnableScheduling
@EnableJpaAuditing
@SpringBootApplication
public class CatchtableApplication {
Expand Down
80 changes: 51 additions & 29 deletions src/main/java/com/catchtable/chatbot/service/ChatbotService.java
Original file line number Diff line number Diff line change
@@ -1,62 +1,73 @@
package com.catchtable.chatbot.service;

import java.util.List;

import com.catchtable.chatbot.dto.create.ChatMessageRequest;
import com.catchtable.chatbot.dto.create.ChatMessageResponse;
import com.catchtable.chatbot.dto.read.ChatMessageListResponse;
import com.catchtable.chatbot.entity.ChatMessage;
import com.catchtable.chatbot.entity.MessageRole;
import com.catchtable.coupon.service.CouponService;
import com.catchtable.global.exception.CustomException;
import com.catchtable.global.exception.ErrorCode;
import com.catchtable.reservation.service.ReservationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatbotService {

private static final int MAX_HISTORY_SIZE = 20;

private static final String SYSTEM_PROMPT =
"๋„ˆ๋Š” ์บ์น˜ํ…Œ์ด๋ธ” ๋ ˆ์Šคํ† ๋ž‘ ์˜ˆ์•ฝ ํ”Œ๋žซํผ์˜ AI ๋„์šฐ๋ฏธ์•ผ. "
+ "์‚ฌ์šฉ์ž์˜ ์˜ˆ์•ฝ, ๋งค์žฅ ๊ฒ€์ƒ‰, ๋ง›์ง‘ ์ถ”์ฒœ ์š”์ฒญ์„ ๋„์™€์ค˜. "
+ "ํ•œ๊ตญ์–ด๋กœ ์นœ์ ˆํ•˜๊ฒŒ ๋‹ต๋ณ€ํ•ด.";
private String buildSystemPrompt() {
return "๋„ˆ๋Š” 'CatchEat(์บ์น˜์ž‡)'์ด๋ผ๋Š” ๋ ˆ์Šคํ† ๋ž‘ ์˜ˆ์•ฝ ํ”Œ๋žซํผ์˜ AI ๋น„์„œ์•ผ. "
+ "์˜ค๋Š˜ ๋‚ ์งœ๋Š” " + java.time.LocalDate.now() + "์ด์•ผ. "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

java.time.LocalDate.now()๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•˜๋ฉด ์‹œ์Šคํ…œ ํƒ€์ž„์กด์— ์˜์กดํ•˜๊ฒŒ ๋˜์–ด ํ™˜๊ฒฝ๋ณ„๋กœ ๊ฒฐ๊ณผ๊ฐ€ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ๊ณ  ํ…Œ์ŠคํŠธ ์‹œ ์‹œ๊ฐ„์„ ์ œ์–ดํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. SchedulerConfig์— ์ •์˜๋œ Clock ๋นˆ์„ ์ฃผ์ž…๋ฐ›์•„ LocalDate.now(clock)์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ์‹์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

+ "์‚ฌ์šฉ์ž๊ฐ€ '5์›” 12์ผ'์ฒ˜๋Ÿผ ์—ฐ๋„ ์—†์ด ๋‚ ์งœ๋ฅผ ๋งํ•˜๋ฉด ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๋ฏธ๋ž˜ ๋‚ ์งœ๋กœ ํ•ด์„ํ•ด. "
+ "๋„ˆ์˜ ์—ญํ• ์€ ์‚ฌ์šฉ์ž์˜ ์งˆ๋ฌธ์„ ์ดํ•ดํ•˜๊ณ , ์ฃผ์–ด์ง„ ๋„๊ตฌ(ํ•จ์ˆ˜)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ ˆ์Šคํ† ๋ž‘ ์˜ˆ์•ฝ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ์ด์•ผ. "
+ "์‚ฌ์šฉ์ž๊ฐ€ ์˜ˆ์•ฝ์„ ์š”์ฒญํ•˜๋ฉด, 'createReservationFromAi' ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๊ธฐ ์ „์— ๋ฐ˜๋“œ์‹œ 'getAvailableCouponsForAi' ํ•จ์ˆ˜๋ฅผ ๋จผ์ € ํ˜ธ์ถœํ•ด์„œ ์‚ฌ์šฉ์ž์—๊ฒŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ฟ ํฐ์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์žˆ๋‹ค๋ฉด ์–ด๋–ค ์ฟ ํฐ์„ ์‚ฌ์šฉํ• ์ง€ ๋ฌผ์–ด๋ด์•ผ ํ•ด."
+ "๋งŒ์•ฝ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ฟ ํฐ์ด ์—†๋‹ค๋ฉด, ๋ฐ”๋กœ 'createReservationFromAi' ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ด์„œ ์˜ˆ์•ฝ์„ ์ง„ํ–‰ํ•ด. "
+ "์‚ฌ์šฉ์ž๊ฐ€ ์ฟ ํฐ์„ ์‚ฌ์šฉํ•˜๊ฒ ๋‹ค๊ณ  ํ•˜๋ฉด, ๋‹ต๋ณ€์—์„œ ์ฟ ํฐ ID(์ˆซ์ž)๋ฅผ ์ •ํ™•ํžˆ ์ถ”์ถœํ•˜์—ฌ 'createReservationFromAi' ํ•จ์ˆ˜์˜ 'couponId' ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋ฐ˜๋“œ์‹œ ํฌํ•จ์‹œ์ผœ์„œ ํ˜ธ์ถœํ•ด์•ผ ํ•ด. "
+ "ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๊ธฐ ์ „์— '๋งค์žฅ ์ด๋ฆ„', '๋‚ ์งœ', '์‹œ๊ฐ„', '์ธ์›์ˆ˜' 4๊ฐ€์ง€ ์ •๋ณด๊ฐ€ ๋ชจ๋‘ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด. "
+ "์ •๋ณด๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์‚ฌ์šฉ์ž์—๊ฒŒ ์ถ”๊ฐ€ ์ •๋ณด๋ฅผ ์š”์ฒญํ•ด. "
+ "๋ชจ๋“  ๋‹ต๋ณ€์€ ํ•œ๊ตญ์–ด๋กœ, ์นœ์ ˆํ•˜๊ณ  ๋ช…ํ™•ํ•˜๊ฒŒ ์ œ๊ณตํ•ด์•ผ ํ•ด.";
}

private final ChatClient chatClient;
private final ChatbotDbService dbService;
private final ReservationService reservationService;
private final CouponService couponService;

// ํŠธ๋žœ์žญ์…˜ ์—†์Œ โ€” ํ๋ฆ„ ์ œ์–ด๋งŒ
public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) {
// 1๋‹จ๊ณ„: DB ์ž‘์—… (@Transactional) โ€” ์„ธ์…˜ ์กฐํšŒ/์ƒ์„ฑ, ์ผ์ผ ์ œํ•œ ํ™•์ธ, ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ ์ €์žฅ
Long sessionId = dbService.saveUserMessage(userId, request.message());

// 2๋‹จ๊ณ„: AI ํ˜ธ์ถœ (ํŠธ๋žœ์žญ์…˜ ์—†์Œ โ€” DB ์ปค๋„ฅ์…˜ ์•ˆ ์žก์Œ)
List<ChatMessage> history = dbService.getRecentHistory(sessionId, MAX_HISTORY_SIZE);
String reply = callAi(history);

String reply = callAi(history, userId);

if (reply == null || reply.isBlank()) {
throw new CustomException(ErrorCode.CHAT_AI_ERROR);
}

// 3๋‹จ๊ณ„: DB ์ž‘์—… (@Transactional) โ€” AI ์‘๋‹ต ์ €์žฅ
ChatMessage saved = dbService.saveAssistantMessage(sessionId, reply);

return new ChatMessageResponse(saved.getId(), reply);
}

public List<ChatMessageListResponse> getMessages(Long userId) {
return dbService.getMessages(userId);
}

private String callAi(List<ChatMessage> history) {
List<Message> messages = new java.util.ArrayList<>();
messages.add(new SystemMessage(SYSTEM_PROMPT));
private String callAi(List<ChatMessage> history, Long userId) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(buildSystemPrompt()));

for (ChatMessage msg : history) {
if (msg.getRole() == MessageRole.USER) {
Expand All @@ -67,22 +78,33 @@ private String callAi(List<ChatMessage> history) {
}

try {
return chatClient.prompt(new Prompt(messages))
return chatClient.prompt()
.messages(messages)
.tools(reservationService, couponService)
.toolContext(Map.of("userId", userId))
.call()
.content();

} catch (Exception e) {
String message = getFullErrorMessage(e).toLowerCase();
if (message.contains("api key") || message.contains("auth") || message.contains("401")) {
throw new CustomException(ErrorCode.CHAT_AI_AUTH_ERROR);
}
if (message.contains("rate") || message.contains("429") || message.contains("quota")) {
throw new CustomException(ErrorCode.CHAT_AI_RATE_LIMIT);
}
if (message.contains("timeout") || message.contains("timed out")) {
throw new CustomException(ErrorCode.CHAT_AI_TIMEOUT);
}
throw new CustomException(ErrorCode.CHAT_AI_ERROR);
handleAiException(e);
return null;
}
}

private void handleAiException(Exception e) {
log.error("AI API ํ˜ธ์ถœ ์ค‘ ์˜ˆ์™ธ ๋ฐœ์ƒ", e);

String message = getFullErrorMessage(e).toLowerCase();
if (message.contains("api key") || message.contains("auth") || message.contains("401")) {
throw new CustomException(ErrorCode.CHAT_AI_AUTH_ERROR);
}
if (message.contains("rate") || message.contains("429") || message.contains("quota")) {
throw new CustomException(ErrorCode.CHAT_AI_RATE_LIMIT);
}
if (message.contains("timeout") || message.contains("timed out")) {
throw new CustomException(ErrorCode.CHAT_AI_TIMEOUT);
}
throw new CustomException(ErrorCode.CHAT_AI_ERROR);
}

private String getFullErrorMessage(Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import com.catchtable.coupon.entity.Coupon;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.util.List;

public interface CouponRepository extends JpaRepository<Coupon, Long> {
Expand All @@ -13,4 +15,15 @@ public interface CouponRepository extends JpaRepository<Coupon, Long> {
List<Coupon> findAllByUserId(@Param("userId") Long userId);

boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId);

/**
* UNUSED ์ƒํƒœ์˜ ์ฟ ํฐ ์ค‘ ํ…œํ”Œ๋ฆฟ ๋งŒ๋ฃŒ ์‹œ๊ฐ์ด ์ง€๋‚œ ๊ฒƒ์„ ์ผ๊ด„ EXPIRED๋กœ ์ „ํ™˜ํ•œ๋‹ค.
* ๊ฒฝ๋กœ ํ‘œํ˜„์‹(c.couponTemplate.expiredAt) ์‚ฌ์šฉ ์‹œ Hibernate๊ฐ€ ์•”์‹œ์  ์กฐ์ธ์„ ์ฒ˜๋ฆฌํ•œ๋‹ค.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Coupon c SET c.status = com.catchtable.coupon.entity.CouponStatus.EXPIRED " +
"WHERE c.status = com.catchtable.coupon.entity.CouponStatus.UNUSED " +
"AND c.isDeleted = false " +
"AND c.couponTemplate.expiredAt < :now")
int expireCoupons(@Param("now") LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.catchtable.coupon.scheduler;

import com.catchtable.coupon.repository.CouponRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.LocalDateTime;

@Slf4j
@Component
@RequiredArgsConstructor
public class CouponExpirationScheduler {

private final CouponRepository couponRepository;
private final Clock clock;

/**
* 5๋ถ„ ์ฃผ๊ธฐ๋กœ ๋งŒ๋ฃŒ๋œ ์ฟ ํฐ์„ EXPIRED ์ƒํƒœ๋กœ ์ „ํ™˜ํ•œ๋‹ค.
* ๋ฒŒํฌ UPDATE ๋‹จ์ผ ์ฟผ๋ฆฌ๋กœ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ ํŠธ๋žœ์žญ์…˜ ๋ถ€๋‹ด์ด ์ž‘๋‹ค.
*/
@Scheduled(cron = "0 */5 * * * *")
@Transactional
public void expireCoupons() {
LocalDateTime now = LocalDateTime.now(clock);
int affected = couponRepository.expireCoupons(now);

if (affected > 0) {
log.info("[์ฟ ํฐ ๋งŒ๋ฃŒ] {}๊ฑด EXPIRED ์ฒ˜๋ฆฌ ์™„๋ฃŒ", affected);
}
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/catchtable/coupon/service/CouponService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.catchtable.coupon.dto.read.CouponReadResponse;
import com.catchtable.coupon.dto.read.CouponTemplateActiveResponse;
import com.catchtable.coupon.entity.Coupon;
import com.catchtable.coupon.entity.CouponStatus;
import com.catchtable.coupon.entity.CouponTemplate;
import com.catchtable.coupon.repository.CouponRepository;
import com.catchtable.coupon.repository.CouponTemplateRepository;
Expand All @@ -14,12 +15,17 @@
import com.catchtable.user.entity.User;
import com.catchtable.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class CouponService {
Expand Down Expand Up @@ -96,4 +102,35 @@ public void returnCoupon(Long couponId) {

coupon.returnCoupon();
}

@Tool(description = "์‚ฌ์šฉ์ž๊ฐ€ ์˜ˆ์•ฝ ์‹œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ฟ ํฐ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. " +
"์ฟ ํฐ ID์™€ ํ• ์ธ ์ •๋ณด๋ฅผ ํฌํ•จํ•˜์—ฌ ์‚ฌ์šฉ์ž์—๊ฒŒ ์–ด๋–ค ์ฟ ํฐ์„ ์‚ฌ์šฉํ• ์ง€ ๋ฌผ์–ด๋ณผ ๋•Œ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.")
@Transactional(readOnly = true)
public String getAvailableCouponsForAi(ToolContext toolContext) {
Long userId = (Long) toolContext.getContext().get("userId");
log.info("AI Tool ํ˜ธ์ถœ: getAvailableCouponsForAi, userId={}", userId);

List<Coupon> availableCoupons = couponRepository.findAllByUserId(userId).stream()
.filter(coupon -> coupon.getStatus() == CouponStatus.UNUSED && coupon.getCouponTemplate().getExpiredAt().isAfter(LocalDateTime.now()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

LocalDateTime.now() ๋Œ€์‹  ์ฃผ์ž…๋œ Clock ๋นˆ์„ ์‚ฌ์šฉํ•˜์—ฌ LocalDateTime.now(clock)์œผ๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ํ”„๋กœ์ ํŠธ ๋‚ด ๋‹ค๋ฅธ ์Šค์ผ€์ค„๋Ÿฌ ๋กœ์ง๋“ค๊ณผ ์‹œ๊ฐ„ ๊ธฐ์ค€์„ ํ†ต์ผํ•˜์—ฌ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ณ  ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๋†’์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

.collect(Collectors.toList());

if (availableCoupons.isEmpty()) {
return "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ฟ ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค.";
}

return availableCoupons.stream()
.map(coupon -> {
String discountInfo;
if (coupon.getCouponTemplate().getDiscountRate() != null) {
discountInfo = coupon.getCouponTemplate().getDiscountRate() + "% ํ• ์ธ";
} else {
discountInfo = coupon.getCouponTemplate().getAmount() + "์› ํ• ์ธ";
}
return String.format("%s (ID: %d, %s)",
coupon.getCouponTemplate().getCouponName(),
coupon.getId(),
discountInfo);
})
.collect(Collectors.joining(", "));
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/catchtable/global/config/SchedulerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.catchtable.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

import java.time.Clock;
import java.time.ZoneId;

@Configuration
@EnableScheduling
public class SchedulerConfig {

/**
* ์Šค์ผ€์ค„๋Ÿฌ ๋ฐ ์‹œ๊ฐ„ ๋น„๊ต ๋กœ์ง์—์„œ ์‚ฌ์šฉํ•  Clock.
* KST(Asia/Seoul) ๊ธฐ์ค€์œผ๋กœ ๊ณ ์ •ํ•˜์—ฌ Docker ์ปจํ…Œ์ด๋„ˆ ๊ธฐ๋ณธ ํƒ€์ž„์กด(UTC) ์˜ํ–ฅ์„ ๋ฐ›์ง€ ์•Š๋„๋ก ํ•œ๋‹ค.
* LocalDateTime.now(clock) ํ˜•ํƒœ๋กœ ์‚ฌ์šฉํ•˜๋ฉด ํƒ€์ž„์กด ์ผ๊ด€์„ฑ๊ณผ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์ด ๋ชจ๋‘ ํ™•๋ณด๋œ๋‹ค.
*/
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Seoul"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/v3/api-docs/**",
"/swagger-resources/**",
"/actuator/health",
"/actuator/info"
"/actuator/info",
"/images/**"
).permitAll()
.requestMatchers(HttpMethod.GET,
"/api/v1/stores/**",
Expand Down
Loading
Loading