Skip to content
Merged
127 changes: 126 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,126 @@
# TagCafe-BE
# ☕️ TagCafe

**카공해야 되는데.. 어디 카페 가지?**

> 와이파이는 되나? 책상은 넓나? 콘센트는 있나? ...
> 생각할 게 너무 많아 🤯

혹시 이런 고민 한 번이라도 해본 적 있다면,
**당신을 위한 카공 맞춤형 카페 지도 플랫폼, TagCafe!**

---
## 📝 프로젝트 소개
![image](https://github.com/user-attachments/assets/fd6a2a54-eb7a-4d7a-98e0-4da37fa2d79c)

- TagCafe는 **와이파이, 책상 크기, 콘센트, 화장실, 주차 여부** 등
**카공에 꼭 필요한 요소**를 기반으로 필터링하여 카페를 찾을 수 있는 플랫폼입니다.
- **사용자 리뷰와 제보**를 통해 지속적으로 데이터가 업데이트됩니다.
- **카페 저장 / 방문 여부 체크** 기능으로
내가 좋아하는 카페를 기억하고 다시 찾기 편리합니다.

### 🌐 배포 주소

- 웹 서비스: [https://tagcafe.site](https://tagcafe.site)

<br>

### 🔍 주요 기능

#### 🤎 홈
- 키워드 검색 (지역명, 카페명)
- **태그 필터**로 조건 검색
`영업시간`, `와이파이`, `책상`, `콘센트`, `화장실`, `주차`, `평점` 등 👉 *다중 선택 가능*
- 지도에서 카페 선택 → 상세 페이지로 이동
- **현재 위치로 지도 이동** 기능 지원

#### 🤎 카페 상세페이지
- 기본 정보: 사진, 카페명, 주소, 운영시간
- 태그 정보: 와이파이, 책상, 콘센트, 화장실, 주차, 평점 등
- 지도 위치, 전화번호 표시
- 사용자 리뷰 목록 조회 및 작성 가능

#### 🤎 저장한 카페
- 내가 저장한 카페 리스트
→ 카드 형식 UI + 주요 태그 아이콘 표시 (와이파이, 콘센트)
- 태그 기반 필터링 가능
- **방문 여부 체크 & 필터링** 가능

#### 🤎 마이페이지 - 리뷰
- 내가 작성한 리뷰 **조회 / 수정 / 삭제** 가능

#### 🤎 마이페이지 - 제보
- **추천하고 싶은 카공 카페 직접 제보** 가능
- 태그 선택 + 리뷰 작성
- 관리자의 승인 후 정식 등록
- 승인 전에는 **수정 가능**

#### 🤎 사이드메뉴
- 자주 묻는 질문 (FAQ)
- 의견 및 오류 제보
- 닉네임 수정
- 로그아웃 / 회원 탈퇴

<br>

### 👩🏻‍💻 팀원 소개

| 이름 | 역할 |
|:----:|----------------------------------------------------------------|
| <div align="center"><a href="https://github.com/ghi512"><img src="https://avatars.githubusercontent.com/ghi512" width="100"/><br/>김민지</a></div> | - 카페 검색 및 지도 기반 조회 기능 구현 (카카오맵 API, 구글맵 API 연동)<br/>- 홈, 상세 페이지, 저장한 카페 UI 구현<br/>- Swagger 활용 API 문서화 |
| <div align="center"><a href="https://github.com/jjinleee"><img src="https://avatars.githubusercontent.com/jjinleee" width="100"/><br/>이진</a></div> | - 회원가입, 로그인 기능 구현 (카카오 OAuth2 연동) <br/>- 마이페이지 리뷰/제보 기능 개발 (등록/조회/수정)<br/>- 회원관리, 마이페이지, 사이드메뉴 UI 구현<br/>- CI/CD 설정 및 배포 자동화 |


<br>

### 🛠 기술 스택

> #### UI
<!-- figma -->
<img src="https://img.shields.io/badge/figma-F24E1E?style=for-the-badge&logo=figma&logoColor=white">

> #### Frontend
<!--react, javascript, css-->
<img src="https://img.shields.io/badge/react-61DAFB?style=for-the-badge&logo=react&logoColor=black" /> <img src="https://img.shields.io/badge/javascript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black" /> <img src="https://img.shields.io/badge/css-1572B6?style=for-the-badge&logo=css3&logoColor=white" />

> #### Backend
<!-- Java, springboot -->
<img src="https://img.shields.io/badge/java-007396?style=for-the-badge&logo=React-61DAFB&logoColor=white" /> <img src="https://img.shields.io/badge/springboot-6DB33F?style=for-the-badge&logo=springboot&logoColor=white" />

> #### 데이터베이스
<!-- mysql, rds -->
<img src="https://img.shields.io/badge/mysql-4479A1?style=for-the-badge&logo=mysql&logoColor=white" /> <img src="https://img.shields.io/badge/amazonrds-527FFF?style=for-the-badge&logo=amazonrds&logoColor=white" />

> #### 배포
<!-- ec2, -->
<img src="https://img.shields.io/badge/amazonec2-FF9900?style=for-the-badge&logo=amazonec2&logoColor=white" /> <img src="https://img.shields.io/badge/githubactions-2088FF?style=for-the-badge&logo=githubactions&logoColor=white" />


> #### 개발 환경
<!-- vscode, intellij -->
<img src="https://img.shields.io/badge/VSCode-007ACC?style=for-the-badge&logo=Visual%20Studio%20Code&logoColor=fff" /> <img src="https://img.shields.io/badge/Nintellijidea-000000?style=for-the-badge&logo=intellijidea&logoColor=white" />


> #### 협업
<!-- notion, github-->
<img src="https://img.shields.io/badge/Notion-000000?style=for-the-badge&logo=notion&logoColor=white" /> <img src="https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white" />

<br>

## 💡 설계

### 🎨 Figma
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/f3a6c6df-e9ab-4097-a556-e0e085d70629" />
<br>

### 🗄️ ERD
<img width="1000" src="https://github.com/user-attachments/assets/995ff332-a88d-4e25-b26e-b64df6b6f602" />
<br>

### 🔍 API 명세서
- Swagger API 문서: [https://tagcafe.site/swagger-ui/index.html](https://tagcafe.site/swagger-ui/index.html)

<br>

### 🏗️ System Architecture

<img width="500" src="https://github.com/user-attachments/assets/a02ebb63-74b5-425d-805c-4ed516279c43" />
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.Minjin.TagCafe.entity.User;
import com.Minjin.TagCafe.repository.UserRepository;
import com.Minjin.TagCafe.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -21,6 +23,7 @@
import java.util.Map;
import java.util.Optional;

@Tag(name = "Auth", description = "카카오 로그인 및 사용자 인증 관련 API")
@RestController
@RequestMapping("/oauth/kakao")
@RequiredArgsConstructor
Expand All @@ -33,6 +36,7 @@ public class KakaoAuthController {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;

@Operation(summary = "카카오 로그인 시작", description = "카카오 로그인 인증 페이지로 리디렉션합니다.")
@GetMapping("/login")
public RedirectView kakaoLogin() {
String kakaoAuthUrl = "https://kauth.kakao.com/oauth/authorize"
Expand All @@ -44,6 +48,7 @@ public RedirectView kakaoLogin() {
return new RedirectView(kakaoAuthUrl);
}

@Operation(summary = "카카오 로그인 콜백", description = "카카오 인증 후 사용자 정보를 받아옵니다.")
@GetMapping("/callback")
public RedirectView kakaoCallback(@RequestParam(name="code") String code) {

Expand Down Expand Up @@ -119,6 +124,7 @@ public RedirectView kakaoCallback(@RequestParam(name="code") String code) {
+ "&token=" + jwtToken);
}

@Operation(summary = "로그인한 사용자 정보 조회", description = "현재 로그인한 사용자의 정보를 반환합니다.")
@GetMapping("/userinfo")
public ResponseEntity<String> getUserInfo(@RequestParam("access_token") String accessToken) {
String userInfoUrl = "https://kapi.kakao.com/v2/user/me";
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/Minjin/TagCafe/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.Minjin.TagCafe.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("TagCafe API")
.description("카공러 맞춤형 카페 정보 플랫폼 TagCafe의 API 명세서입니다.")
.version("v1.0.0"));
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/Minjin/TagCafe/controller/CafeController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.Minjin.TagCafe.entity.Cafe;
import com.Minjin.TagCafe.repository.CafeRepository;
import com.Minjin.TagCafe.service.CafeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -15,6 +17,7 @@
import java.util.*;
import java.util.stream.Collectors;

@Tag(name = "Cafe", description = "카페 정보 조회 및 필터 검색 API")
@RestController
@RequestMapping("/cafes")
@RequiredArgsConstructor
Expand All @@ -24,6 +27,7 @@ public class CafeController {
private final CafeRepository cafeRepository;

// id로 카페 조회
@Operation(summary = "ID로 카페 조회", description = "카페 ID를 기반으로 카페 상세 정보를 조회합니다.")
@GetMapping("/{cafeId}")
public ResponseEntity<CafeDto> getCafeById(@PathVariable("cafeId") Long cafeId) {
Cafe cafe = cafeService.getCafeById(cafeId);
Expand Down Expand Up @@ -54,6 +58,7 @@ public ResponseEntity<CafeDto> getCafeById(@PathVariable("cafeId") Long cafeId)
}

// 검색
@Operation(summary = "카페 검색", description = "키워드로 카페를 검색합니다.")
@GetMapping("/search")
public ResponseEntity<List<CafeSearchDTO>> searchCafe(@RequestParam(name = "query") String query) {
List<Cafe> cafes = cafeService.searchCafeByKeyword(query);
Expand All @@ -72,6 +77,7 @@ public ResponseEntity<List<CafeSearchDTO>> searchCafe(@RequestParam(name = "quer
}

// 지도 영역 내 카페 조회 (위경도 범위 내 검색)
@Operation(summary = "지도의 특정 영역 내 카페 조회", description = "지도 상에서 특정 위경도 범위에 있는 카페 목록을 반환합니다.")
@GetMapping("/area")
public ResponseEntity<List<CafeHomeDTO>> getCafesInArea(@RequestParam(name = "minLat") double minLat,
@RequestParam(name = "maxLat") double maxLat,
Expand Down Expand Up @@ -105,6 +111,7 @@ public ResponseEntity<List<CafeHomeDTO>> getCafesInArea(@RequestParam(name = "mi
}

// 특정 태그와 특정 값을 가진 카페 조회
@Operation(summary = "여러 태그 조건으로 카페 필터링", description = "태그 이름과 값들을 기준으로 카페를 필터링하여 조회합니다.")
@GetMapping("/filter")
public ResponseEntity<List<CafeHomeDTO>> getCafesByMultipleTags(@RequestParam(name = "tagNames") List<String> tagNames,
@RequestParam(name = "values") List<String> values) {
Expand Down Expand Up @@ -152,13 +159,15 @@ public ResponseEntity<List<CafeHomeDTO>> getCafesByMultipleTags(@RequestParam(na


// admin - 카페 검색 후 db 저장
@Operation(summary = "admin - 카페 저장", description = "관리자가 카페 정보를 저장합니다.")
@PostMapping
public ResponseEntity<?> addCafe(@RequestBody CafeDto cafeDto) {
Cafe savedCafe = cafeService.addCafe(cafeDto);
return ResponseEntity.ok(savedCafe);
}

// admin - 태그 값 업데이트
@Operation(summary = "admin - 카페 태그 수정", description = "특정 카페의 태그 정보(wifi, 책상 등)를 수정합니다.")
@PutMapping("/{cafeId}/tags")
public ResponseEntity<Cafe> updateCafeTags(@PathVariable("cafeId") Long cafeId,
@RequestBody CafeDto cafeDto) {
Expand All @@ -176,12 +185,14 @@ public ResponseEntity<Cafe> updateCafeTags(@PathVariable("cafeId") Long cafeId,
}

// 모든 카페 조회 API 추가
@Operation(summary = "모든 카페 조회", description = "전체 카페 목록을 조회합니다.")
@GetMapping
public ResponseEntity<List<Cafe>> getAllCafes() {
List<Cafe> cafes = cafeService.getAllCafes();
return ResponseEntity.ok(cafes);
}

@Operation(summary = "카페 태그 조회", description = "카페 ID를 기준으로 태그(wifi, 콘센트 등) 정보를 조회합니다.")
@GetMapping("/{cafeId}/tags")
public ResponseEntity<Map<String, String>> getCafeTags(@PathVariable("cafeId") Long cafeId) {
Cafe cafe = cafeRepository.findById(cafeId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.Minjin.TagCafe.entity.QA;
import com.Minjin.TagCafe.repository.FeedbackRepository;
import com.Minjin.TagCafe.repository.QARepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -16,6 +18,7 @@
import java.util.Map;
import java.util.stream.Collectors;

@Tag(name = "FAQ", description = "자주 묻는 질문 및 사용자 피드백 관련 API")
@RestController
@RequestMapping("/faq")
@CrossOrigin(origins = "http://tagcafe.site")
Expand All @@ -30,13 +33,15 @@ public FAQController(FeedbackRepository feedbackRepository, QARepository qaRepos


//자주 묻는 질문 조회
@Operation(summary = "자주 묻는 질문 조회", description = "모든 QA 데이터를 조회합니다.")
@GetMapping("/qa")
public ResponseEntity<List<QA>> getQAs() {
List<QA> qaList = qaRepository.findAll();
return ResponseEntity.ok(qaList);
}

//사용자 의견 및 오류 제보 제출
@Operation(summary = "사용자 피드백 제출", description = "사용자의 의견이나 오류 제보를 저장합니다.")
@PostMapping("/feedback")
public ResponseEntity<Map<String, String>> submitFeedback(@RequestBody FeedbackRequest request) {
Feedback feedback = new Feedback();
Expand All @@ -51,6 +56,7 @@ public ResponseEntity<Map<String, String>> submitFeedback(@RequestBody FeedbackR
}

// ✅ 관리자용 - 사용자 의견 및 오류 목록 조회 API
@Operation(summary = "admin - 사용자 피드백 전체 조회", description = "관리자가 보는 사용자 피드백 목록입니다.")
@GetMapping("/feedback")
public ResponseEntity<List<FeedbackResponse>> getFeedbackList() {
List<FeedbackResponse> feedbacks = feedbackRepository.findAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.Minjin.TagCafe.controller;

import io.swagger.v3.oas.annotations.Hidden;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.view.RedirectView;

@Hidden
@RestController
public class HomeController {
@GetMapping("/")
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/Minjin/TagCafe/controller/MyController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.Minjin.TagCafe.entity.Cafe;
import com.Minjin.TagCafe.repository.CafeRepository;
import com.Minjin.TagCafe.service.ReviewService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
Expand All @@ -14,6 +16,7 @@
import java.util.List;
import java.util.Map;

@Tag(name = "My", description = "마이페이지 - 내가 작성한 리뷰 조회 및 관리 API")
@RestController
@RequestMapping("/my")
public class MyController {
Expand All @@ -23,6 +26,7 @@ public class MyController {
@Autowired
private CafeRepository cafeRepository;

@Operation(summary = "내가 작성한 리뷰 목록 조회", description = "userEmail을 기반으로 사용자가 작성한 리뷰 목록을 조회합니다.")
@GetMapping("/reviews")
public ResponseEntity<List<ReviewDTO>> getUserReviews(
@RequestHeader("Authorization") String authorizationHeader,
Expand All @@ -40,6 +44,7 @@ public ResponseEntity<List<ReviewDTO>> getUserReviews(
return ResponseEntity.ok(reviews);
}

@Operation(summary = "리뷰 상세 조회", description = "리뷰 ID를 기반으로 해당 리뷰의 상세 정보와 카페 정보를 조회합니다.")
@GetMapping("/reviews/{reviewId}")
public ResponseEntity<Map<String, Object>> getReviewById(@PathVariable("reviewId") Long reviewId) {
if (reviewId == null) {
Expand All @@ -65,12 +70,14 @@ public ResponseEntity<Map<String, Object>> getReviewById(@PathVariable("reviewId
}
}

@Operation(summary = "리뷰 수정", description = "리뷰 ID를 기반으로 내용을 수정합니다.")
@PutMapping("/reviews/{reviewId}")
public ResponseEntity<String> updateReview(@PathVariable("reviewId") Long reviewId, @RequestBody ReviewDTO dto) {
reviewService.updateReview(reviewId, dto);
return ResponseEntity.ok("리뷰가 성공적으로 수정되었습니다.");
}

@Operation(summary = "리뷰 삭제", description = "리뷰 ID를 기반으로 리뷰를 삭제합니다.")
@DeleteMapping("/reviews/{reviewId}")
public ResponseEntity<String> deleteReview(@PathVariable("reviewId") Long reviewId) {
try {
Expand All @@ -80,6 +87,8 @@ public ResponseEntity<String> deleteReview(@PathVariable("reviewId") Long review
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("리뷰 삭제 실패");
}
}

@Operation(summary = "태그 기반 리뷰 필터 조회", description = "userEmail과 선택한 태그 조건을 바탕으로 리뷰를 필터링하여 조회합니다.")
@GetMapping("/reviews/filter")
public ResponseEntity<List<ReviewDTO>> getReviewsByCafeWithFilter(
@RequestParam("userEmail") String userEmail,
Expand Down
Loading