diff --git a/backend/.gitattributes b/.gitattributes
similarity index 58%
rename from backend/.gitattributes
rename to .gitattributes
index 8af972cd..b403581f 100644
--- a/backend/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,4 @@
/gradlew text eol=lf
+*.sh text eol=lf
*.bat text eol=crlf
-*.jar binary
+*.jar binary
\ No newline at end of file
diff --git a/.github/hooks/pre-commit b/.github/hooks/pre-commit
index 4da562c8..bb8075fd 100755
--- a/.github/hooks/pre-commit
+++ b/.github/hooks/pre-commit
@@ -20,15 +20,17 @@ part=$(echo "$current_branch" | cut -d'/' -f2)
#################### 프론트엔드 스크립트 ####################
-if [ "$part" = "fe" ]; then
+# 3) 대상 파일이 없으면 스킵
+if [ -z "$frontend_files" ]; then
+ echo "[pre-commit] No frontend files to lint. Skipping."
+elif [ "$part" = "fe" ]; then
# eslint 실행을 위해 frontend 폴더로 이동
cd frontend
echo "[pre-commit] ⏳ Checking lint for staged files..."
+
# Lint fix 실행
-if ! pnpm exec eslint --fix $frontend_files; then
- echo "[pre-commit] ❌ Lint errors remain after fixing. Commit aborted."
- exit 1
-fi
+ printf '%s\0' $frontend_files \
+ | xargs -0 -n 80 pnpm exec eslint --fix
# Lint로 fix된 파일들을 다시 스테이징
echo "[pre-commit] Staging fixed files..."
@@ -38,7 +40,6 @@ fi
echo ""
fi
-
#################### 백엔드 스크립트 ####################
if [ "$part" = "be" ]; then
diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push
index 04ade264..24eee039 100755
--- a/.github/hooks/pre-push
+++ b/.github/hooks/pre-push
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env bash
#################### 공통 스크립트 ####################
diff --git a/.github/workflows/be-cd.yml b/.github/workflows/be-cd.yml
index 719e5e2c..7c1b75ff 100644
--- a/.github/workflows/be-cd.yml
+++ b/.github/workflows/be-cd.yml
@@ -7,6 +7,8 @@ on:
- main
paths:
- "backend/**"
+ - "script/deploy/backend_deploy.sh"
+ - ".github/workflows/be-cd.yml"
jobs:
deploy:
@@ -39,6 +41,16 @@ jobs:
run: |
./gradlew clean build -Dspring.profiles.active=prod -x test
+ - name: Copy deploy_script to EC2
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USERNAME }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ source: "script/deploy/backend_deploy.sh"
+ target: "~/app/deploy"
+ strip_components: 2
+
# EC2로 파일 전송
- name: Copy files to EC2
uses: appleboy/scp-action@v0.1.7
@@ -57,5 +69,5 @@ jobs:
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
- cd ~/app
- bash deploy_scripts/backend_deploy.sh
+ cd ~/app/deploy
+ bash ./backend_deploy.sh
diff --git a/.github/workflows/be-ci.yml b/.github/workflows/be-ci.yml
index c7b79e74..da1a20c8 100644
--- a/.github/workflows/be-ci.yml
+++ b/.github/workflows/be-ci.yml
@@ -6,11 +6,12 @@ on:
- dev
paths:
- "backend/**"
+ - ".github/workflows/be-ci.yml"
jobs:
backend:
runs-on: ubuntu-latest
-
+
defaults:
run:
working-directory: ./backend
diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml
new file mode 100644
index 00000000..6b754028
--- /dev/null
+++ b/.github/workflows/changesets.yml
@@ -0,0 +1,44 @@
+name: Changesets
+
+on:
+ push:
+ branches:
+ - main
+
+env:
+ CI: true
+
+jobs:
+ version:
+ timeout-minutes: 15
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code repository
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Set up .npmrc
+ run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
+ env:
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - name: Create PR and publish versions
+ uses: changesets/action@v1
+ with:
+ commit: "[FE-Chore] update versions"
+ title: "[FE-Chore] update versions"
+ publish: pnpm changeset publish
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/fe-cd.yml b/.github/workflows/fe-cd.yml
index e730a16f..e0c379b6 100644
--- a/.github/workflows/fe-cd.yml
+++ b/.github/workflows/fe-cd.yml
@@ -7,6 +7,9 @@ on:
- main
paths:
- "frontend/**"
+ - "script/deploy/frontend_deploy.sh"
+ - "script/deploy/nginx/**"
+ - ".github/workflows/fe-cd.yml"
jobs:
deploy:
@@ -18,7 +21,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: "20"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -33,19 +36,65 @@ jobs:
- name: Build Frontend
working-directory: ./frontend
run: |
- pnpm install
+ pnpm install --frozen-lockfile
pnpm build
+ - name: Copy nginx file to EC2
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USERNAME }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ source: "script/deploy/nginx/*"
+ target: "~/app/deploy/nginx"
+ strip_components: 3
+
+ - name: Copy deploy script to EC2
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USERNAME }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ source: "script/deploy/frontend_deploy.sh"
+ target: "~/app/deploy"
+ strip_components: 2
+
+ # jq 설치
+ - name: Install jq
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y jq
+
+ # 폴더 구조 준비
+ - name: Prepare dist folder structure
+ run: |
+ mkdir -p frontend/dist/client
+ mkdir -p frontend/dist/server
+ cp -r frontend/apps/client/dist/* frontend/dist/client/
+ cp -r frontend/apps/server/dist/* frontend/dist/server/
+ jq 'del(.devDependencies)' frontend/apps/server/package.json > frontend/dist/server/package.json
+
# EC2로 파일 전송
- name: Copy files to EC2
- uses: appleboy/scp-action@v0.1.7
+ uses: appleboy/scp-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
- source: "frontend/dist/"
+ source: "frontend/dist"
target: "~/app"
+ # 서버 pnpm install
+ - name: Install pnpm on server
+ uses: appleboy/ssh-action@v1.2.0
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USERNAME }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ script: |
+ cd ~/app/frontend/dist/server
+ pnpm install --prod
+
# 배포 스크립트 실행
- name: Deploy
uses: appleboy/ssh-action@v1.2.0
@@ -54,5 +103,5 @@ jobs:
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
- cd ~/app
- bash deploy_scripts/frontend_deploy.sh
+ cd ~/app/deploy
+ bash ./frontend_deploy.sh
diff --git a/.github/workflows/fe-ci.yml b/.github/workflows/fe-ci.yml
new file mode 100644
index 00000000..d9dd454d
--- /dev/null
+++ b/.github/workflows/fe-ci.yml
@@ -0,0 +1,53 @@
+name: fe-ci
+
+on:
+ pull_request:
+ branches:
+ - dev
+ paths:
+ - "frontend/**"
+
+jobs:
+ frontend:
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: ./frontend
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Cache node_modules
+ uses: actions/cache@v4
+ with:
+ path: ~/.pnpm-store
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+
+ - name: Set .env file
+ run: |
+ echo "${{ secrets.FE_ENV }}" > .env
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Build Frontend
+ run: pnpm build
+
+ - name: Run Tests
+ run: pnpm test
diff --git a/README.md b/README.md
index 171700e9..4736d080 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
-# ⏰ 언제 만나? 바쁜 학생들을 위한 가장 완벽한 약속 시간 찾기
-사용자들의 개인 일정을 연동하여 최적의 약속 시간을 추천하는 일정 조율 서비스
+# ⏰ 언제 만나?
+### 바쁜 학생들을 위한 가장 완벽한 약속 시간 찾기
+여러 사람의 일정을 한눈에 비교하고, 효율적으로 조율할 수 있어요. 바쁜 일정 속에서도 모임을 쉽게 계획할 수 있도록 돕는 스마트한 일정 조율 플랫폼이에요!
🔗 [배포 링크](https://unjemanna.site/)
@@ -8,6 +9,55 @@
---
+
+
+## ✨ 주요 기능
+### 일정 조율 생성
+#### 📩 초대 링크 공유로 간편한 참여
+일정 조율 논의를 생성한 후, 초대 링크를 공유해 손쉽게 논의에 참여할 수 있어요.
+
+
+| 일정 조율 생성 | 일정 조율 생성 완료 & 초대 링크 복사 |
+|-------------------------------------------|-------------------------------------------|
+|
|
|
+| 조율할 일정을 생성할 수 있어요. | 일정 조율 링크를 복사하고 공유할 수 있어요. |
+
+### 일정 조율 초대 참여 및 조회
+
+#### 🕒 최적의 후보 일정 추천
+조율할 날짜 범위와 일정이 진행될 시간대를 설정하면, 모든 참여자의 일정을 분석해 가장 적합한 후보 일정을 추천해 줘요.
+
+| 일정 조율 초대 참여 | 일정 조율 |
+|-------------------------------------------|-------------------------------------------|
+|
|
|
+| 초대 링크로 일정 조율에 참여할 수 있어요.| 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있어요. |
+
+
+
+### 일정 확정
+#### ✅ 확정된 일정 자동 반영
+일정 조율이 완료되면, 확정된 일정이 모든 참여자의 개인 일정에 자동으로 추가돼요.
+Google 캘린더와 연동된 경우, 캘린더에도 자동으로 반영되어 더욱 편리하게 관리할 수 있어요.
+
+| 일정 조율 상태 확인 | 일정 확정 |
+|-------------------------------------------|-------------------------------------------|
+|
|
|
+| 후보 일정에서 조율해야 하는 사람을 알 수 있어요. | 주최자는 후보 일정을 확정해서 일정 조율을 완료할 수 있어요. |
+
+### 개인 일정 관리
+#### 🔗 Google 캘린더 연동으로 더욱 편리하게
+사용자는 Google 캘린더와 연동하여 자신의 일정을 불러올 수 있어요.
+별도의 입력 없이 기존 일정을 기반으로 조율이 가능해요.
+
+| 내 일정 관리 | 홈 |
+|-------------------------------------------|-------------------------------------------|
+|
|
| |
+| 내 일정을 생성하고 관리할 수 있어요. | 다가오는 일정, 확정되지 않은 일정, 지난 일정을 확인할 수 있어요.|
+
+
+
+---
+
## 🛠️ 기술 스택
| 구분 | 기술 스택 |
|---------------|-----------------------------------------------------------------------------------------------------------------------------------|
@@ -16,36 +66,21 @@
| **인프라** |     |
| **소통** |  |
-
---
-
## 🏡 아키텍쳐
-
-
-
-
----
+### AWS
+
-## ✨ 메인 기능
-### 공유 일정
-
-| 일정 조율 생성 | 일정 조율 생성 완료 | 일정 조율 결과 |
-|-------------------------------------------|-------------------------------------------|-------------------------------------------|
-|
|
|
|
-| 조율할 일정을 생성할 수 있습니다. | 일정 조율 링크를 복사하고 공유할 수 있습니다. | 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있습니다. |
-
-| 내 일정 관리 | 홈 |
-|-------------------------------------------|-------------------------------------------|
-|
|
| |
-| 내 일정을 생성하고 관리할 수 있습니다. 구글 캘린더와의 연동을 지원합니다. | 다가오는 일정, 확정되지 않은 일정, 지난 일정을 확인할 수 있습니다.|
+### 시스템
+
---
-## 👥 팀원 구성
+## 👥 팀원 역할
| 이현영 |
@@ -71,11 +106,22 @@
BE |
BE(팀장) |
-
+
+ ✔ 개인/공유 일정 캘린더 구현 ✔ 디자인 토큰 & SVG & 이미지 변환 스크립트 작성 ✔ 전역 Modal, Notification 관리 & 에러핸들링 |
+ ✔ Single/Range DatePicker 구현 ✔ git hook & 슬랙 웹훅 세팅 ✔ Fetch 유틸 구현 |
+ ✔ 비트 연산 로직(후보 일정 산출) 구현 ✔ 구글 캘린더 API 연동 (+OAuth) ✔ Redis 세팅 (동현) ✔ CI/CD (동권) |
+
+
+
---
+## 📋️ ERD
+
+
+---
+
## 🤝 협업 전략
### 브랜치 구조
@@ -86,4 +132,18 @@
### 코드리뷰
`Pn룰`을 도입하여 리뷰의 중요도를 리뷰이가 알 수 있도록 합니다.
+
+
+---
+
+## 📚 팀 문서
+- [🐬 Wiki 메인](https://github.com/softeer5th/Team4-enDolphin/wiki)
+- [🤝 팀의 협업 과정이 궁금하다면?](https://github.com/softeer5th/Team4-enDolphin/wiki/Together)
+- [🔥 백엔드의 비밀이 궁금하다면?](https://github.com/softeer5th/Team4-enDolphin/wiki/Backend)
+- [💧 프론트엔드의 고민을 알고싶다면?](https://github.com/softeer5th/Team4-enDolphin/wiki/Frontend)
+- [🍭 회의록](https://bside.notion.site/efba066ebf0d47b98017b5924ce9e30d)
+
+
+
+---
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java b/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java
index d1705067..8ad3c796 100644
--- a/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java
+++ b/backend/src/main/java/endolphin/backend/domain/auth/AuthController.java
@@ -2,12 +2,18 @@
import endolphin.backend.domain.auth.dto.LoginRequest;
import endolphin.backend.domain.auth.dto.OAuthResponse;
+import endolphin.backend.domain.auth.dto.AuthTokenResponse;
+import endolphin.backend.global.util.CookieUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
+
import java.io.IOException;
+
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -17,36 +23,73 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;
+@Slf4j
@Tag(name = "Auth", description = "인증 관리 API")
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
+ private final CookieUtil cookieUtil;
@Value("${app.frontend.url}")
private String frontendUrl;
- @Value("${app.frontend.callback}")
+ @Value("${app.frontend.callback.login}")
private String frontendCallback;
@GetMapping("/api/v1/oauth2/callback")
- public void oauthCallback(@RequestParam("code") String code,
+ public void oauthCallback(@RequestParam(value = "code", required = false) String code,
+ @RequestParam(value = "error", required = false) String error,
HttpServletResponse response) throws IOException {
- String googleCode = authService.oauth2Callback(code);
-
- String redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl)
- .path(frontendCallback)
- .queryParam("code", googleCode)
- .build().toUriString();
+ String redirectUrl;
+ if (error != null || code == null || code.isBlank()) {
+ log.error("[oauthCallback] error: {}", error);
+ redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl)
+ .path(frontendCallback)
+ .queryParam("error", error)
+ .build().toUriString();
+ } else {
+ redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl)
+ .path(frontendCallback)
+ .queryParam("code", code)
+ .build().toUriString();
+ }
response.sendRedirect(redirectUrl);
}
@Operation(summary = "로그인", description = "로그인하여 JWT 토큰을 발급받습니다.")
@PostMapping("/api/v1/login")
- public ResponseEntity login(@Valid @RequestBody LoginRequest request) {
- OAuthResponse response = authService.login(request.code());
- return ResponseEntity.ok(response);
+ public ResponseEntity login(@Valid @RequestBody LoginRequest request,
+ HttpServletResponse response) {
+ AuthTokenResponse tokens = authService.login(request.code());
+ OAuthResponse oAuthResponse = tokens.oAuthResponse();
+
+ response.addCookie(cookieUtil.createRefreshTokenCookie(tokens.refreshToken()));
+
+ return ResponseEntity.ok(oAuthResponse);
+ }
+
+ @Operation(summary = "액세스 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.")
+ @PostMapping("/api/v1/refresh-token")
+ public ResponseEntity refreshToken(HttpServletRequest request,
+ HttpServletResponse response) {
+
+ AuthTokenResponse newTokens = authService.refresh(
+ cookieUtil.getRefreshToken(request.getCookies()));
+
+ response.addCookie(cookieUtil.createRefreshTokenCookie(newTokens.refreshToken()));
+
+ return ResponseEntity.ok(newTokens.oAuthResponse());
+ }
+
+ @Operation(summary = "로그아웃", description = "로그아웃하여 리프레시 토큰을 무효화합니다.")
+ @PostMapping("/api/v1/logout")
+ public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) {
+ authService.logout(
+ cookieUtil.getRefreshToken(request.getCookies()));
+ response.addCookie(cookieUtil.deleteRefreshTokenCookie());
+ return ResponseEntity.noContent().build();
}
}
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java b/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java
index 44abd046..069a4c0e 100644
--- a/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java
+++ b/backend/src/main/java/endolphin/backend/domain/auth/AuthService.java
@@ -1,6 +1,9 @@
package endolphin.backend.domain.auth;
+import endolphin.backend.domain.auth.dto.AuthTokenResponse;
+import endolphin.backend.domain.auth.dto.TokenDto;
import endolphin.backend.domain.user.UserService;
+import endolphin.backend.domain.user.dto.LoginUserDto;
import endolphin.backend.global.error.exception.ErrorCode;
import endolphin.backend.global.error.exception.OAuthException;
import endolphin.backend.global.google.dto.GoogleTokens;
@@ -8,12 +11,8 @@
import endolphin.backend.domain.auth.dto.OAuthResponse;
import endolphin.backend.domain.user.entity.User;
import endolphin.backend.global.google.GoogleOAuthService;
-import endolphin.backend.global.security.JwtProvider;
-import endolphin.backend.global.util.TimeUtil;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
+
import lombok.RequiredArgsConstructor;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -24,31 +23,36 @@ public class AuthService {
private final GoogleOAuthService googleOAuthService;
private final UserService userService;
- private final JwtProvider jwtProvider;
-
- @Value("${jwt.expired}")
- private long expired;
-
- @Transactional(readOnly = true)
- public String oauth2Callback(String code) {
- if (code == null || code.isBlank()) {
- throw new OAuthException(ErrorCode.INVALID_OAUTH_CODE);
- }
- return code;
- }
+ private final TokenService tokenService;
- public OAuthResponse login(String code) {
- GoogleTokens tokenResponse = googleOAuthService.getGoogleTokens(code);
+ public AuthTokenResponse login(String code) {
+ GoogleTokens tokenResponse = googleOAuthService.getGoogleTokens(code, true);
GoogleUserInfo userInfo = googleOAuthService.getUserInfo(tokenResponse.accessToken());
validateUserInfo(userInfo);
- User user = userService.upsertUser(userInfo, tokenResponse);
+ LoginUserDto userDto = userService.upsertUser(userInfo);
- String accessToken = jwtProvider.createToken(user.getId(), user.getEmail());
- LocalDateTime expiredAt = TimeUtil.getNow().plus(expired, ChronoUnit.MILLIS);
+ User user = userDto.user();
+ TokenDto accessToken = tokenService.createAccessToken(user);
+ TokenDto refreshToken = tokenService.createRefreshToken(user);
- return new OAuthResponse(accessToken, expiredAt);
+ return new AuthTokenResponse(OAuthResponse.of(accessToken, userDto),
+ refreshToken);
}
+ public AuthTokenResponse refresh(String refreshToken) {
+ Long userId = tokenService.getUserIdFromToken(refreshToken);
+ User user = userService.getUser(userId);
+ TokenDto newAccessToken = tokenService.createAccessToken(user);
+ TokenDto newRefreshToken = tokenService.updateRefreshToken(userId, refreshToken);
+
+ return new AuthTokenResponse(
+ OAuthResponse.of(newAccessToken),
+ newRefreshToken);
+ }
+
+ public void logout(String refreshToken) {
+ tokenService.deleteRefreshToken(refreshToken);
+ }
private void validateUserInfo(GoogleUserInfo userInfo) {
if (userInfo == null) {
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/RefreshTokenRepository.java b/backend/src/main/java/endolphin/backend/domain/auth/RefreshTokenRepository.java
new file mode 100644
index 00000000..9c244cd5
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/auth/RefreshTokenRepository.java
@@ -0,0 +1,15 @@
+package endolphin.backend.domain.auth;
+
+import endolphin.backend.domain.auth.entity.RefreshToken;
+import java.time.LocalDateTime;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface RefreshTokenRepository extends JpaRepository {
+
+ Optional findByToken(String token);
+
+ void deleteByExpirationBefore(LocalDateTime now);
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/TokenService.java b/backend/src/main/java/endolphin/backend/domain/auth/TokenService.java
new file mode 100644
index 00000000..68845de0
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/auth/TokenService.java
@@ -0,0 +1,76 @@
+package endolphin.backend.domain.auth;
+
+import endolphin.backend.domain.auth.dto.TokenDto;
+import endolphin.backend.domain.auth.entity.RefreshToken;
+import endolphin.backend.domain.user.entity.User;
+import endolphin.backend.global.error.exception.ErrorCode;
+import endolphin.backend.global.error.exception.RefreshTokenException;
+import endolphin.backend.global.security.JwtProvider;
+import endolphin.backend.global.util.TimeUtil;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class TokenService {
+
+ private final RefreshTokenRepository refreshTokenRepository;
+ private final JwtProvider jwtProvider;
+
+ public Long getUserIdFromToken(String token) {
+ if (token == null || token.isBlank()) {
+ throw new RefreshTokenException(ErrorCode.EMPTY_REFRESH_TOKEN);
+ }
+ return jwtProvider.getUserId(token);
+ }
+
+ public TokenDto createAccessToken(User user) {
+ return jwtProvider.createToken(user.getId(), user.getEmail());
+ }
+
+ public TokenDto createRefreshToken(User user) {
+ TokenDto token = jwtProvider.createToken(user.getId());
+ RefreshToken refreshToken = RefreshToken.builder()
+ .user(user)
+ .token(token.token())
+ .expiration(token.expiredAt())
+ .build();
+
+ refreshTokenRepository.save(refreshToken);
+ return token;
+ }
+
+ public TokenDto updateRefreshToken(Long userId, String token) {
+ RefreshToken refreshToken = getRefreshToken(token);
+ validateToken(refreshToken);
+ TokenDto newRefreshToken = jwtProvider.createToken(userId);
+ refreshToken.updateToken(newRefreshToken);
+ refreshTokenRepository.save(refreshToken);
+ return newRefreshToken;
+ }
+
+ public void deleteRefreshToken(String token) {
+ refreshTokenRepository.findByToken(token).ifPresent(refreshTokenRepository::delete);
+ }
+
+ private RefreshToken getRefreshToken(String token) {
+ return refreshTokenRepository.findByToken(token)
+ .orElseThrow(() -> new RefreshTokenException(ErrorCode.INVALID_REFRESH_TOKEN));
+ }
+
+ private void validateToken(RefreshToken refreshToken) {
+ if (refreshToken.getExpiration().isBefore(TimeUtil.getNow())) {
+ deleteToken(refreshToken);
+ throw new RefreshTokenException(ErrorCode.REFRESH_TOKEN_EXPIRED);
+ }
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void deleteToken(RefreshToken refreshToken) {
+ refreshTokenRepository.delete(refreshToken);
+ }
+
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/dto/AuthTokenResponse.java b/backend/src/main/java/endolphin/backend/domain/auth/dto/AuthTokenResponse.java
new file mode 100644
index 00000000..7358dfd7
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/auth/dto/AuthTokenResponse.java
@@ -0,0 +1,9 @@
+package endolphin.backend.domain.auth.dto;
+
+
+public record AuthTokenResponse(
+ OAuthResponse oAuthResponse,
+ TokenDto refreshToken
+) {
+
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java b/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java
index eae98666..4c08e0a4 100644
--- a/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java
+++ b/backend/src/main/java/endolphin/backend/domain/auth/dto/OAuthResponse.java
@@ -1,10 +1,31 @@
package endolphin.backend.domain.auth.dto;
+import endolphin.backend.domain.user.dto.LoginUserDto;
import java.time.LocalDateTime;
public record OAuthResponse(
String accessToken,
- LocalDateTime expiredAt
+ LocalDateTime expiredAt,
+ Boolean isFirstLogin,
+ Boolean isGoogleCalendarConnected
) {
+ public static OAuthResponse of(TokenDto accessToken, LoginUserDto loginUserDto) {
+ return new OAuthResponse(
+ accessToken.token(),
+ accessToken.expiredAt(),
+ loginUserDto.isFirstLogin(),
+ loginUserDto.isGoogleCalendarConnected()
+ );
+ }
+
+ public static OAuthResponse of(TokenDto accessToken) {
+ return new OAuthResponse(
+ accessToken.token(),
+ accessToken.expiredAt(),
+ null,
+ null
+ );
+ }
+
}
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/dto/TokenDto.java b/backend/src/main/java/endolphin/backend/domain/auth/dto/TokenDto.java
new file mode 100644
index 00000000..b9ef03e8
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/auth/dto/TokenDto.java
@@ -0,0 +1,10 @@
+package endolphin.backend.domain.auth.dto;
+
+import java.time.LocalDateTime;
+
+public record TokenDto(
+ String token,
+ LocalDateTime expiredAt
+) {
+
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/auth/entity/RefreshToken.java b/backend/src/main/java/endolphin/backend/domain/auth/entity/RefreshToken.java
new file mode 100644
index 00000000..0f60909c
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/auth/entity/RefreshToken.java
@@ -0,0 +1,51 @@
+package endolphin.backend.domain.auth.entity;
+
+import endolphin.backend.domain.auth.dto.TokenDto;
+import endolphin.backend.domain.user.entity.User;
+import endolphin.backend.global.base_entity.BaseTimeEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@NoArgsConstructor
+@Getter
+@Table(name = "refresh_token")
+public class RefreshToken extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Column(nullable = false, unique = true)
+ private String token;
+
+ @Column(nullable = false)
+ private LocalDateTime expiration;
+
+ @Builder
+ public RefreshToken(User user, String token, LocalDateTime expiration) {
+ this.user = user;
+ this.token = token;
+ this.expiration = expiration;
+ }
+
+ public void updateToken(TokenDto token) {
+ this.token = token.token();
+ this.expiration = token.expiredAt();
+ }
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarController.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarController.java
new file mode 100644
index 00000000..4c6ad072
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarController.java
@@ -0,0 +1,55 @@
+package endolphin.backend.domain.calendar;
+
+import endolphin.backend.domain.calendar.dto.CalendarResponse;
+import endolphin.backend.global.dto.ListResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.io.IOException;
+
+@Slf4j
+@Tag(name = "Calendar", description = "캘린더 관련 API")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1")
+public class CalendarController {
+
+ private final CalendarFacade calendarFacade;
+
+ @Value("${app.frontend.url}")
+ private String frontendUrl;
+
+ @Value("${app.frontend.callback.google-calendar}")
+ private String frontendCallback;
+
+ @GetMapping("/calendar/callback/google")
+ public void subscribeGoogleCalendar(@RequestParam(value = "code", required = false) String code,
+ @RequestParam(value = "error", required = false) String error,
+ HttpServletResponse response) throws IOException {
+ if (error != null) {
+ log.error("[subscribeGoogleCalendar] error: {}", error);
+ }
+ calendarFacade.linkGoogleCalendar(code);
+
+ String redirectUrl = UriComponentsBuilder.fromUriString(frontendUrl)
+ .path(frontendCallback)
+ .build().toUriString();
+
+ response.sendRedirect(redirectUrl);
+ }
+
+ @GetMapping("/calendar/list")
+ public ResponseEntity> calendarList() {
+ ListResponse response = calendarFacade.getCalendarList();
+ return ResponseEntity.ok(response);
+ }
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarFacade.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarFacade.java
new file mode 100644
index 00000000..86570484
--- /dev/null
+++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarFacade.java
@@ -0,0 +1,56 @@
+package endolphin.backend.domain.calendar;
+
+import endolphin.backend.domain.calendar.dto.CalendarResponse;
+import endolphin.backend.domain.calendar.entity.Calendar;
+import endolphin.backend.domain.calendar.event.GoogleCalendarLinkEvent;
+import endolphin.backend.domain.user.UserService;
+import endolphin.backend.domain.user.entity.User;
+import endolphin.backend.global.dto.ListResponse;
+import endolphin.backend.global.google.GoogleCalendarService;
+import endolphin.backend.global.google.GoogleOAuthService;
+import endolphin.backend.global.google.dto.GoogleCalendarDto;
+import endolphin.backend.global.google.dto.GoogleTokens;
+import endolphin.backend.global.google.dto.GoogleUserInfo;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class CalendarFacade {
+ private final CalendarService calendarService;
+ private final UserService userService;
+
+ private final GoogleCalendarService googleCalendarService;
+ private final GoogleOAuthService googleOAuthService;
+
+ private final ApplicationEventPublisher eventPublisher;
+
+ public void linkGoogleCalendar(String code) {
+ if (code == null || code.isBlank()) {
+ return;
+ }
+ GoogleTokens tokens = googleOAuthService.getGoogleTokens(code, false);
+ GoogleUserInfo userInfo = googleOAuthService.getUserInfo(tokens.accessToken());
+
+
+ User user = userService.getUserByEmail(userInfo.email());
+ user = userService.updateTokens(user, tokens.accessToken(), tokens.refreshToken());
+
+ GoogleCalendarDto calendarDto = googleCalendarService.getPrimaryCalendar(user);
+ calendarService.attachGoogleCalendar(calendarDto, user);
+
+ eventPublisher.publishEvent(new GoogleCalendarLinkEvent(user));
+ }
+
+ public ListResponse getCalendarList() {
+ User user = userService.getCurrentUser();
+
+ List calendars = calendarService.findAllByUserId(user.getId());
+
+ List response = calendars.stream()
+ .map(c -> new CalendarResponse(c.getName())).toList();
+ return new ListResponse<>(response);
+ }
+}
diff --git a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java
index aecfa23b..914694f6 100644
--- a/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java
+++ b/backend/src/main/java/endolphin/backend/domain/calendar/CalendarRepository.java
@@ -21,4 +21,6 @@ public interface CalendarRepository extends JpaRepository {
+ "FROM Calendar c "
+ "WHERE c.user.id IN :userIds")
List