Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) {

/**
* Authorization 헤더가 존재하고 Bearer 포맷인지 확인한다.
*
*
* @throws CustomAuthException 헤더 누락/형식 오류
*/
private static void requireBearerAuthorizationHeader(String authorizationHeader) {
Expand Down Expand Up @@ -171,4 +171,4 @@ protected void doFilterInternal(
throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.assu.server.domain.common.dto;

import org.springframework.data.domain.Page;
import java.util.List;

public record PageResponseDTO<T>(
List<T> items,
int page,
int size,
int totalPages,
long totalElements
) {
public static <T> PageResponseDTO<T> of(Page<T> page) {
return new PageResponseDTO<>(
page.getContent(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.getTotalElements()
);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class DeviceTokenController {
@Operation(
summary = "Device Token 등록 API",
description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed8092864ac5a1ddc88d07?source=copy_link)\n" +
"- device Token을 등록하고 등록된 Token의 ID를 반환합니다.\n" +
"- Device Token을 등록하고 등록된 Token의 ID를 반환합니다.\n" +
" - 'token': Request Param, String\n"
)
@PostMapping
Expand All @@ -34,7 +34,7 @@ public BaseResponse<Long> register(@AuthenticationPrincipal PrincipalDetails pd,
summary = "Device Token 등록 해제 API",
description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed80b8b26be9e01d24c929?source=copy_link)\n" +
"- 로그아웃/탈퇴 시 호출해 device Token 등록을 해제합니다. 자신의 토큰만 해제가 가능합니다.\n"+
" - 'token-id': Path Variavle, Long\n"
" - 'token-id': Path Variable, Long\n"
)
@DeleteMapping("/{token-id}")
public BaseResponse<String> unregister(@AuthenticationPrincipal PrincipalDetails pd,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
@NoArgsConstructor
@AllArgsConstructor
public class DeviceToken extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,11 @@
import java.util.Optional;

public interface DeviceTokenRepository extends JpaRepository<DeviceToken, Long> {
@Query("select dt.token from DeviceToken dt where dt.member.id =:memberId and dt.active=true")
List<String> findActiveTokensByMemberId(@Param("memberId") Long memberId);

@Transactional
@Modifying
@Query("update DeviceToken dt set dt.active = false where dt.token in :tokens")
void deactivateTokens(@Param("tokens") List<String> tokens);
List<DeviceToken> findAllByMemberIdAndActiveTrue(Long memberId);

Optional<DeviceToken> findByToken(String token);
List<DeviceToken> findAllByTokenIn(List<String> tokens);

// 같은 회원 + 같은 토큰 있는지 확인
Optional<DeviceToken> findByMemberIdAndToken(Long memberId, String token);

// 같은 회원이 가진 모든 토큰 (비활성화용)
List<DeviceToken> findAllByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.assu.server.domain.deviceToken.service;

import java.util.List;

public interface DeviceTokenService {
Long register(String tokenId, Long memberId);
Long register(String token, Long memberId);
void unregister(Long tokenId, Long memberId);
void deactivateTokens(List<String> invalidTokens);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class DeviceTokenServiceImpl implements DeviceTokenService {
Expand All @@ -20,41 +22,35 @@ public class DeviceTokenServiceImpl implements DeviceTokenService {

@Transactional
@Override
public Long register(String tokenId, Long memberId) {
public Long register(String token, Long memberId) {
Member member = memberRepository.findMemberById(memberId)
.orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER));

// 1) 같은 회원 + 같은 토큰이 이미 있으면 → active = true 로만 복구
// (가장 정확한 쿼리: findByMemberIdAndToken)
var sameTokenOpt = deviceTokenRepository.findByMemberIdAndToken(memberId, tokenId);
// 1) 같은 회원 + 같은 토큰 → 활성화만 복구
var sameTokenOpt = deviceTokenRepository.findByMemberIdAndToken(memberId, token);

if (sameTokenOpt.isPresent()) {
DeviceToken exist = sameTokenOpt.get();
exist.setActive(true);
deviceTokenRepository.save(exist);
return exist.getId();
}

// 2) 같은 회원 + 다른 토큰 → 그 회원의 기존 active 토큰 전부 비활성화
// (현재 보유 메서드 활용: 활성 토큰 문자열 가져와 deactivate)
var activeTokens = deviceTokenRepository.findActiveTokensByMemberId(memberId);
if (!activeTokens.isEmpty()) {
// 현재 등록하려는 tokenId 와 다른 것들만 비활성화
var toDeactivate = activeTokens.stream()
.filter(t -> !t.equals(tokenId))
.toList();
if (!toDeactivate.isEmpty()) {
deviceTokenRepository.deactivateTokens(toDeactivate);
}
// 2) 같은 회원 + 다른 토큰 → 기존 활성 토큰 비활성화
List<DeviceToken> activeTokens =
deviceTokenRepository.findAllByMemberIdAndActiveTrue(memberId);

for (DeviceToken deviceToken : activeTokens) {
deviceToken.setActive(false);
}

// 3) 토큰 insert (다른 회원이 같은 토큰을 갖고 있어도 상관 없이 insert)
// 3) 신규 토큰 저장
DeviceToken newToken = DeviceToken.builder()
.member(member)
.token(tokenId)
.token(token)
.active(true)
.build();
deviceTokenRepository.save(newToken);

deviceTokenRepository.save(newToken);
return newToken.getId();
}

Expand All @@ -71,4 +67,11 @@ public void unregister(Long tokenId, Long memberId) {
throw new DatabaseException(ErrorStatus.DEVICE_TOKEN_NOT_FOUND);
});
}

@Transactional
@Override
public void deactivateTokens(List<String> invalidTokens) {
List<DeviceToken> invalidEntities = deviceTokenRepository.findAllByTokenIn(invalidTokens);
invalidEntities.forEach(dt -> dt.setActive(false));
}
}
Original file line number Diff line number Diff line change
@@ -1,70 +1,63 @@
package com.assu.server.domain.inquiry.controller;

import com.assu.server.domain.inquiry.dto.profileImage.ProfileImageResponse;
import com.assu.server.domain.common.dto.PageResponseDTO;
import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO;
import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO;
import com.assu.server.domain.inquiry.dto.InquiryResponseDTO;
import com.assu.server.domain.inquiry.entity.Inquiry;
import com.assu.server.domain.inquiry.service.InquiryService;
import com.assu.server.domain.inquiry.service.ProfileImageService;
import com.assu.server.global.apiPayload.BaseResponse;
import com.assu.server.global.apiPayload.code.status.SuccessStatus;

import com.assu.server.global.util.PrincipalDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

@Tag(name = "MyPage", description = "마이페이지 API")
@Tag(name = "Inquiry", description = "문의 API")
@RestController
@RequestMapping("/member")
@RequestMapping("/inquiries")
@RequiredArgsConstructor
public class InquiryController {

private final InquiryService inquiryService;
private final ProfileImageService profileImageService;

@Operation(
summary = "문의 생성 API",
description = "# [v1.0 (2025-09-02)](https://www.notion.so/2441197c19ed800688f0cfb304dead63?source=copy_link)\n" +
"- 문의를 생성하고 해당 문의의 id를 반환합니다.\n"+
" - InquiryCreateRequestDTO: title, content, email\n"
)
@PostMapping("/inquiries")
@PostMapping
public BaseResponse<Long> create(
@AuthenticationPrincipal PrincipalDetails pd,
@RequestBody @Valid InquiryCreateRequestDTO req
@RequestBody @Valid InquiryCreateRequestDTO inquiryCreateRequestDTO
) {
Long id = inquiryService.create(req, pd.getId());
Long id = inquiryService.create(inquiryCreateRequestDTO, pd.getId());
return BaseResponse.onSuccess(SuccessStatus._OK, id);
}

@Operation(
summary = "문의 목록을 조회하는 API",
description = "# [v1.0 (2025-09-02)](https://www.notion.so/2441197c19ed803eba4af9598484e5c5?source=copy_link)\n" +
"- 본인의 문의 목록을 상태별로 조회합니다.\n"+
" - status: Request Param, String, [all/waiting/answered]\n" +
" - status: Request Param, Enum, [WAITING, ANSWERED, ALL]\n" +
" - page: Request Param, Integer, 1 이상\n" +
" - size: Request Param, Integer, default = 20"
)
@GetMapping("/inquiries")
public BaseResponse<Map<String, Object>> list(
@GetMapping
public BaseResponse<PageResponseDTO<InquiryResponseDTO>> list(
@AuthenticationPrincipal PrincipalDetails pd,
@RequestParam(defaultValue = "all") String status, // all | waiting | answered
@RequestParam(defaultValue = "all") Inquiry.Status status,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size
) {
Map<String, Object> response = inquiryService.getInquiries(status, page, size, pd.getId());
return BaseResponse.onSuccess(SuccessStatus._OK, response);
PageResponseDTO<InquiryResponseDTO> inquiryResponseDTO = inquiryService.getInquiries(status, page, size, pd.getId());
return BaseResponse.onSuccess(SuccessStatus._OK, inquiryResponseDTO);
}

/** 단건 상세 조회 */
Expand All @@ -74,13 +67,13 @@ public BaseResponse<Map<String, Object>> list(
"- 본인의 단건 문의를 상세 조회합니다.\n"+
" - inquiry-id: Path Variable, Long\n"
)
@GetMapping("/inquiries/{inquiry-id}")
@GetMapping("/{inquiry-id}")
public BaseResponse<InquiryResponseDTO> get(
@AuthenticationPrincipal PrincipalDetails pd,
@PathVariable("inquiry-id") Long inquiryId
) {
InquiryResponseDTO response = inquiryService.get(inquiryId, pd.getMemberId());
return BaseResponse.onSuccess(SuccessStatus._OK, response);
@PathVariable("inquiry-id") Long inquiryId)
{
InquiryResponseDTO inquiryResponseDTO = inquiryService.get(inquiryId, pd.getId());
return BaseResponse.onSuccess(SuccessStatus._OK, inquiryResponseDTO);
}

/** 문의 답변 (운영자) */
Expand All @@ -90,51 +83,12 @@ public BaseResponse<InquiryResponseDTO> get(
"- 문의에 답변을 저장하고 상태를 ANSWERED로 변경합니다.\n"+
" - inquiry-id: Path Variable, Long\n"
)
@PatchMapping("/inquiries/{inquiry-id}/answer")
@PatchMapping("/{inquiry-id}/answer")
public BaseResponse<String> answer(
@PathVariable("inquiry-id") Long inquiryId,
@RequestBody @Valid InquiryAnswerRequestDTO req
@RequestBody @Valid InquiryAnswerRequestDTO inquiryAnswerRequestDTO
) {
inquiryService.answer(inquiryId, req.getAnswer());
inquiryService.answer(inquiryId, inquiryAnswerRequestDTO.answer());
return BaseResponse.onSuccess(SuccessStatus._OK, "The inquiry answered successfully. id=" + inquiryId);
}

@Operation(
summary = "프로필 사진 업로드/교체 API",
description = "# [v1.0 (2025-09-15)](https://clumsy-seeder-416.notion.site/26f1197c19ed8031bc50e3571e8ea18f?source=copy_link)\n" +
"- `multipart/form-data`로 프로필 이미지를 업로드합니다.\n" +
"- 기존 이미지가 있으면 S3에서 삭제 후 새 이미지로 교체합니다.\n" +
"- 성공 시 업로드된 이미지 key를 반환합니다."
)
@PutMapping(value = "/profile/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public BaseResponse<ProfileImageResponse> uploadOrReplaceProfileImage(
@AuthenticationPrincipal PrincipalDetails pd,
@RequestPart("image")
@Parameter(
description = "프로필 이미지 파일 (jpg/png/webp 등)",
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE,
schema = @Schema(type = "string", format = "binary")
)
)
MultipartFile image
) {
String key = profileImageService.updateProfileImage(pd.getMemberId(), image);
return BaseResponse.onSuccess(SuccessStatus._OK, new ProfileImageResponse(key));
}

@Operation(
summary = "프로필 이미지 조회 API",
description = "# [v1.0 (2025-09-18)](https://clumsy-seeder-416.notion.site/2711197c19ed8039bbe2c48380c9f4c8?source=copy_link)\n" +
"- 로그인한 사용자의 프로필 이미지 presigned URL을 반환합니다.\n" +
"- URL은 일정 시간 동안만 유효합니다."
)
@GetMapping("/profile/image")
public BaseResponse<ProfileImageResponse> getProfileImage(
@AuthenticationPrincipal PrincipalDetails pd
) {
String url = profileImageService.getProfileImageUrl(pd.getMemberId());
return BaseResponse.onSuccess(SuccessStatus._OK, new ProfileImageResponse(url));
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.assu.server.domain.inquiry.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;

@Getter
public class InquiryAnswerRequestDTO {
@NotBlank(message = "answer는 비어 있을 수 없습니다.")
private String answer;
}
public record InquiryAnswerRequestDTO (
@NotBlank(message = "answer는 비어 있을 수 없습니다.")
String answer
) {}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.assu.server.domain.inquiry.dto;
import lombok.*;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor @Builder
public class InquiryCreateRequestDTO {
private String title;
private String content;
private String email;
}

import jakarta.validation.constraints.NotBlank;

public record InquiryCreateRequestDTO (
@NotBlank(message = "title은 비어 있을 수 없습니다.")
String title,

@NotBlank(message = "content는 비어 있을 수 없습니다.")
String content,

@NotBlank(message = "email은 비어 있을 수 없습니다.")
String email
){ }
Loading