diff --git a/build.gradle b/build.gradle index 62e7850..62bdfef 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/_data/_data/auth/config/SecurityConstant.java b/src/main/java/com/_data/_data/auth/config/SecurityConstant.java index 0125d98..74af2ff 100644 --- a/src/main/java/com/_data/_data/auth/config/SecurityConstant.java +++ b/src/main/java/com/_data/_data/auth/config/SecurityConstant.java @@ -6,6 +6,8 @@ public class SecurityConstant { "/api/login", "/v3/api-docs/**", "/swagger-ui.html", - "/swagger-ui/**" + "/swagger-ui/**", + "/shared/**", + "/.well-known/**" }; } diff --git a/src/main/java/com/_data/_data/community/controller/ShareController.java b/src/main/java/com/_data/_data/community/controller/ShareController.java new file mode 100644 index 0000000..c562b1b --- /dev/null +++ b/src/main/java/com/_data/_data/community/controller/ShareController.java @@ -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 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 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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/community/dto/ShareResponse.java b/src/main/java/com/_data/_data/community/dto/ShareResponse.java new file mode 100644 index 0000000..dc9c75f --- /dev/null +++ b/src/main/java/com/_data/_data/community/dto/ShareResponse.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/community/dto/TokenInfoDto.java b/src/main/java/com/_data/_data/community/dto/TokenInfoDto.java new file mode 100644 index 0000000..a812643 --- /dev/null +++ b/src/main/java/com/_data/_data/community/dto/TokenInfoDto.java @@ -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" +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/community/entity/ShareToken.java b/src/main/java/com/_data/_data/community/entity/ShareToken.java new file mode 100644 index 0000000..c63ff7f --- /dev/null +++ b/src/main/java/com/_data/_data/community/entity/ShareToken.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/community/repository/ShareTokenRepository.java b/src/main/java/com/_data/_data/community/repository/ShareTokenRepository.java new file mode 100644 index 0000000..6696896 --- /dev/null +++ b/src/main/java/com/_data/_data/community/repository/ShareTokenRepository.java @@ -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 { + Optional findByTokenAndContentTypeAndExpiresAtAfter( + String token, String contentType, LocalDateTime now); + + Optional findByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/community/service/ShareService.java b/src/main/java/com/_data/_data/community/service/ShareService.java new file mode 100644 index 0000000..dc3193b --- /dev/null +++ b/src/main/java/com/_data/_data/community/service/ShareService.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c29d01d..2cd6288 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,13 @@ spring: profiles: include: secret + # Thymeleaf 설정 (HTML 템플릿용) + thymeleaf: + cache: false # 개발환경에서는 false, 운영환경에서는 true + prefix: classpath:/templates/ + suffix: .html + encoding: UTF-8 + datasource: driver-class-name: com.mysql.cj.jdbc.Driver hikari: @@ -65,8 +72,6 @@ app: dir: /app/uploads base-url: /uploads -#selenium: -# url: http://selenium:4444 logging: level: diff --git a/src/main/resources/templates/error/share-not-found.html b/src/main/resources/templates/error/share-not-found.html new file mode 100644 index 0000000..0871183 --- /dev/null +++ b/src/main/resources/templates/error/share-not-found.html @@ -0,0 +1,64 @@ + + + + + + + 링크를 찾을 수 없습니다 + + + +
+
😕
+

링크를 찾을 수 없습니다

+

+ 공유 링크가 만료되었거나 잘못된 링크입니다.
+ 앱에서 최신 콘텐츠를 확인해보세요! +

+ 📱 Android 앱 다운로드 +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/shared/post-view.html b/src/main/resources/templates/shared/post-view.html new file mode 100644 index 0000000..005585f --- /dev/null +++ b/src/main/resources/templates/shared/post-view.html @@ -0,0 +1,125 @@ + + + + + + + 게시물 + + + + + + + + + + + + + + + +
+

📝 게시물

+

게시물 내용

+ 게시물 이미지 + + + +
+

🚀 더 많은 기능을 위해 앱을 설치하세요!

+

댓글 작성, 좋아요, 팔로우 등 모든 기능을 이용해보세요

+ + 🤖 Android 앱 다운로드 +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/shared/profile-view.html b/src/main/resources/templates/shared/profile-view.html new file mode 100644 index 0000000..ce8c0d7 --- /dev/null +++ b/src/main/resources/templates/shared/profile-view.html @@ -0,0 +1,165 @@ + + + + + + + 프로필 + + + + + + + + + + + +
+ 프로필 이미지 +
+ 👤 +
+ +

사용자명

+

국가

+ +
+
+ 0 +
게시물
+
+
+ 0 +
팔로워
+
+
+ 0 +
팔로잉
+
+
+ +
+

🤝 앱에서 팔로우하고 더 많은 콘텐츠를 확인하세요!

+

이 사용자의 최신 게시물과 활동을 놓치지 마세요

+ + 🤖 Android 앱 다운로드 +
+
+ + + + \ No newline at end of file