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,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<ProjectShareTokenResponseDto> getShareToken(
@Parameter(description = "프로젝트 ID", required = true)
@PathVariable Long projectId,
Authentication authentication) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
return ResponseDto.ok(
projectShareTokenService.getOrCreateShareToken(projectId, userDetails.getUsername()));
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/blockcloud/controller/ProjectViewController.java
Original file line number Diff line number Diff line change
@@ -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<ProjectViewResponseDto> viewProject(
@Parameter(description = "조회할 프로젝트 ID", required = true)
@PathVariable Long projectId,
@Parameter(description = "공유 토큰", required = true)
@RequestParam String token) {
return ResponseDto.ok(projectViewService.getProjectView(projectId, token));
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/blockcloud/domain/project/Project.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ public class Project extends BaseTimeEntity {

@OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@Builder.Default
private List<ProjectUser> 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<Deployment> deployments = new ArrayList<>();
Expand Down
77 changes: 77 additions & 0 deletions src/main/java/com/blockcloud/domain/project/ProjectShareToken.java
Original file line number Diff line number Diff line change
@@ -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("-", "");
}
}
Original file line number Diff line number Diff line change
@@ -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<ProjectShareToken, Long> {

/**
* 프로젝트 ID로 공유 토큰을 조회합니다.
*/
@Query("SELECT pst FROM ProjectShareToken pst WHERE pst.project.id = :projectId")
Optional<ProjectShareToken> findByProjectId(@Param("projectId") Long projectId);

/**
* 토큰 문자열로 공유 토큰을 조회합니다.
*/
Optional<ProjectShareToken> findByToken(String token);

/**
* 프로젝트 ID와 토큰으로 유효한 공유 토큰을 조회합니다.
*/
@Query("SELECT pst FROM ProjectShareToken pst WHERE pst.project.id = :projectId AND pst.token = :token")
Optional<ProjectShareToken> findValidTokenByProjectIdAndToken(@Param("projectId") Long projectId, @Param("token") String token);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 형태)
}

97 changes: 97 additions & 0 deletions src/main/java/com/blockcloud/service/ProjectShareTokenService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading