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
@@ -0,0 +1,32 @@
package com.example.RealMatch.business.application.service;

import java.util.UUID;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.RealMatch.business.domain.entity.CampaignProposal;
import com.example.RealMatch.business.domain.repository.CampaignProposalRepository;
import com.example.RealMatch.business.exception.BusinessErrorCode;
import com.example.RealMatch.business.presentation.dto.response.CampaignProposalDetailResponse;
import com.example.RealMatch.global.exception.CustomException;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CampaignProposalQueryService {

private final CampaignProposalRepository campaignProposalRepository;

public CampaignProposalDetailResponse getProposalDetail(
Long userId,
UUID proposalId
) {
CampaignProposal proposal = campaignProposalRepository.findByIdWithTags(proposalId)
.orElseThrow(() -> new CustomException(BusinessErrorCode.CAMPAIGN_PROPOSAL_NOT_FOUND));

return CampaignProposalDetailResponse.from(proposal);
Comment on lines +27 to +30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

userId 파라미터가 사용되지 않고 있어, 인증된 사용자가 다른 사용자의 제안 정보를 조회할 수 있는 보안 취약점이 있습니다. 제안을 조회하기 전에 현재 사용자가 해당 제안(creator 또는 brand 소속)에 접근할 권한이 있는지 확인하는 로직을 추가해야 합니다. BusinessErrorCode에 제안한 PROPOSAL_ACCESS_FORBIDDEN 에러 코드를 사용하여 권한이 없는 경우 예외를 발생시키는 것을 권장합니다.

        CampaignProposal proposal = campaignProposalRepository.findByIdWithTags(proposalId)
                .orElseThrow(() -> new CustomException(BusinessErrorCode.CAMPAIGN_PROPOSAL_NOT_FOUND));

        Long creatorId = proposal.getCreator().getId();
        Long brandUserId = (proposal.getBrand().getUser() != null) ? proposal.getBrand().getUser().getId() : null;

        if (!userId.equals(creatorId) && !userId.equals(brandUserId)) {
            throw new CustomException(BusinessErrorCode.PROPOSAL_ACCESS_FORBIDDEN);
        }

        return CampaignProposalDetailResponse.from(proposal);

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.RealMatch.business.domain.repository;

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

import org.springframework.data.jpa.repository.JpaRepository;
Expand Down Expand Up @@ -49,4 +50,13 @@ List<UUID> findReceivedProposalIds(
@Param("status") ProposalStatus status
);

@Query("""
select distinct cp
from CampaignProposal cp
left join fetch cp.tags t
left join fetch t.tagContent
where cp.id = :proposalId
""")
Optional<CampaignProposal> findByIdWithTags(UUID proposalId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
public enum BusinessErrorCode implements BaseErrorCode {

// ===== 조회 =====
CAMPAIGN_APPLY_ALREADY_APPLIED(HttpStatus.BAD_REQUEST, "CAMPAIGN_400_1", "이미 지원한 캠페인입니다.");
CAMPAIGN_APPLY_ALREADY_APPLIED(HttpStatus.BAD_REQUEST, "CAMPAIGN_400_1", "이미 지원한 캠페인입니다."),

CAMPAIGN_PROPOSAL_NOT_FOUND(HttpStatus.NOT_FOUND, "CAMPAIGN_404_1", "캠페인 지원 내역이 없습니다.");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

제안 상세 조회 시 권한이 없는 사용자의 접근을 막기 위한 에러 코드를 추가하는 것이 좋습니다. CampaignProposalQueryService에서 권한 검사를 추가할 때 이 에러 코드를 사용할 수 있습니다.

Suggested change
CAMPAIGN_PROPOSAL_NOT_FOUND(HttpStatus.NOT_FOUND, "CAMPAIGN_404_1", "캠페인 지원 내역이 없습니다.");
CAMPAIGN_PROPOSAL_NOT_FOUND(HttpStatus.NOT_FOUND, "CAMPAIGN_404_1", "캠페인 지원 내역이 없습니다."),
PROPOSAL_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, "PROPOSAL_403_1", "해당 제안에 접근할 권한이 없습니다.");


private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package com.example.RealMatch.business.presentation.controller;

import java.util.UUID;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.RealMatch.business.application.service.CampaignProposalQueryService;
import com.example.RealMatch.business.application.service.CampaignProposalService;
import com.example.RealMatch.business.presentation.docs.CampaignProposalSwagger;
import com.example.RealMatch.business.presentation.dto.request.CampaignProposalRequestDto;
import com.example.RealMatch.business.presentation.dto.response.CampaignProposalDetailResponse;
import com.example.RealMatch.global.config.jwt.CustomUserDetails;
import com.example.RealMatch.global.presentation.CustomResponse;
import com.example.RealMatch.user.domain.entity.enums.Role;
Expand All @@ -20,10 +26,11 @@

@Tag(name = "Business", description = "비즈니스 API")
@RestController
@RequestMapping("/api/v1/campaigns")
@RequestMapping("/api/v1/campaigns/proposal")
@RequiredArgsConstructor
public class CampaignProposalController implements CampaignProposalSwagger {
private final CampaignProposalService campaignProposalService;
private final CampaignProposalQueryService campaignProposalQueryService;

@Override
@PostMapping("/request")
Expand All @@ -49,5 +56,30 @@ public CustomResponse<String> requestCampaignProposal(
campaignProposalService.requestCampaign(1L, Role.from(userDetails.getRole()), request);
return CustomResponse.ok("캠페인 제안에 성공했습니다.");
}

@GetMapping("/{campaignProposalId}")
@Operation(
summary = "캠페인 제안 상세 조회 API",
description = """
캠페인 제안 단건의 상세 정보를 조회합니다.

제안에 포함된 콘텐츠 태그 정보와
제안 상태(REVIEWING / MATCHED / REJECTED)를 함께 반환합니다.
"""
)
public CustomResponse<CampaignProposalDetailResponse> getProposalDetail(
@AuthenticationPrincipal CustomUserDetails principal,
@PathVariable UUID campaignProposalId
) {
CampaignProposalDetailResponse response =
campaignProposalQueryService.getProposalDetail(
principal.getUserId(),
campaignProposalId
);

return CustomResponse.ok(response);
}


}

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import java.util.UUID;

import com.example.RealMatch.business.domain.entity.CampaignProposal;
import com.example.RealMatch.campaign.domain.entity.CampaignContentTag;
import com.example.RealMatch.business.domain.entity.CampaignProposalContentTag;
import com.example.RealMatch.tag.domain.enums.ContentTagType;
import com.example.RealMatch.tag.presentation.dto.response.ContentTagResponse;

Expand Down Expand Up @@ -41,10 +41,7 @@ public class CampaignProposalDetailResponse {

private ContentTagResponse contentTags;

public static CampaignProposalDetailResponse from(
CampaignProposal proposal,
List<CampaignContentTag> tags
) {
public static CampaignProposalDetailResponse from(CampaignProposal proposal) {
return CampaignProposalDetailResponse.builder()
.proposalId(proposal.getId())
.brandId(proposal.getBrand().getId())
Expand All @@ -58,17 +55,17 @@ public static CampaignProposalDetailResponse from(
.status(proposal.getStatus().name())
.refusalReason(proposal.getRefusalReason())
.createdAt(proposal.getCreatedAt())
.contentTags(toContentTagResponse(tags))
.contentTags(toContentTagResponse(proposal.getTags()))
.build();
}

private static ContentTagResponse toContentTagResponse(
List<CampaignContentTag> tags
List<CampaignProposalContentTag> tags
) {
Map<ContentTagType, List<ContentTagResponse.TagItemResponse>> map =
new EnumMap<>(ContentTagType.class);

for (CampaignContentTag tag : tags) {
for (CampaignProposalContentTag tag : tags) {
ContentTagType type = tag.getTagContent().getTagType();

map.computeIfAbsent(type, k -> new ArrayList<>())
Expand All @@ -85,19 +82,16 @@ private static ContentTagResponse toContentTagResponse(
}

private static ContentTagResponse.TagItemResponse toTagItem(
CampaignContentTag tag
CampaignProposalContentTag tag
) {
String baseName = tag.getTagContent().getKorName();
String name = tag.getCustomTagValue() != null && !tag.getCustomTagValue().isBlank()
? baseName + " (" + tag.getCustomTagValue() + ")"
: baseName;

return new ContentTagResponse.TagItemResponse(
tag.getTagContent().getId(),
resolveTagName(tag)
name
);
}

private static String resolveTagName(CampaignContentTag tag) {
String baseName = tag.getTagContent().getKorName();
if (tag.getCustomTagValue() != null && !tag.getCustomTagValue().isBlank()) {
return baseName + " (" + tag.getCustomTagValue() + ")";
}
return baseName;
}
}
Loading