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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ dependencies {

implementation 'com.mysql:mysql-connector-j:8.3.0'

// share 기능
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

// 크롤러
implementation 'org.jsoup:jsoup:1.15.4'
implementation 'org.seleniumhq.selenium:selenium-java:latest.release'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class SecurityConstant {
"/api/login",
"/v3/api-docs/**",
"/swagger-ui.html",
"/swagger-ui/**"
"/swagger-ui/**",
"/shared/**",
"/.well-known/**"
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package com._data._data.community.controller;
import com._data._data.community.dto.PostDetailDto;
import com._data._data.community.dto.ProfileDto;
import com._data._data.community.dto.ShareResponse;
import com._data._data.community.dto.TokenInfoDto;
import com._data._data.community.entity.ShareToken;
import com._data._data.community.service.PostService;
import com._data._data.community.service.ShareService;
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 jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;


@Controller
@RequiredArgsConstructor
@Tag(name = "Share", description = "게시물 및 프로필 공유 API")
public class ShareController {

private final ShareService shareService;
private final PostService postService;

@Value("${app.base-url}")
private String baseUrl;

@Value("${app.android.package-name}")
private String androidPackageName;

@Value("${app.android.sha256-fingerprint}")
private String androidSha256Fingerprint;

// 1. POST /api/share/post/{postId} - 공유 URL 생성
@Operation(
summary = "게시물 공유 링크 생성",
description = "게시물의 공유 가능한 링크를 생성합니다. 생성된 링크는 웹과 앱에서 모두 사용 가능합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "공유 링크 생성 성공",
content = @Content(schema = @Schema(implementation = ShareResponse.class))
),
@ApiResponse(
responseCode = "404",
description = "게시물을 찾을 수 없음"
)
})
@PostMapping("/api/share/post/{postId}")
@ResponseBody
public ResponseEntity<ShareResponse> createPostShareLink(
@Parameter(description = "공유할 게시물 ID", required = true)
@PathVariable Long postId) {
try {
// 포스트 존재 확인 (PostService의 getPostDetail 사용)
postService.getPostDetail(postId, null);

String shareToken = shareService.createPostShareToken(postId);
String shareUrl = "/shared/post/" + shareToken;

return ResponseEntity.ok(new ShareResponse(shareUrl, shareToken));
} catch (EntityNotFoundException e) {
return ResponseEntity.notFound().build();
}
}

// 2. GET /shared/post/{token} - 웹 미리보기 HTML 반환
@Operation(
summary = "게시물 웹 미리보기",
description = "공유 토큰을 통해 게시물의 웹 미리보기 페이지를 반환합니다. 소셜미디어 공유 시 미리보기로 사용됩니다.",
responses = {
@ApiResponse(responseCode = "200", description = "미리보기 페이지 반환"),
@ApiResponse(responseCode = "404", description = "토큰이 유효하지 않거나 만료됨")
}
)
@GetMapping("/shared/post/{token}")
public String getSharedPost(
@Parameter(description = "공유 토큰", required = true)
@PathVariable String token, Model model) {
ShareToken shareToken = shareService.validateToken(token, "POST");
if (shareToken == null) {
return "error/share-not-found";
}

try {
PostDetailDto post = postService.getPostDetail(shareToken.getContentId(), null);
model.addAttribute("post", post);
model.addAttribute("baseUrl", baseUrl);
model.addAttribute("androidPackageName", androidPackageName);
return "shared/post-view";
} catch (EntityNotFoundException e) {
return "error/share-not-found";
}
}

// 3. POST /api/share/profile/{userId} - 공유 URL 생성
@Operation(
summary = "프로필 공유 링크 생성",
description = "사용자 프로필의 공유 가능한 링크를 생성합니다. 생성된 링크는 웹과 앱에서 모두 사용 가능합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "공유 링크 생성 성공",
content = @Content(schema = @Schema(implementation = ShareResponse.class))
),
@ApiResponse(
responseCode = "404",
description = "사용자를 찾을 수 없음"
)
})
@PostMapping("/api/share/profile/{userId}")
@ResponseBody
public ResponseEntity<ShareResponse> createProfileShareLink(
@Parameter(description = "공유할 사용자 ID", required = true)
@PathVariable Long userId) {
try {
// 유저 존재 확인 (PostService의 getProfile 사용)
postService.getProfile(null, userId);

String shareToken = shareService.createProfileShareToken(userId);
String shareUrl = "/shared/profile/" + shareToken;

return ResponseEntity.ok(new ShareResponse(shareUrl, shareToken));
} catch (EntityNotFoundException e) {
return ResponseEntity.notFound().build();
}
}

// 4. GET /shared/profile/{token} - 웹 미리보기 HTML 반환
@Operation(
summary = "프로필 웹 미리보기",
description = "공유 토큰을 통해 사용자 프로필의 웹 미리보기 페이지를 반환합니다. 소셜미디어 공유 시 미리보기로 사용됩니다.",
responses = {
@ApiResponse(responseCode = "200", description = "미리보기 페이지 반환"),
@ApiResponse(responseCode = "404", description = "토큰이 유효하지 않거나 만료됨")
}
)
@GetMapping("/shared/profile/{token}")
public String getSharedProfile(
@Parameter(description = "공유 토큰", required = true)
@PathVariable String token, Model model) {
ShareToken shareToken = shareService.validateToken(token, "PROFILE");
if (shareToken == null) {
return "error/share-not-found";
}

try {
ProfileDto profile = postService.getProfile(null, shareToken.getContentId());
model.addAttribute("profile", profile);
model.addAttribute("baseUrl", baseUrl);
model.addAttribute("androidPackageName", androidPackageName);
return "shared/profile-view";
} catch (EntityNotFoundException e) {
return "error/share-not-found";
}
}

