Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,59 @@
![123](https://github.com/user-attachments/assets/a6256292-33d9-4801-9b9d-78f11d9dea13)

> Bottle note 프로젝트의 API 서버입니다.

## Bottle Note Api Server
<img src="https://github.com/bottle-note/.github/assets/97773895/fa515094-a490-4b7b-9bca-026752fe3de1" alt="Image 1" width="49.5%"/>
<img src="https://github.com/bottle-note/.github/assets/97773895/98a69d6d-af4f-4365-ac6c-b3e7bdd6aed1" alt="Image 4" width="49.5%"/>
<img src="https://github.com/bottle-note/.github/assets/97773895/5877ab4b-af23-4767-aebc-5e97a35a89ba" alt="Image 3" width="49.5%"/>
<img src="https://github.com/bottle-note/.github/assets/97773895/039cf3df-7169-46ed-a76b-72de6f1a1217" alt="Image 2" width="49.5%"/>
<br/>

### 개요(Overview)
## *bottle note*

> Bottle note 프로젝트의 API 서버입니다.
---

**📄*내 입맛에 딱 맞는 한병을 찾아가는 여정노트***

보틀노트는 위스키 애호가들이 모여 서로의 의견을 공유하고, 더욱 즐겁고 유익한 시음 경험을 할 수 있도록 도와줍니다. <br/>
위스키에 대한 열정을 함께 나눠보세요! 쉽고 편리하게 위스키 평가와 리뷰를 공유할 수 있어요!

---

**🥃 위스키 별점 및 리뷰 작성**

위스키를 시음한 후 별점을 부여하고, 상세한 리뷰를 작성해 보세요! <br/>
맛, 향, 느낌 등 다양한 측면을 기록하고 공유할 수 있습니다. <br/>
가격을 입력하고, 시음한 장소를 태그하여 좋은 경험한 장소를 기록해 보는건 어때요?<br/>
<br/>

**🥃 위스키 정보 및 리뷰 보기**

위스키의 상세 정보와 다른 사용자의 리뷰를 쉽게 확인할 수 있습니다. <br/>
다양한 의견을 통해 다른 사람들이 위스키를 어떻게 평가했는지 알 수 있고 <br/>
새로운 위스키를 시도해볼 수 있어요!<br/>
<br/>

**🥃 찜하기 기능으로 위스키 목록 관리**

관심 있는 위스키를 찜하기 기능을 통해 목록에 추가하고 <br/>
찜한 위스키 목록을 통해 나만의 위스키 컬렉션을 만들 수 있어요!<br/>
<br/>
**🥃 다른 유저와 팔로우 및 별점 보기**

다른 사용자를 팔로우하여 그들의 별점과 리뷰를 볼 수 있어요! <br/>
나의 팔로워들은 어떤 평가를 했을까요? <br/>
팔로워가 나와 취향이 비슷하다면, 내 입맛에 맞는 새로운 위스키를 알 수 있을지도?<br/>
<br/>

**🥃 나의 평가 및 별점 기록 관리**

내가 평가한 위스키와 부여한 별점을 한눈에 볼 수 있어요! <br/>
나만의 위스키 평가 기록을 통해 나의 취향을 분석하고 다음 시음을 계획해보세요!<br/>
<br/>

---

보틀노트는 위스키 애호가들이 모여 서로의 의견을 공유하고 <br/>
더욱 즐겁고 유익한 시음 경험을 할 수 있도록 도와줍니다.

Comment on lines +56 to 58

Copilot AI Jun 19, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] This paragraph duplicates content earlier in the README; consider removing the duplicate to streamline the document.

Suggested change
보틀노트는 위스키 애호가들이 모여 서로의 의견을 공유하고 <br/>
더욱 즐겁고 유익한 시음 경험을 할 수 있도록 도와줍니다.

