Skip to content
Open
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
454 changes: 453 additions & 1 deletion README.md

Large diffs are not rendered by default.

Binary file modified src/main/java/com/mrokga/carrot_server/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public class AuthController {
private final AuthService authService;
private final UserService userService;

/**
* 휴대폰 번호로 인증번호 SMS를 발송하는 api
* @param phoneNumber 인증번호를 받을 휴대폰 번호
* @return 성공 응답 DTO
*/
@PostMapping("/send")
@Operation(summary = "인증번호 sms 발송", description = "사용자 휴대폰 번호로 인증번호 sms 발송")
@ApiResponses(value = {
Expand All @@ -45,6 +50,12 @@ public ResponseEntity<ApiResponseDto<Void>> sendSms(@Parameter(description = "
return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success"));
}

/**
* 사용자가 입력한 인증번호를 검증하는 api
* Redis에 저장된 인증번호와 비교하여 결과를 return
* @param request 휴대폰번호와 인증번호
* @return 인증 결과에 따른 응답 (200 OK, 400 BAD_REQUEST, 410 GONE)
*/
@PostMapping("/verify")
@Operation(summary = "인증번호 인증", description = "사용자가 입력한 인증번호와 redis에 저장된 값 비교")
@ApiResponses(value = {
Expand Down Expand Up @@ -75,6 +86,11 @@ public ResponseEntity<ApiResponseDto<Void>> verifyCode(@RequestBody VerifyCodeRe
};
}

/**
* 닉네임 중복 여부를 검사하는 api
* @param nickname 검사할 닉네임
* @return 중복 여부에 따른 응답 (중복 시 400 BAD_REQUEST, 사용 가능 시 200 OK)
*/
@PostMapping("/validate-nickname")
@Operation(summary = "닉네임 중복검사", description = "사용자가 입력한 닉네임이 중복되었는지 검사")
@ApiResponses(value = {
Expand All @@ -100,6 +116,11 @@ public ResponseEntity<ApiResponseDto<String>> validateNickname(@Parameter(descri
}


/**
* 새로운 사용자 회원가입을 처리하는 api
* @param request 회원가입 정보
* @return 생성된 user entity 포함된 응답 DTO
*/
@PostMapping("/signup")
@Operation(summary = "회원가입 요청", description = "회원가입 요청")
@ApiResponses(value = {
Expand All @@ -115,6 +136,11 @@ public ResponseEntity<ApiResponseDto<User>> signup(@RequestBody SignupRequestDto
return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", user));
}

/**
* 인증번호 SMS를 재발송하는 api
* @param phoneNumber 재발송을 요청할 휴대폰 번호
* @return 성공 응답 DTO
*/
@PostMapping("/resend")
@Operation(summary = "인증번호 sms 재발송", description = "사용자 휴대폰 번호로 인증번호 sms 재발송")
@ApiResponses(value = {
Expand All @@ -126,6 +152,12 @@ public ResponseEntity<ApiResponseDto<Void>> resendSms(@Parameter(description = "
return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success"));
}

/**
* 로그인 처리 api
* 인증 성공 시 사용자 정보를 조회하고 jwt를 발급한 뒤 return
* @param request 전화번호 및 입력된 인증번호
* @return 로그인 성공 시 토큰과 사용자 정보를 포함한 응답 DTO, 실패 시 에러 응답
*/
@PostMapping("/login")
@Operation(summary = "로그인 요청", description = "로그인 요청")
@ApiResponses(value = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ public class AuthService {
private static final String ACCESS_TOKEN_PREFIX = "access_token:";
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";

// SMS 발신 번호
@Value("${sms.sender}")
private String sender;

/**
* 지정된 발신번호로 인증번호 SMS를 전송하고, 인증번호를 Redis에 저장
* @param phoneNumber 인증번호를 받을 전화번호
*/
public void sendSms(String phoneNumber) {

String code = generateCode();
Expand All @@ -53,24 +58,35 @@ public void sendSms(String phoneNumber) {
try {
messageService.send(message);
} catch (NurigoMessageNotReceivedException e) {
redisTemplate.delete(key);
log.info("failed message list = {}", e.getFailedMessageList());
log.info("exception = {}", e.getMessage());
} catch (Exception e) {
redisTemplate.delete(key);
log.info("exception = {}", e.getMessage());
}
}

/**
* 사용자가 입력한 인증번호의 유효성 검증
* @param phoneNumber 전화번호 (Redis Key 조회용)
* @param code 사용자 입력 인증번호
* @return 인증 결과 {@link VerifyCodeResult}
*/
public VerifyCodeResult verifyCode(String phoneNumber, String code) {
log.info("[AuthService] verifyCode starts");
String key = SMS_PREFIX + phoneNumber;

// 1. Redis에서 해당 휴대폰번호로 저장된 인증번호 조회
String saved = redisTemplate.opsForValue().get(key);
log.info("saved = {}", saved);

// 2. 저장된 인증번호가 없는 경우 (만료로 판단)
if (saved == null) {
return VerifyCodeResult.EXPIRED;
}

// 3. Redis에서 조회한 인증번호와 사용자가 입력한 인증번호가 일치하지 않는 경우
if(!saved.equals(code)) {
return VerifyCodeResult.MISMATCH;
}
Expand All @@ -91,13 +107,22 @@ public static String generateCode() {
return String.format("%06d", number);
}

/**
* 기존 인증번호를 삭제하고 새로운 인증번호 SMS를 재전송
* @param phoneNumber 인증번호를 받을 전화번호
*/
public void resendSms(String phoneNumber) {

redisTemplate.delete(SMS_PREFIX + phoneNumber);

sendSms(phoneNumber);
}

/**
* Access Token과 Refresh Token을 발급하고, Refresh Token을 Redis에 저장 후 token이 담긴 DTO를 반환
* @param user 토큰을 발급받을 유저
* @return 발급된 토큰 정보가 담긴 DTO
*/
public TokenResponseDto issueAndReturnTokens(User user) {
String accessToken = tokenProvider.generateAccessToken(user);
String refreshToken = tokenProvider.generateRefreshToken(user);
Expand All @@ -111,17 +136,30 @@ public TokenResponseDto issueAndReturnTokens(User user) {
.build();
}

/**
* Refresh Token을 사용하여 Access Token과 Refresh Token 갱신
* @param user 토큰을 갱신할 유저
* @param oldRefreshToken 갱신 요청 시 사용될 기존 Refresh Token
* @return 새롭게 발급된 토큰 정보가 담긴 DTO
*/
public TokenResponseDto renew(User user, String oldRefreshToken) {
String key = REFRESH_TOKEN_PREFIX + user.getId();
String storedRefreshToken = redisTemplate.opsForValue().get(key);

// 1. 저장된 토큰이 없거나, 요청된 토큰과 저장된 토큰이 일치하지 않거나, 토큰 자체의 유효성 검증에 실패한 경우
if(storedRefreshToken == null || !storedRefreshToken.equals(oldRefreshToken) || !tokenProvider.validToken(oldRefreshToken)) {
throw new RuntimeException("INVALID REFRESH TOKEN");
}

// 2. 유효한 경우, 새로운 토큰 발급 및 저장
return issueAndReturnTokens(user);
}

/**
* Refresh Token을 Redis에 저장
* @param user
* @param refreshToken 저장할 Refresh Token
*/
public void saveRefreshToken(User user, String refreshToken) {
String key = REFRESH_TOKEN_PREFIX + user.getId();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public List<MessageResponseDto> getMessages(@PathVariable Integer roomId) {


/**
* WebSocket/STOMP 용 Controller
* WebSocket/STOMP 용 Controller
* - @RestController 대신 @Controller 사용
* - /pub/chat/message 로 발행된 STOMP 메시지를 수신
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ public class ChatRoom {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

// 상품
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;

// 판매자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "seller_id", nullable = false)
private User seller;

// 구매 희망자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "buyer_id", nullable = false)
private User buyer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class QuickReply {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

// 유저 아이디
@Column(name = "user_id", nullable = false)
private Integer userId;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Optional<Appointment> findByChatRoom_IdAndStatusIn(Integer chatRoomId,

Optional<Appointment> findByChatRoom_Id(Integer chatRoomId);

// 나의 약속(채팅방 참여자이거나 제안자=나). 상태 필터 optional
// 나의 약속(채팅방 참여자이거나 제안자=나). 상태 필터 optional
@Query("""
SELECT a
FROM Appointment a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// 채팅방 내에서 이뤄지는 기능
// 채팅방 조회 및 열람에서 본인 인증을 통해 채팅방 참여자 여부를 가려놨기에
// 여기서는 따로 채팅 참여자 여부를 가리지 않음. (즉 약속 CRUD 권한 여부 가리지 않음. 아미 조회 및 열람했으면 약속에 대한 권한도 당연히 부여)
@Service
@RequiredArgsConstructor
public class AppointmentService {
Expand All @@ -41,6 +44,7 @@ private AppointmentResponseDto toDto(Appointment appointment) {
.build();
}

// 약속 생성
@Transactional
public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){
ChatRoom room = chatRoomRepository.findById(roomId)
Expand All @@ -49,7 +53,7 @@ public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){
User proposer = userRepository.findById(dto.getProposerId())
.orElseThrow(() -> new EntityNotFoundException("AppointmentService.create(): 유저 없음"));

// 중복 약속 방지: 해당 채팅방에 PENDING/ACCEPTED 상태 약속이 있으면 생성 불가
// 중복 약속 방지: 해당 채팅방에 PENDING/ACCEPTED 상태 약속이 있으면 생성 불가
appointmentRepository.findByChatRoom_IdAndStatusIn(
roomId, java.util.List.of(AppointmentStatus.PENDING, AppointmentStatus.ACCEPTED)
).ifPresent(a -> {
Expand All @@ -66,7 +70,7 @@ public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){

Appointment saved = appointmentRepository.save(appointment);

// 시스템 메시지
// 시스템 메시지 생성 후 전송
String content = String.format("%s님이 %s %s에 만나자고 약속을 제안했습니다.",
proposer.getNickname(),
dto.getMeetingTime().toLocalDate(),
Expand All @@ -76,6 +80,7 @@ public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){
return toDto(saved);
}

// 약속 수락
@Transactional
public AppointmentResponseDto acceptAppointment(Integer appointmentId) {
Appointment appointment = appointmentRepository.findById(appointmentId)
Expand All @@ -98,27 +103,29 @@ public AppointmentResponseDto acceptAppointment(Integer appointmentId) {

productService.changeStatus(dto);

// 시스템 메시지
// 시스템 메시지 생성 후 전송
String content = "약속이 수락되었습니다. 상품 상태가 예약중으로 변경됩니다.";
chatMessageService.sendSystemMessage(room, content);

return toDto(appointment);
}

// 약속 거절
@Transactional
public AppointmentResponseDto rejectAppointment(Integer appointmentId) {
Appointment appointment = appointmentRepository.findById(appointmentId)
.orElseThrow(() -> new EntityNotFoundException("약속을 찾을 수 없습니다."));

appointment.setStatus(AppointmentStatus.REJECTED);

// 시스템 메시지
// 시스템 메시지 생성 후 전송
String content = "약속이 거절되었습니다.";
chatMessageService.sendSystemMessage(appointment.getChatRoom(), content);

return toDto(appointment);
}

// 약속 취소
@Transactional
public AppointmentResponseDto cancelAppointment(Integer appointmentId) {
Appointment appointment = appointmentRepository.findById(appointmentId)
Expand All @@ -129,7 +136,7 @@ public AppointmentResponseDto cancelAppointment(Integer appointmentId) {
ChatRoom room = appointment.getChatRoom();
Product product = room.getProduct();

// changeStatus 호출 (Transaction까지 정리)
// changeStatus 호출 (Transaction까지 정리)
ChangeStatusRequestDto dto = ChangeStatusRequestDto.builder()
.productId(product.getId())
.sellerId(room.getSeller().getId())
Expand All @@ -140,13 +147,14 @@ public AppointmentResponseDto cancelAppointment(Integer appointmentId) {

productService.changeStatus(dto);

// 시스템 메시지
// 시스템 메시지 생성 후 전송
String content = "약속이 취소되었습니다. 상품 상태가 판매중으로 돌아갑니다.";
chatMessageService.sendSystemMessage(room, content);

return toDto(appointment);
}

// 약속 조회
@Transactional(readOnly = true)
public AppointmentResponseDto getAppointmentByChatRoomId(Integer roomId) {
Appointment appointment = appointmentRepository.findByChatRoom_Id(roomId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ public class ChatMessageReadService {
private final ChatRoomRepository chatRoomRepository;
private final ChatMessageRepository chatMessageRepository;

// 읽음 처리
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markAsRead(Integer roomId, Integer messageId, Integer userId) {
// 권한/검증
/* 권한 확인
1. 다른 방 메시지 읽음 처리 불가
2. 해당 채팅방 참여자 여부 확인
*/
ChatRoom room = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new IllegalArgumentException("채팅방 없음"));

Expand Down
Loading
Loading