Skip to content

Commit d632448

Browse files
authored
Merge: Implement project sharing collaboration features
2 parents ef6fb2e + 3ecc608 commit d632448

File tree

9 files changed

+406
-0
lines changed

9 files changed

+406
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.blockcloud.controller;
2+
3+
import com.blockcloud.dto.ResponseDto.ProjectShareTokenResponseDto;
4+
import com.blockcloud.dto.common.ResponseDto;
5+
import com.blockcloud.dto.oauth.CustomUserDetails;
6+
import com.blockcloud.service.ProjectShareTokenService;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.Parameter;
9+
import io.swagger.v3.oas.annotations.media.Content;
10+
import io.swagger.v3.oas.annotations.media.Schema;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
13+
import io.swagger.v3.oas.annotations.tags.Tag;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.security.core.Authentication;
16+
import org.springframework.web.bind.annotation.*;
17+
18+
/**
19+
* 프로젝트 공유 토큰 관리 API 컨트롤러
20+
*/
21+
@Tag(name = "Project Share Token API", description = "프로젝트 공유 토큰 생성, 조회, 재생성, 비활성화 관련 API")
22+
@RestController
23+
@RequestMapping("/api/projects")
24+
@RequiredArgsConstructor
25+
public class ProjectShareTokenController {
26+
27+
private final ProjectShareTokenService projectShareTokenService;
28+
29+
/**
30+
* 프로젝트의 공유 토큰을 생성하거나 조회합니다.
31+
*
32+
* @param projectId 프로젝트 ID
33+
* @param authentication 인증 정보
34+
* @return 공유 토큰 정보
35+
*/
36+
@Operation(
37+
summary = "공유 토큰 생성/조회",
38+
description = "프로젝트의 공유 토큰을 생성하거나 기존 토큰을 조회합니다. JWT 인증 필요."
39+
)
40+
@GetMapping("/{projectId}/share-token")
41+
public ResponseDto<ProjectShareTokenResponseDto> getShareToken(
42+
@Parameter(description = "프로젝트 ID", required = true)
43+
@PathVariable Long projectId,
44+
Authentication authentication) {
45+
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
46+
return ResponseDto.ok(
47+
projectShareTokenService.getOrCreateShareToken(projectId, userDetails.getUsername()));
48+
}
49+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.blockcloud.controller;
2+
3+
import com.blockcloud.dto.ResponseDto.ProjectViewResponseDto;
4+
import com.blockcloud.dto.common.ResponseDto;
5+
import com.blockcloud.service.ProjectViewService;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.Parameter;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.Schema;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
/**
17+
* 프로젝트 공개 조회 API 컨트롤러 URL 공유를 위한 인증 없는 프로젝트 조회 기능을 제공합니다.
18+
*/
19+
@Tag(name = "Project View API", description = "프로젝트 공개 조회 및 URL 공유 관련 API")
20+
@RestController
21+
@RequestMapping("/api/view")
22+
@RequiredArgsConstructor
23+
public class ProjectViewController {
24+
25+
private final ProjectViewService projectViewService;
26+
27+
/**
28+
* 프로젝트 ID와 공유 토큰으로 프로젝트 정보와 블록 아키텍처를 공개 조회합니다.
29+
*
30+
* @param projectId 조회할 프로젝트 ID
31+
* @param token 공유 토큰
32+
* @return 프로젝트 정보와 블록 아키텍처가 담긴 응답 객체
33+
*/
34+
@Operation(
35+
summary = "프로젝트 공개 조회",
36+
description = "프로젝트 ID와 공유 토큰으로 프로젝트 정보와 블록 아키텍처를 조회합니다. 유효한 공유 토큰이 필요합니다."
37+
)
38+
@GetMapping("/{projectId}")
39+
public ResponseDto<ProjectViewResponseDto> viewProject(
40+
@Parameter(description = "조회할 프로젝트 ID", required = true)
41+
@PathVariable Long projectId,
42+
@Parameter(description = "공유 토큰", required = true)
43+
@RequestParam String token) {
44+
return ResponseDto.ok(projectViewService.getProjectView(projectId, token));
45+
}
46+
}

src/main/java/com/blockcloud/domain/project/Project.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ public class Project extends BaseTimeEntity {
3030

3131
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
3232
@JsonIgnore
33+
@Builder.Default
3334
private List<ProjectUser> members = new ArrayList<>();
3435

36+
@OneToOne(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
37+
@JsonIgnore
38+
private ProjectShareToken shareToken;
39+
3540
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
3641
@JsonIgnore
3742
private List<Deployment> deployments = new ArrayList<>();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.blockcloud.domain.project;
2+
3+
import com.blockcloud.domain.global.BaseTimeEntity;
4+
import jakarta.persistence.*;
5+
import lombok.*;
6+
7+
import java.time.LocalDateTime;
8+
import java.util.UUID;
9+
10+
/**
11+
* 프로젝트 공유 토큰 엔티티 프로젝트를 외부에 공유할 때 사용하는 토큰을 관리합니다.
12+
*/
13+
@Getter
14+
@Entity
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
@AllArgsConstructor
17+
@Builder
18+
@Table(name = "project_share_tokens")
19+
public class ProjectShareToken extends BaseTimeEntity {
20+
21+
@Id
22+
@GeneratedValue(strategy = GenerationType.IDENTITY)
23+
private Long id;
24+
25+
@Column(nullable = false, unique = true, length = 255)
26+
private String token;
27+
28+
@Column(name = "expires_at")
29+
private LocalDateTime expiresAt;
30+
31+
@Column(name = "is_active", nullable = false)
32+
@Builder.Default
33+
private Boolean isActive = true;
34+
35+
@OneToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "project_id", nullable = false)
37+
private Project project;
38+
39+
/**
40+
* 프로젝트에 대한 공유 토큰을 생성합니다.
41+
*
42+
* @param project 공유할 프로젝트
43+
* @return 생성된 공유 토큰
44+
*/
45+
public static ProjectShareToken createForProject(Project project) {
46+
String token = generateSecureToken();
47+
LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); // 30일 후 만료
48+
49+
return ProjectShareToken.builder()
50+
.token(token)
51+
.expiresAt(expiresAt)
52+
.isActive(true)
53+
.project(project)
54+
.build();
55+
}
56+
57+
58+
/**
59+
* 토큰이 유효한지 확인합니다.
60+
*
61+
* @return 토큰이 활성화되어 있고 만료되지 않았으면 true
62+
*/
63+
public boolean isValid() {
64+
return this.isActive &&
65+
this.expiresAt != null &&
66+
this.expiresAt.isAfter(LocalDateTime.now());
67+
}
68+
69+
/**
70+
* 안전한 토큰을 생성합니다.
71+
*
72+
* @return 생성된 토큰 문자열
73+
*/
74+
private static String generateSecureToken() {
75+
return UUID.randomUUID().toString().replace("-", "");
76+
}
77+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.blockcloud.domain.project;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
import org.springframework.data.jpa.repository.Query;
5+
import org.springframework.data.repository.query.Param;
6+
7+
import java.util.Optional;
8+
9+
/**
10+
* 프로젝트 공유 토큰 리포지토리
11+
*/
12+
public interface ProjectShareTokenRepository extends JpaRepository<ProjectShareToken, Long> {
13+
14+
/**
15+
* 프로젝트 ID로 공유 토큰을 조회합니다.
16+
*/
17+
@Query("SELECT pst FROM ProjectShareToken pst WHERE pst.project.id = :projectId")
18+
Optional<ProjectShareToken> findByProjectId(@Param("projectId") Long projectId);
19+
20+
/**
21+
* 토큰 문자열로 공유 토큰을 조회합니다.
22+
*/
23+
Optional<ProjectShareToken> findByToken(String token);
24+
25+
/**
26+
* 프로젝트 ID와 토큰으로 유효한 공유 토큰을 조회합니다.
27+
*/
28+
@Query("SELECT pst FROM ProjectShareToken pst WHERE pst.project.id = :projectId AND pst.token = :token")
29+
Optional<ProjectShareToken> findValidTokenByProjectIdAndToken(@Param("projectId") Long projectId, @Param("token") String token);
30+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.blockcloud.dto.ResponseDto;
2+
3+
import java.time.LocalDateTime;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
/**
8+
* 프로젝트 공유 토큰 응답 DTO
9+
*/
10+
@Getter
11+
@Builder
12+
public class ProjectShareTokenResponseDto {
13+
14+
private Long projectId;
15+
private String token;
16+
private String shareUrl;
17+
private LocalDateTime createdAt;
18+
private LocalDateTime expiresAt;
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.blockcloud.dto.ResponseDto;
2+
3+
import java.time.LocalDateTime;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
/**
8+
* 프로젝트 공개 조회용 응답 DTO
9+
* URL 공유를 위한 프로젝트 정보와 블록 아키텍처 정보를 포함합니다.
10+
*/
11+
@Getter
12+
@Builder
13+
public class ProjectViewResponseDto {
14+
15+
private Long id;
16+
private String name;
17+
private String description;
18+
private LocalDateTime createdAt;
19+
private LocalDateTime updatedAt;
20+
private Object blocks; // 블록 아키텍처 정보 (JSON 형태)
21+
}
22+
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.blockcloud.service;
2+
3+
import com.blockcloud.domain.project.Project;
4+
import com.blockcloud.domain.project.ProjectRepository;
5+
import com.blockcloud.domain.project.ProjectShareToken;
6+
import com.blockcloud.domain.project.ProjectShareTokenRepository;
7+
import com.blockcloud.dto.ResponseDto.ProjectShareTokenResponseDto;
8+
import com.blockcloud.exception.CommonException;
9+
import com.blockcloud.exception.error.ErrorCode;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.stereotype.Service;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
/**
16+
* 프로젝트 공유 토큰 관리 서비스
17+
*/
18+
@Service
19+
@RequiredArgsConstructor
20+
public class ProjectShareTokenService {
21+
22+
private final ProjectRepository projectRepository;
23+
private final ProjectShareTokenRepository projectShareTokenRepository;
24+
25+
@Value("${app.base-url}")
26+
private String baseUrl;
27+
28+
@Value("${app.share-url-path}")
29+
private String shareUrlPath;
30+
31+
/**
32+
* 프로젝트의 공유 토큰을 생성하거나 조회합니다.
33+
*
34+
* @param projectId 프로젝트 ID
35+
* @param email 요청한 사용자의 이메일
36+
* @return 공유 토큰 정보
37+
*/
38+
@Transactional
39+
public ProjectShareTokenResponseDto getOrCreateShareToken(Long projectId, String email) {
40+
Project project = projectRepository.findById(projectId)
41+
.orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT));
42+
43+
validateProjectMember(project, email);
44+
45+
ProjectShareToken shareToken = projectShareTokenRepository.findByProjectId(projectId)
46+
.orElseGet(() -> {
47+
ProjectShareToken newToken = ProjectShareToken.createForProject(project);
48+
return projectShareTokenRepository.save(newToken);
49+
});
50+
51+
return toResponseDto(shareToken);
52+
}
53+
54+
55+
/**
56+
* 사용자가 해당 프로젝트의 멤버인지 검증하는 메서드
57+
*/
58+
private void validateProjectMember(Project project, String email) {
59+
boolean isMember = project.getMembers().stream()
60+
.anyMatch(member -> member.getUser().getEmail().equals(email));
61+
if (!isMember) {
62+
throw new CommonException(ErrorCode.ACCESS_DENIED);
63+
}
64+
}
65+
66+
/**
67+
* ProjectShareToken 엔티티를 ResponseDto로 변환
68+
*/
69+
private ProjectShareTokenResponseDto toResponseDto(ProjectShareToken shareToken) {
70+
String shareUrl = buildShareUrl(shareToken.getProject().getId(), shareToken.getToken());
71+
72+
return ProjectShareTokenResponseDto.builder()
73+
.projectId(shareToken.getProject().getId())
74+
.token(shareToken.getToken())
75+
.shareUrl(shareUrl)
76+
.createdAt(shareToken.getCreatedAt())
77+
.expiresAt(shareToken.getExpiresAt())
78+
.build();
79+
}
80+
81+
/**
82+
* 공유 URL을 생성합니다.
83+
*
84+
* @param projectId 프로젝트 ID
85+
* @param token 공유 토큰
86+
* @return 생성된 공유 URL
87+
*/
88+
private String buildShareUrl(Long projectId, String token) {
89+
// baseUrl 끝에 슬래시가 있으면 제거
90+
String cleanBaseUrl =
91+
baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
92+
// shareUrlPath 시작에 슬래시가 없으면 추가
93+
String cleanSharePath = shareUrlPath.startsWith("/") ? shareUrlPath : "/" + shareUrlPath;
94+
95+
return String.format("%s%s/%d?token=%s", cleanBaseUrl, cleanSharePath, projectId, token);
96+
}
97+
}

0 commit comments

Comments
 (0)