Copilot uses AI. Check for mistakes.
![boton](https://github.com/user-attachments/assets/50bf121a-fb97-46d6-9d1f-6e56858363c1)
![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/bottle-note/bottle-note-api-server?utm_source=oss&utm_medium=github&utm_campaign=bottle-note%2Fbottle-note-api-server&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews)
![boton](https://github.com/user-attachments/assets/d8750770-1e6a-4133-86a4-298f795420a6)
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ tasks.register('unitTest', Test) {
includeTags 'unit'
}
}
tasks.register('check rule', Test) {
tasks.register('check_rule', Test) {
useJUnitPlatform {
includeTags 'rule'
}
Expand Down
2 changes: 1 addition & 1 deletion git.environment-variables
7 changes: 5 additions & 2 deletions http/위스키/위스키_검색.http
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ Content-Type: application/json
%}

### 위스키 목록 조회

GET {{host}}/api/v1/alcohols/search?category=SINGLE_MALT&cursor=1&pageSize=1&keyword=초콜릿
# @no-cookie-jar
GET {{host}}/api/v1/alcohols/search?category=SINGLE_MALT&cursor=1&pageSize=10&keyword=비 오늘 날 추천 위스키
Authorization: Bearer {{accessToken}}


### 위스키 조회 상세 조회
# @no-cookie-jar
GET {{host}}/api/v1/alcohols/4
Authorization: Bearer {{accessToken}}

### 카테고리 조회
# @no-cookie-jar
GET {{host}}/api/v1/alcohols/categories
23 changes: 23 additions & 0 deletions src/docs/asciidoc/api/auth/auth.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
=== 루트 관리자 권한 검증 ===

- 현재 로그인한 사용자가 루트 관리자 권한을 가지고 있는지 검증합니다.
- 개발 / 운영 환경이 변경 가능한지 검증하는데 활용 할 수 있습니다.

[source]
----
api/v2/auth/admin/permissions
----

[discrete]
==== 요청 파라미터 ====

[discrete]
include::{snippets}/auth/admin/root-permissions/curl-request.adoc[]
include::{snippets}/auth/admin/root-permissions/http-request.adoc[]

[discrete]
==== 응답 파라미터 ====

[discrete]
include::{snippets}/auth/admin/root-permissions/response-fields.adoc[]
include::{snippets}/auth/admin/root-permissions/http-response.adoc[]
5 changes: 5 additions & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ include::api/overview/global-auth.adoc[]
'''
include::api/file/image/upload/presign-url.adoc[]

== 권한 (auth) 관련 API

'''
include::api/auth/auth.adoc[]

== 회원 (user) 관련 API

'''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ protected boolean shouldNotFilter(HttpServletRequest request) {
* 다만 내가 픽했는지 여부등을 파악하기 위해 선택적으로 제공
* <p>
* 수정 : 2024-11-10
*
* TODO: 현재 하드코딩된 경로 관리 방식을 애노테이션 기반으로 개선 필요
* TODO: @AccessPolicy 또는 @OptionalAuth 애노테이션을 활용하여 컨트롤러에서 선언적으로 관리
* TODO: 경로 추가 시마다 두 곳(SecurityConfig + 여기)을 수정해야 하는 문제 해결 필요
*/
private boolean skipFilter(String method, String url) {
final String targetPath = method + ":" + url;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app.bottlenote.global.security.jwt;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Configuration;

@Setter
@Getter
@Configuration
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ConfigurationProperties(prefix = "security.jwt")
@ConfigurationPropertiesScan
public class JwtProperties {
private String secretKey;
private long accessTokenExpiration;
private long refreshTokenExpiration;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
@Component
public class JwtTokenProvider {

public static final int ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 24시간
public static final int REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; // 14일
public static final int ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 2;
public static final int REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14;
public static final String KEY_ROLES = "roles";
private final Key secretKey;

Expand All @@ -30,7 +30,7 @@ public class JwtTokenProvider {
* @param secret 토큰 생성용 시크릿 키
*/
public JwtTokenProvider(
@Value("${security.jwt.secret-key}") String secret
@Value("${security.jwt.secret-key}") String secret
) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
Expand All @@ -48,9 +48,9 @@ public TokenItem generateToken(String userEmail, UserType role, Long userId) {
String accessToken = createAccessToken(userEmail, role, userId);
String refreshToken = createRefreshToken(userEmail, role, userId);
return TokenItem.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}

/**
Expand All @@ -65,11 +65,11 @@ public String createAccessToken(String userEmail, UserType role, Long userId) {
Claims claims = createClaims(userEmail, role, userId);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
}

public String createGuestToken(Long userId, int expireTime) {
Expand All @@ -78,12 +78,12 @@ public String createGuestToken(Long userId, int expireTime) {
claims.put(KEY_ROLES, UserType.ROLE_GUEST.name());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setSubject("guest")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expireTime))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
.setClaims(claims)
.setSubject("guest")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expireTime))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
}

/**
Expand All @@ -98,11 +98,11 @@ public String createRefreshToken(String userEmail, UserType role, Long userId) {
Claims claims = createClaims(userEmail, role, userId);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
}

/**
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/app/bottlenote/user/controller/AuthV2Controller.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package app.bottlenote.user.controller;

import app.bottlenote.global.data.response.GlobalResponse;
import app.bottlenote.global.security.SecurityContextUtil;
import app.bottlenote.user.exception.UserException;
import app.bottlenote.user.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.RestController;

import static app.bottlenote.user.exception.UserExceptionCode.REQUIRED_USER_ID;


@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/auth")
public class AuthV2Controller {
private final AuthService authService;

@GetMapping("/admin/permissions")
public ResponseEntity<?> checkAdminStatus() {
Long currentUserId = SecurityContextUtil.getUserIdByContext().
orElseThrow(() -> new UserException(REQUIRED_USER_ID));

boolean is = authService.checkAdminStatus(currentUserId);
return GlobalResponse.ok(is);
}
}
29 changes: 29 additions & 0 deletions src/main/java/app/bottlenote/user/domain/RootAdmin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package app.bottlenote.user.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity(name = "root_admins")
@Table(name = "root_admins")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RootAdmin {

@Id
@Column(name = "user_id")
private Long userId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@MapsId
private User user;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.bottlenote.user.repository;

import app.bottlenote.user.domain.RootAdmin;
import org.springframework.data.jpa.repository.JpaRepository;

public interface RootAdminRepository extends JpaRepository<RootAdmin, Long> {
boolean existsByUserId(Long userId);
}
20 changes: 20 additions & 0 deletions src/main/java/app/bottlenote/user/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app.bottlenote.user.service;

import app.bottlenote.user.repository.RootAdminRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final RootAdminRepository rootAdminRepository;

@Transactional(readOnly = true)
public boolean checkAdminStatus(Long userId) {
return rootAdminRepository.existsByUserId(userId);
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ spring:
security:
jwt:
secret-key: ${JWT_SECRET_KEY:c2VjdXJlU2VjcmV0S2V5MTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QWJDRGVGR2hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlrSg==}
access-token-expiration: 86400 # 하루 (24시간)
refresh-token-expiration: 2592000 # 한 달 (30일)


# Actuator 설정
management:
Expand Down
Loading
Loading