// 5. GET /.well-known/assetlinks.json - Digital Asset Links 파일 제공 (Android App Links용)
@Operation(
summary = "Android App Links 검증 파일",
description = "Android App Links를 위한 Digital Asset Links 검증 파일을 제공합니다. " +
"Android 시스템이 앱과 웹사이트 간의 연결을 검증하는 데 사용됩니다.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Digital Asset Links JSON 파일 반환",
content = @Content(mediaType = "application/json")
)
}
)
@GetMapping(value = "/.well-known/assetlinks.json", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String getAssetLinks() {
return String.format("""
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "%s",
"sha256_cert_fingerprints": ["%s"]
}
}]
""", androidPackageName, androidSha256Fingerprint);
}

// 6. GET /api/share/token/{token} - 앱에서 토큰으로 실제 ID 조회용 (필요시)
@Operation(
summary = "토큰으로 콘텐츠 정보 조회",
description = "공유 토큰을 통해 실제 게시물 또는 프로필 ID와 타입을 조회합니다. " +
"앱에서 딥링크 처리 시 사용됩니다.",
responses = {
@ApiResponse(
responseCode = "200",
description = "콘텐츠 정보 반환",
content = @Content(schema = @Schema(implementation = TokenInfoDto.class))
),
@ApiResponse(responseCode = "404", description = "토큰이 유효하지 않거나 만료됨")
}
)
@GetMapping("/api/share/token/{token}")
@ResponseBody
public ResponseEntity<TokenInfoDto> getContentIdByToken(
@Parameter(description = "공유 토큰", required = true)
@PathVariable String token) {
ShareToken shareToken = shareService.validateToken(token, "POST");
if (shareToken == null) {
shareToken = shareService.validateToken(token, "PROFILE");
}

if (shareToken == null) {
return ResponseEntity.notFound().build();
}

TokenInfoDto tokenInfo = new TokenInfoDto(shareToken.getContentId(), shareToken.getContentType());
return ResponseEntity.ok(tokenInfo);
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/_data/_data/community/dto/ShareResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com._data._data.community.dto;


import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ShareResponse {
private String shareUrl;
private String token;
}
12 changes: 12 additions & 0 deletions src/main/java/com/_data/_data/community/dto/TokenInfoDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com._data._data.community.dto;


import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class TokenInfoDto {
private Long contentId;
private String contentType; // "POST" or "PROFILE"
}
36 changes: 36 additions & 0 deletions src/main/java/com/_data/_data/community/entity/ShareToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com._data._data.community.entity;


import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Entity
@Table(name = "share_tokens")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ShareToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false)
private String token;

@Column(nullable = false)
private String contentType; // "POST" or "PROFILE"

@Column(nullable = false)
private Long contentId;

@Column(nullable = false)
private LocalDateTime createdAt;

@Column(nullable = false)
private LocalDateTime expiresAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com._data._data.community.repository;

import com._data._data.community.entity.ShareToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional;

@Repository
public interface ShareTokenRepository extends JpaRepository<ShareToken, Long> {
Optional<ShareToken> findByTokenAndContentTypeAndExpiresAtAfter(
String token, String contentType, LocalDateTime now);

Optional<ShareToken> findByToken(String token);
}
62 changes: 62 additions & 0 deletions src/main/java/com/_data/_data/community/service/ShareService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com._data._data.community.service;


import com._data._data.community.entity.ShareToken;
import com._data._data.community.repository.ShareTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class ShareService {

private final ShareTokenRepository shareTokenRepository;

public String createPostShareToken(Long postId) {
String token = UUID.randomUUID().toString();

ShareToken shareToken = ShareToken.builder()
.token(token)
.contentType("POST")
.contentId(postId)
.expiresAt(LocalDateTime.now().plusDays(30)) // 30일 후 만료
.createdAt(LocalDateTime.now())
.build();

shareTokenRepository.save(shareToken);
return token;
}

public String createProfileShareToken(Long userId) {
String token = UUID.randomUUID().toString();

ShareToken shareToken = ShareToken.builder()
.token(token)
.contentType("PROFILE")
.contentId(userId)
.expiresAt(LocalDateTime.now().plusDays(30))
.createdAt(LocalDateTime.now())
.build();

shareTokenRepository.save(shareToken);
return token;
}

public ShareToken validateToken(String token, String expectedType) {
return shareTokenRepository.findByTokenAndContentTypeAndExpiresAtAfter(
token, expectedType, LocalDateTime.now())
.orElse(null);
}

public Long getPostIdByToken(String token) {
ShareToken shareToken = validateToken(token, "POST");
return shareToken != null ? shareToken.getContentId() : null;
}

public Long getUserIdByToken(String token) {
ShareToken shareToken = validateToken(token, "PROFILE");
return shareToken != null ? shareToken.getContentId() : null;
}
}
Loading