Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,91 @@
package com.slatto.domain.sharelink.controller;

import com.slatto.domain.sharelink.dto.request.ShareLinkRequest.ShareLinkCreateReqDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkCreateResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkEntryResDTO;
import com.slatto.domain.sharelink.dto.request.ShareLinkRequest.GuestCreateReqDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.GuestCreateResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkInfoResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkToggleResDTO;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.slatto.domain.sharelink.service.ShareLinkService;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
@Tag(name = "ShareLink", description = "공유 링크 API")
public class ShareLinkController {

private final ShareLinkService shareLinkService;

@Operation(summary = "공유 링크 생성", description = "영상당 1개만 생성 가능하며, 이미 있으면 409를 반환합니다.")
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/videos/{videoId}/share-links")
public ApiResponse<ShareLinkCreateResDTO> createShareLink(
@PathVariable Long videoId,
@RequestBody @Valid ShareLinkCreateReqDTO req
) {
return ApiResponse.success(
CommonSuccessCode.CREATED,
shareLinkService.createShareLink(videoId, req)
);
}

@Operation(summary = "공유 링크 진입 검증", description = "게스트가 링크로 접근했을 때 유효성을 확인합니다. 인증이 필요 없습니다.")
@GetMapping("/share-links/{token}")
public ApiResponse<ShareLinkEntryResDTO> getShareLinkByToken(
@PathVariable String token
) {
return ApiResponse.success(
CommonSuccessCode.OK,
shareLinkService.getShareLinkByToken(token)
);
}

@Operation(summary = "게스트 등록", description = "링크로 진입한 게스트가 이름을 등록하고 guestId를 발급받습니다. 인증이 필요 없습니다.")
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/share-links/{token}/guests")
public ApiResponse<GuestCreateResDTO> registerGuest(
@PathVariable String token,
@RequestBody @Valid GuestCreateReqDTO req
) {
return ApiResponse.success(
CommonSuccessCode.CREATED,
shareLinkService.registerGuest(token, req)
);
}

@Operation(summary = "공유 링크 조회 (소유자용)", description = "영상의 공유 링크를 조회합니다. 프로젝트 멤버만 가능합니다.")
@GetMapping("/videos/{videoId}/share-links")
public ApiResponse<
ShareLinkInfoResDTO> getShareLinkByVideo(
@PathVariable Long videoId,
@AuthenticationPrincipal Long userId
) {
return ApiResponse.success(
CommonSuccessCode.OK,
shareLinkService.getShareLinkByVideo(videoId, userId)
);
}

@Operation(summary = "공유 링크 활성/비활성 토글", description = "공유 링크의 활성 상태를 뒤집습니다. 프로젝트 멤버만 가능합니다.")
@PatchMapping("/share-links/{shareLinkId}")
public ApiResponse<ShareLinkToggleResDTO> toggleShareLink(
@PathVariable Long shareLinkId,
@AuthenticationPrincipal Long userId
) {
return ApiResponse.success(
CommonSuccessCode.OK,
shareLinkService.toggleShareLink(shareLinkId, userId)
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.slatto.domain.sharelink.converter;

import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkCreateResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkEntryResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.GuestCreateResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkInfoResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkToggleResDTO;
import com.slatto.domain.sharelink.entity.Guest;
import com.slatto.domain.sharelink.entity.ShareLink;
import com.slatto.domain.video.entity.Video;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
public class ShareLinkConverter {

public ShareLink toShareLink(Video video, LocalDateTime expiredAt) {
return ShareLink.create(video, expiredAt);
}

public ShareLinkCreateResDTO toCreateResponse(ShareLink shareLink) {
return new ShareLinkCreateResDTO(
shareLink.getId(),
shareLink.getVideo().getId(),
shareLink.getToken(),
shareLink.getIsActive(),
shareLink.getExpiredAt(),
shareLink.getCreatedAt()
);
}

public ShareLinkEntryResDTO toEntryResponse(ShareLink shareLink) {
return new ShareLinkEntryResDTO(
shareLink.getVideo().getId(),
shareLink.getVideo().getTitle(),
true // 게스트는 항상 닉네임을 입력해야 함
);
}

public Guest toGuest(ShareLink shareLink, String name) {
return Guest.create(shareLink, name);
}

public GuestCreateResDTO toGuestCreateResponse(Guest guest) {
return new GuestCreateResDTO(
guest.getId(),
guest.getShareLink().getId(),
guest.getName(),
guest.getCreatedAt()
);
}

public ShareLinkInfoResDTO toInfoResponse(ShareLink shareLink) {
return new ShareLinkInfoResDTO(
shareLink.getId(),
shareLink.getVideo().getId(),
shareLink.getToken(),
shareLink.getIsActive(),
shareLink.getExpiredAt(),
shareLink.getCreatedAt()
);
}

public ShareLinkToggleResDTO toToggleResponse(ShareLink shareLink) {
return new ShareLinkToggleResDTO(
shareLink.getId(),
shareLink.getIsActive()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.slatto.domain.sharelink.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDateTime;

public class ShareLinkRequest {

@Schema(description = "공유 링크 생성 요청")
public record ShareLinkCreateReqDTO(
@Schema(example = "1")
@NotNull(message = "사용자 ID는 필수입니다.")
Long userId,

@Schema(description = "만료 일시. 미지정 시 무기한", example = "2026-12-31T23:59:59", nullable = true)
LocalDateTime expiredAt
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) { }

@Schema(description = "게스트 등록 요청")
public record GuestCreateReqDTO(
@Schema(example = "홍길동")
@NotBlank(message = "이름은 필수입니다.")
String name
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.slatto.domain.sharelink.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDateTime;

public class ShareLinkResponse {

@Schema(description = "공유 링크 생성 응답")
public record ShareLinkCreateResDTO(
@Schema(example = "5")
Long shareLinkId,

@Schema(example = "10")
Long videoId,

@Schema(example = "550e8400-e29b-41d4-a716-446655440000")
String token,

@Schema(example = "true")
Boolean isActive,

@Schema(example = "2026-12-31T23:59:59", nullable = true)
LocalDateTime expiredAt,

@Schema(example = "2026-07-24T14:00:00")
LocalDateTime createdAt
) { }

@Schema(description = "공유 링크 진입 검증 응답")
public record ShareLinkEntryResDTO(
@Schema(example = "10")
Long videoId,

@Schema(example = "1차 편집본")
String videoTitle,

@Schema(example = "true", description = "게스트 닉네임 입력이 필요한지 여부")
Boolean requiresNickname
) { }

@Schema(description = "게스트 등록 응답")
public record GuestCreateResDTO(
@Schema(example = "20")
Long guestId,

@Schema(example = "5")
Long shareLinkId,

@Schema(example = "홍길동")
String name,

@Schema(example = "2026-07-24T14:00:00")
LocalDateTime createdAt
) { }

@Schema(description = "공유 링크 조회 응답 (소유자용)")
public record ShareLinkInfoResDTO(
@Schema(example = "5")
Long shareLinkId,

@Schema(example = "10")
Long videoId,

@Schema(example = "550e8400-e29b-41d4-a716-446655440000")
String token,

@Schema(example = "true")
Boolean isActive,

@Schema(example = "2026-12-31T23:59:59", nullable = true)
LocalDateTime expiredAt,

@Schema(example = "2026-07-24T14:00:00")
LocalDateTime createdAt
) { }

@Schema(description = "공유 링크 활성/비활성 토글 응답")
public record ShareLinkToggleResDTO(
@Schema(example = "5")
Long shareLinkId,

@Schema(example = "false", description = "토글 후의 활성 상태")
Boolean isActive
) { }
}
9 changes: 9 additions & 0 deletions src/main/java/com/slatto/domain/sharelink/entity/Guest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,13 @@ public class Guest extends BaseEntity {

@Column(name = "name", nullable = false, length = 100)
private String name;

private Guest(ShareLink shareLink, String name) {
this.shareLink = shareLink;
this.name = name;
}

public static Guest create(ShareLink shareLink, String name) {
return new Guest(shareLink, name);
}
}
27 changes: 27 additions & 0 deletions src/main/java/com/slatto/domain/sharelink/entity/ShareLink.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "share_link")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ShareLink extends BaseEntity {

private static final boolean DEFAULT_ACTIVE = true;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
Expand All @@ -32,4 +35,28 @@ public class ShareLink extends BaseEntity {

@Column(name = "expired_at", nullable = true)
private LocalDateTime expiredAt;

private ShareLink(Video video, String token, LocalDateTime expiredAt) {
this.video = video;
this.token = token;
this.isActive = DEFAULT_ACTIVE;
this.expiredAt = expiredAt;
}

// 토큰은 엔티티가 직접 생성 — 서비스에서 실수로 예측 가능한 값을 넣는 걸 막음
public static ShareLink create(Video video, LocalDateTime expiredAt) {
return new ShareLink(video, UUID.randomUUID().toString(), expiredAt);
}

// 활성/비활성 토글 — 현재 상태를 뒤집고 결과를 반환
public boolean toggleActive() {
this.isActive = !this.isActive;
return this.isActive;
}

// 게스트가 실제로 들어올 수 있는 링크인지
public boolean isUsable() {
if (!this.isActive) return false;
return this.expiredAt == null || this.expiredAt.isAfter(LocalDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.slatto.domain.sharelink.exception;

import com.slatto.global.response.code.BaseCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ShareLinkErrorCode implements BaseCode {

SHARE_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "SHARELINK404", "공유 링크를 찾을 수 없습니다."),
SHARE_LINK_ALREADY_EXISTS(HttpStatus.CONFLICT, "SHARELINK409", "이미 이 영상의 공유 링크가 존재합니다."),
INVALID_EXPIRED_AT(HttpStatus.BAD_REQUEST, "SHARELINK400", "만료 일시는 현재 시각보다 이후여야 합니다."),
SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다.");

private final HttpStatus httpStatus;
private final String code;
private final String message;

@Override
public boolean isSuccess() {
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.slatto.domain.sharelink.repository;

import com.slatto.domain.sharelink.entity.ShareLink;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ShareLinkRepository extends JpaRepository<ShareLink, Long> {

// 진입 검증용 — 토큰으로 조회
Optional<ShareLink> findByToken(String token);

// 소유자 조회용 — 영상 기준
Optional<ShareLink> findByVideoId(Long videoId);

// 중복 생성 방지용 (409)
boolean existsByVideoId(Long videoId);
}
Loading
Loading