diff --git a/src/main/java/com/blockcloud/controller/ProjectShareTokenController.java b/src/main/java/com/blockcloud/controller/ProjectShareTokenController.java new file mode 100644 index 0000000..8eb96f5 --- /dev/null +++ b/src/main/java/com/blockcloud/controller/ProjectShareTokenController.java @@ -0,0 +1,49 @@ +package com.blockcloud.controller; + +import com.blockcloud.dto.ResponseDto.ProjectShareTokenResponseDto; +import com.blockcloud.dto.common.ResponseDto; +import com.blockcloud.dto.oauth.CustomUserDetails; +import com.blockcloud.service.ProjectShareTokenService; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * 프로젝트 공유 토큰 관리 API 컨트롤러 + */ +@Tag(name = "Project Share Token API", description = "프로젝트 공유 토큰 생성, 조회, 재생성, 비활성화 관련 API") +@RestController +@RequestMapping("/api/projects") +@RequiredArgsConstructor +public class ProjectShareTokenController { + + private final ProjectShareTokenService projectShareTokenService; + + /** + * 프로젝트의 공유 토큰을 생성하거나 조회합니다. + * + * @param projectId 프로젝트 ID + * @param authentication 인증 정보 + * @return 공유 토큰 정보 + */ + @Operation( + summary = "공유 토큰 생성/조회", + description = "프로젝트의 공유 토큰을 생성하거나 기존 토큰을 조회합니다. JWT 인증 필요." + ) + @GetMapping("/{projectId}/share-token") + public ResponseDto getShareToken( + @Parameter(description = "프로젝트 ID", required = true) + @PathVariable Long projectId, + Authentication authentication) { + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return ResponseDto.ok( + projectShareTokenService.getOrCreateShareToken(projectId, userDetails.getUsername())); + } +} \ No newline at end of file diff --git a/src/main/java/com/blockcloud/controller/ProjectViewController.java b/src/main/java/com/blockcloud/controller/ProjectViewController.java new file mode 100644 index 0000000..1419092 --- /dev/null +++ b/src/main/java/com/blockcloud/controller/ProjectViewController.java @@ -0,0 +1,46 @@ +package com.blockcloud.controller; + +import com.blockcloud.dto.ResponseDto.ProjectViewResponseDto; +import com.blockcloud.dto.common.ResponseDto; +import com.blockcloud.service.ProjectViewService; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 프로젝트 공개 조회 API 컨트롤러 URL 공유를 위한 인증 없는 프로젝트 조회 기능을 제공합니다. + */ +@Tag(name = "Project View API", description = "프로젝트 공개 조회 및 URL 공유 관련 API") +@RestController +@RequestMapping("/api/view") +@RequiredArgsConstructor +public class ProjectViewController { + + private final ProjectViewService projectViewService; + + /** + * 프로젝트 ID와 공유 토큰으로 프로젝트 정보와 블록 아키텍처를 공개 조회합니다. + * + * @param projectId 조회할 프로젝트 ID + * @param token 공유 토큰 + * @return 프로젝트 정보와 블록 아키텍처가 담긴 응답 객체 + */ + @Operation( + summary = "프로젝트 공개 조회", + description = "프로젝트 ID와 공유 토큰으로 프로젝트 정보와 블록 아키텍처를 조회합니다. 유효한 공유 토큰이 필요합니다." + ) + @GetMapping("/{projectId}") + public ResponseDto viewProject( + @Parameter(description = "조회할 프로젝트 ID", required = true) + @PathVariable Long projectId, + @Parameter(description = "공유 토큰", required = true) + @RequestParam String token) { + return ResponseDto.ok(projectViewService.getProjectView(projectId, token)); + } +} \ No newline at end of file diff --git a/src/main/java/com/blockcloud/domain/project/Project.java b/src/main/java/com/blockcloud/domain/project/Project.java index b41d9d1..73001e5 100644 --- a/src/main/java/com/blockcloud/domain/project/Project.java +++ b/src/main/java/com/blockcloud/domain/project/Project.java @@ -30,8 +30,13 @@ public class Project extends BaseTimeEntity { @OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore + @Builder.Default private List members = new ArrayList<>(); + @OneToOne(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore + private ProjectShareToken shareToken; + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List deployments = new ArrayList<>(); diff --git a/src/main/java/com/blockcloud/domain/project/ProjectShareToken.java b/src/main/java/com/blockcloud/domain/project/ProjectShareToken.java new file mode 100644 index 0000000..3a87aa1 --- /dev/null +++ b/src/main/java/com/blockcloud/domain/project/ProjectShareToken.java @@ -0,0 +1,77 @@ +package com.blockcloud.domain.project; + +import com.blockcloud.domain.global.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 프로젝트 공유 토큰 엔티티 프로젝트를 외부에 공유할 때 사용하는 토큰을 관리합니다. + */ +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "project_share_tokens") +public class ProjectShareToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 255) + private String token; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "is_active", nullable = false) + @Builder.Default + private Boolean isActive = true; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + /** + * 프로젝트에 대한 공유 토큰을 생성합니다. + * + * @param project 공유할 프로젝트 + * @return 생성된 공유 토큰 + */ + public static ProjectShareToken createForProject(Project project) { + String token = generateSecureToken(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); // 30일 후 만료 + + return ProjectShareToken.builder() + .token(token) + .expiresAt(expiresAt) + .isActive(true) + .project(project) + .build(); + } + + + /** + * 토큰이 유효한지 확인합니다. + * + * @return 토큰이 활성화되어 있고 만료되지 않았으면 true + */ + public boolean isValid() { + return this.isActive && + this.expiresAt != null && + this.expiresAt.isAfter(LocalDateTime.now()); + } + + /** + * 안전한 토큰을 생성합니다. + * + * @return 생성된 토큰 문자열 + */ + private static String generateSecureToken() { + return UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/src/main/java/com/blockcloud/domain/project/ProjectShareTokenRepository.java b/src/main/java/com/blockcloud/domain/project/ProjectShareTokenRepository.java new file mode 100644 index 0000000..0cf2d9a --- /dev/null +++ b/src/main/java/com/blockcloud/domain/project/ProjectShareTokenRepository.java @@ -0,0 +1,30 @@ +package com.blockcloud.domain.project; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * 프로젝트 공유 토큰 리포지토리 + */ +public interface ProjectShareTokenRepository extends JpaRepository { + + /** + * 프로젝트 ID로 공유 토큰을 조회합니다. + */ + @Query("SELECT pst FROM ProjectShareToken pst WHERE pst.project.id = :projectId") + Optional findByProjectId(@Param("projectId") Long projectId); + + /** + * 토큰 문자열로 공유 토큰을 조회합니다. + */ + Optional findByToken(String token); + + /** + * 프로젝트 ID와 토큰으로 유효한 공유 토큰을 조회합니다. + */ + @Query("SELECT pst FROM ProjectShareToken pst WHERE pst.project.id = :projectId AND pst.token = :token") + Optional findValidTokenByProjectIdAndToken(@Param("projectId") Long projectId, @Param("token") String token); +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/ProjectShareTokenResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/ProjectShareTokenResponseDto.java new file mode 100644 index 0000000..cfcb0ee --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/ProjectShareTokenResponseDto.java @@ -0,0 +1,19 @@ +package com.blockcloud.dto.ResponseDto; + +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +/** + * 프로젝트 공유 토큰 응답 DTO + */ +@Getter +@Builder +public class ProjectShareTokenResponseDto { + + private Long projectId; + private String token; + private String shareUrl; + private LocalDateTime createdAt; + private LocalDateTime expiresAt; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/ProjectViewResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/ProjectViewResponseDto.java new file mode 100644 index 0000000..f6acbfd --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/ProjectViewResponseDto.java @@ -0,0 +1,22 @@ +package com.blockcloud.dto.ResponseDto; + +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +/** + * 프로젝트 공개 조회용 응답 DTO + * URL 공유를 위한 프로젝트 정보와 블록 아키텍처 정보를 포함합니다. + */ +@Getter +@Builder +public class ProjectViewResponseDto { + + private Long id; + private String name; + private String description; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Object blocks; // 블록 아키텍처 정보 (JSON 형태) +} + diff --git a/src/main/java/com/blockcloud/service/ProjectShareTokenService.java b/src/main/java/com/blockcloud/service/ProjectShareTokenService.java new file mode 100644 index 0000000..5af3fbe --- /dev/null +++ b/src/main/java/com/blockcloud/service/ProjectShareTokenService.java @@ -0,0 +1,97 @@ +package com.blockcloud.service; + +import com.blockcloud.domain.project.Project; +import com.blockcloud.domain.project.ProjectRepository; +import com.blockcloud.domain.project.ProjectShareToken; +import com.blockcloud.domain.project.ProjectShareTokenRepository; +import com.blockcloud.dto.ResponseDto.ProjectShareTokenResponseDto; +import com.blockcloud.exception.CommonException; +import com.blockcloud.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 프로젝트 공유 토큰 관리 서비스 + */ +@Service +@RequiredArgsConstructor +public class ProjectShareTokenService { + + private final ProjectRepository projectRepository; + private final ProjectShareTokenRepository projectShareTokenRepository; + + @Value("${app.base-url}") + private String baseUrl; + + @Value("${app.share-url-path}") + private String shareUrlPath; + + /** + * 프로젝트의 공유 토큰을 생성하거나 조회합니다. + * + * @param projectId 프로젝트 ID + * @param email 요청한 사용자의 이메일 + * @return 공유 토큰 정보 + */ + @Transactional + public ProjectShareTokenResponseDto getOrCreateShareToken(Long projectId, String email) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + validateProjectMember(project, email); + + ProjectShareToken shareToken = projectShareTokenRepository.findByProjectId(projectId) + .orElseGet(() -> { + ProjectShareToken newToken = ProjectShareToken.createForProject(project); + return projectShareTokenRepository.save(newToken); + }); + + return toResponseDto(shareToken); + } + + + /** + * 사용자가 해당 프로젝트의 멤버인지 검증하는 메서드 + */ + private void validateProjectMember(Project project, String email) { + boolean isMember = project.getMembers().stream() + .anyMatch(member -> member.getUser().getEmail().equals(email)); + if (!isMember) { + throw new CommonException(ErrorCode.ACCESS_DENIED); + } + } + + /** + * ProjectShareToken 엔티티를 ResponseDto로 변환 + */ + private ProjectShareTokenResponseDto toResponseDto(ProjectShareToken shareToken) { + String shareUrl = buildShareUrl(shareToken.getProject().getId(), shareToken.getToken()); + + return ProjectShareTokenResponseDto.builder() + .projectId(shareToken.getProject().getId()) + .token(shareToken.getToken()) + .shareUrl(shareUrl) + .createdAt(shareToken.getCreatedAt()) + .expiresAt(shareToken.getExpiresAt()) + .build(); + } + + /** + * 공유 URL을 생성합니다. + * + * @param projectId 프로젝트 ID + * @param token 공유 토큰 + * @return 생성된 공유 URL + */ + private String buildShareUrl(Long projectId, String token) { + // baseUrl 끝에 슬래시가 있으면 제거 + String cleanBaseUrl = + baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + // shareUrlPath 시작에 슬래시가 없으면 추가 + String cleanSharePath = shareUrlPath.startsWith("/") ? shareUrlPath : "/" + shareUrlPath; + + return String.format("%s%s/%d?token=%s", cleanBaseUrl, cleanSharePath, projectId, token); + } +} \ No newline at end of file diff --git a/src/main/java/com/blockcloud/service/ProjectViewService.java b/src/main/java/com/blockcloud/service/ProjectViewService.java new file mode 100644 index 0000000..fade570 --- /dev/null +++ b/src/main/java/com/blockcloud/service/ProjectViewService.java @@ -0,0 +1,61 @@ +package com.blockcloud.service; + +import com.blockcloud.domain.project.Project; +import com.blockcloud.domain.project.ProjectShareToken; +import com.blockcloud.domain.project.ProjectShareTokenRepository; +import com.blockcloud.dto.ResponseDto.ProjectViewResponseDto; +import com.blockcloud.exception.CommonException; +import com.blockcloud.exception.error.ErrorCode; +import com.nimbusds.jose.shaded.gson.Gson; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 프로젝트 공개 조회를 위한 서비스 클래스 + * URL 공유를 위한 인증 없는 프로젝트 조회 기능을 제공합니다. + */ +@Service +@RequiredArgsConstructor +public class ProjectViewService { + + private final ProjectShareTokenRepository projectShareTokenRepository; + + /** + * 프로젝트 ID와 공유 토큰으로 프로젝트 정보와 블록 아키텍처를 조회합니다. + * + * @param projectId 조회할 프로젝트 ID + * @param shareToken 공유 토큰 + * @return 프로젝트 정보와 블록 아키텍처가 담긴 응답 DTO + * @throws CommonException 해당 프로젝트를 찾을 수 없는 경우 또는 유효하지 않은 토큰인 경우 + */ + @Transactional(readOnly = true) + public ProjectViewResponseDto getProjectView(Long projectId, String shareToken) { + // 프로젝트와 토큰 유효성 검증 + ProjectShareToken token = projectShareTokenRepository + .findValidTokenByProjectIdAndToken(projectId, shareToken) + .orElseThrow(() -> new CommonException(ErrorCode.ACCESS_DENIED)); + + // 토큰 만료 및 활성화 상태 검증 + if (!token.isValid()) { + throw new CommonException(ErrorCode.ACCESS_DENIED); + } + + Project project = token.getProject(); + + // 블록 정보를 JSON에서 Object로 변환 + Object blocks = null; + if (project.getBlockInfo() != null && !project.getBlockInfo().isEmpty()) { + blocks = new Gson().fromJson(project.getBlockInfo(), Object.class); + } + + return ProjectViewResponseDto.builder() + .id(project.getId()) + .name(project.getName()) + .description(project.getDescription()) + .createdAt(project.getCreatedAt()) + .updatedAt(project.getUpdatedAt()) + .blocks(blocks) + .build(); + } +} \ No newline at end of file