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
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,11 +34,11 @@ 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}")
@DeleteMapping("/{tokenId}")
public BaseResponse<String> unregister(@AuthenticationPrincipal PrincipalDetails pd,
@PathVariable("token-id") Long tokenId) {
@PathVariable("tokenId") Long tokenId) {
service.unregister(tokenId, pd.getId());
return BaseResponse.onSuccess(
SuccessStatus._OK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.assu.server.domain.common.entity.BaseEntity;
import com.assu.server.domain.member.entity.Member;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;

@Entity
Expand All @@ -11,17 +12,22 @@
@NoArgsConstructor
@AllArgsConstructor
public class DeviceToken extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="member_id", nullable=false)
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="member_id", nullable=false)
private Member member;

@NotNull
@Column(nullable=false, length=200)
private String token;

@Setter
@NotNull
@Column(nullable=false)
private boolean active;
}
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,53 +12,49 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@Transactional
@RequiredArgsConstructor
public class DeviceTokenServiceImpl implements DeviceTokenService {
private final DeviceTokenRepository deviceTokenRepository;
private final MemberRepository memberRepository;

@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) 같은 회원 + 같은 토큰 → 활성화만 복구
Optional<DeviceToken> 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();
}

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

@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,66 @@
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 jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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")
@Validated
@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 = "1") Integer page,
@RequestParam(defaultValue = "all") Inquiry.Status status,
@RequestParam(defaultValue = "1") @Min(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 +70,13 @@ public BaseResponse<Map<String, Object>> list(
"- 본인의 단건 문의를 상세 조회합니다.\n"+
" - inquiry-id: Path Variable, Long\n"
)
@GetMapping("/inquiries/{inquiry-id}")
@GetMapping("/{inquiryId}")
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("inquiryId") Long inquiryId)
{
InquiryResponseDTO inquiryResponseDTO = inquiryService.get(inquiryId, pd.getId());
return BaseResponse.onSuccess(SuccessStatus._OK, inquiryResponseDTO);
}

/** 문의 답변 (운영자) */
Expand All @@ -90,51 +86,12 @@ public BaseResponse<InquiryResponseDTO> get(
"- 문의에 답변을 저장하고 상태를 ANSWERED로 변경합니다.\n"+
" - inquiry-id: Path Variable, Long\n"
)
@PatchMapping("/inquiries/{inquiry-id}/answer")
@PatchMapping("/{inquiryId}/answer")
public BaseResponse<String> answer(
@PathVariable("inquiry-id") Long inquiryId,
@RequestBody @Valid InquiryAnswerRequestDTO req
@PathVariable("inquiryId") Long inquiryId,
@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.

Loading