diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c4d02e2..ee9713f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -92,6 +92,16 @@ services: networks: - backend + sns-post-service: + image: ${DOCKER_USERNAME}/aivle-sns-post:latest + container_name: sns-post-service + depends_on: + - kafka + environment: + - SPRING_PROFILES_ACTIVE=prod + networks: + - backend + networks: backend: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index eec01f9..0bbc2b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,9 +120,21 @@ services: networks: - backend + sns-service: + build: + context: ./sns-service + dockerfile: Dockerfile + container_name: sns-service + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=docker + networks: + - backend + volumes: mysql_data: networks: backend: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle index ea2bdd6..efee951 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,4 +4,6 @@ include 'gateway' include 'auth-service' include 'store-service' include 'shorts-service' +include 'sns-post-service' +include 'sns-service' diff --git a/sns-service/Dockerfile b/sns-service/Dockerfile new file mode 100644 index 0000000..e8b7b97 --- /dev/null +++ b/sns-service/Dockerfile @@ -0,0 +1,12 @@ +FROM openjdk:17-jdk-alpine + +WORKDIR /app + +COPY build/libs/*SNAPSHOT.jar app.jar + +EXPOSE 8080 + +ENV TZ=Asia/Seoul +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +ENTRYPOINT ["java","-Xmx400M","-Djava.security.egd=file:/dev/./urandom","-jar","app.jar"] \ No newline at end of file diff --git a/sns-service/build.gradle b/sns-service/build.gradle new file mode 100644 index 0000000..1a4e305 --- /dev/null +++ b/sns-service/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +dependencies { + // 공통 모듈 + implementation project(':common') + + // Web/API + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Youtube API/인증 + implementation 'com.google.api-client:google-api-client:1.34.1' + implementation 'com.google.apis:google-api-services-youtube:v3-rev222-1.25.0' + implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' + implementation 'com.google.apis:google-api-services-youtubeAnalytics:v2-rev272-1.25.0' + + // 보안·인증 + implementation 'org.springframework.security:spring-security-crypto' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // DB/JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + //runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // 요청 검증 + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + implementation 'com.fasterxml.jackson.core:jackson-databind' +} diff --git a/sns-service/src/main/java/kt/aivle/sns/SnsServiceApplication.java b/sns-service/src/main/java/kt/aivle/sns/SnsServiceApplication.java new file mode 100644 index 0000000..b4ec997 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/SnsServiceApplication.java @@ -0,0 +1,14 @@ +package kt.aivle.sns; + +import kt.aivle.sns.config.YoutubeOAuthProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication(scanBasePackages = {"kt.aivle.sns", "kt.aivle.common"}) +@EnableConfigurationProperties({YoutubeOAuthProperties.class}) // 다른 sns properties 추가 +public class SnsServiceApplication { + public static void main(String[] args) { + SpringApplication.run(SnsServiceApplication.class, args); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/AiPostController.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/AiPostController.java new file mode 100644 index 0000000..d0ffa34 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/AiPostController.java @@ -0,0 +1,30 @@ +// adapter/in/web/AIPostController.java +// Spring Boot 서버 내 REST API 경로 (클라이언트 → Spring Boot) +package kt.aivle.sns.adapter.in.web; + +import jakarta.validation.Valid; +import kt.aivle.sns.adapter.in.web.dto.request.CreateHashtagRequest; +import kt.aivle.sns.adapter.in.web.dto.request.CreatePostRequest; +import kt.aivle.sns.adapter.in.web.dto.response.CreateHashtagResponse; +import kt.aivle.sns.adapter.in.web.dto.response.CreatePostResponse; +import kt.aivle.sns.application.port.in.AiPostUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/posts/ai") +@RequiredArgsConstructor +public class AiPostController { + + private final AiPostUseCase aiPostUseCase; + + @PostMapping("/post") + public CreatePostResponse createPost(@Valid @RequestBody CreatePostRequest request) { + return aiPostUseCase.createPost(request); + } + + @PostMapping("/hashtags") + public CreateHashtagResponse createHashtags(@Valid @RequestBody CreateHashtagRequest request) { + return aiPostUseCase.createHashtags(request); + } +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsAccountController.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsAccountController.java new file mode 100644 index 0000000..cda0f1b --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsAccountController.java @@ -0,0 +1,47 @@ +package kt.aivle.sns.adapter.in.web; + +import kt.aivle.common.response.ApiResponse; +import kt.aivle.common.response.ResponseUtils; +import kt.aivle.sns.adapter.in.web.dto.SnsAccountResponse; +import kt.aivle.sns.application.service.SnsAccountDelegator; +import kt.aivle.sns.adapter.in.web.dto.SnsAccountUpdateRequest; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static kt.aivle.common.code.CommonResponseCode.OK; + +@RestController +@RequestMapping("sns/account") +@RequiredArgsConstructor +public class SnsAccountController { + + private final SnsAccountDelegator snsAccountDelegator; + + private final ResponseUtils responseUtils; + + @GetMapping("/{snsType}") + public ResponseEntity> getAccountInfo(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestParam Long storeId) { + SnsAccountResponse account = snsAccountDelegator.getAccountInfo(snsType, userId, storeId); + return responseUtils.build(OK, account); + } + + @PutMapping("/{snsType}") + public ResponseEntity updateAccount(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestBody SnsAccountUpdateRequest request) { + snsAccountDelegator.updateAccount(snsType, userId, request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{snsType}/list") + public ResponseEntity getPostList(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestParam Long storeId) { + snsAccountDelegator.getPostList(snsType, userId, storeId); + return ResponseEntity.ok().build(); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsOAuthController.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsOAuthController.java new file mode 100644 index 0000000..b57a52f --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsOAuthController.java @@ -0,0 +1,35 @@ +package kt.aivle.sns.adapter.in.web; + +import kt.aivle.sns.application.service.SnsOAuthDelegator; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("sns/oauth") +@RequiredArgsConstructor +public class SnsOAuthController { + private final SnsOAuthDelegator delegator; + + @GetMapping("/") + public String home() throws Exception { + return "sns/oauth/"; + } + + // 각 SNS 연동 버튼 누르면 호출 + @GetMapping("/{snsType}/url") + public String getAuthUrl(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestParam Long storeId) { + return delegator.getAuthUrl(snsType, userId, storeId); + } + + @GetMapping("/{snsType}/callback") + public String callback(@PathVariable SnsType snsType, + @RequestParam String code, + @RequestParam String state) throws Exception { + delegator.handleCallback(snsType, state, code); + + return "계정 연동 완료"; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsPostController.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsPostController.java new file mode 100644 index 0000000..357656e --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/SnsPostController.java @@ -0,0 +1,42 @@ +package kt.aivle.sns.adapter.in.web; + +import kt.aivle.sns.application.service.SnsPostDelegator; +import kt.aivle.sns.adapter.in.web.dto.PostDeleteRequest; +import kt.aivle.sns.adapter.in.web.dto.PostUpdateRequest; +import kt.aivle.sns.domain.model.SnsType; +import kt.aivle.sns.adapter.in.web.dto.PostUploadRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("sns/video") +@RequiredArgsConstructor +public class SnsPostController { + + private final SnsPostDelegator snsPostDelegator; + + @PostMapping("/{snsType}/upload") + public ResponseEntity uploadVideo(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestBody PostUploadRequest request) { + snsPostDelegator.upload(snsType, userId, request); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{snsType}/update") + public ResponseEntity updateVideo(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestBody PostUpdateRequest request) { + snsPostDelegator.update(snsType, userId, request); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{snsType}/delete") + public ResponseEntity deleteVideo(@PathVariable SnsType snsType, + @RequestHeader("X-USER-ID") Long userId, + @RequestBody PostDeleteRequest request) { + snsPostDelegator.delete(snsType, userId, request); + return ResponseEntity.ok().build(); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreateHashtagRequest.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreateHashtagRequest.java new file mode 100644 index 0000000..6191deb --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreateHashtagRequest.java @@ -0,0 +1,38 @@ +package kt.aivle.sns.adapter.in.web.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateHashtagRequest { + + @JsonProperty("post_title") + @NotBlank(message = "게시물 제목을 입력해주세요.") + private String postTitle; + + @JsonProperty("post_content") + @NotBlank(message = "게시물 본문을 입력해주세요.") + private String postContent; + + @JsonProperty("user_keywords") + private List userKeywords; + + @JsonProperty("sns_platform") + @NotBlank(message = "SNS 플랫폼을 입력해주세요.") + private String snsPlatform; + + @JsonProperty("business_type") + @NotBlank(message = "업종을 입력해주세요.") + private String businessType; + + private String location; +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreateHashtagResponse.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreateHashtagResponse.java new file mode 100644 index 0000000..ae20caa --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreateHashtagResponse.java @@ -0,0 +1,6 @@ +package kt.aivle.sns.adapter.in.web.dto.response; + +import java.util.List; + +public record CreateHashtagResponse(List hashtags) { +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreatePostRequest.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreatePostRequest.java new file mode 100644 index 0000000..35a9a66 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreatePostRequest.java @@ -0,0 +1,34 @@ +package kt.aivle.sns.adapter.in.web.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreatePostRequest { + + @JsonProperty("content_data") + @NotBlank(message = "콘텐츠 데이터를 입력해주세요.") + private String contentData; + + @JsonProperty("user_keywords") + private List userKeywords; + + @JsonProperty("sns_platform") + @NotBlank(message = "SNS 플랫폼을 입력해주세요.") + private String snsPlatform; + + @JsonProperty("business_type") + @NotBlank(message = "업종을 입력해주세요.") + private String businessType; + + private String location; +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreatePostResponse.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreatePostResponse.java new file mode 100644 index 0000000..c64702f --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/CreatePostResponse.java @@ -0,0 +1,6 @@ +package kt.aivle.sns.adapter.in.web.dto.response; + +import java.util.List; + +public record CreatePostResponse(String title, String content, List hashtags) { +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostDeleteRequest.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostDeleteRequest.java new file mode 100644 index 0000000..6a9dbd7 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostDeleteRequest.java @@ -0,0 +1,20 @@ +package kt.aivle.sns.adapter.in.web.dto; + +public class PostDeleteRequest { + private String postId; + private Long storeId; + + public PostDeleteRequest() {}; + + public String getPostId() { + return postId; + } + + public void setPostId(String postId) { + this.postId = postId; + } + + public Long getStoreId() { + return storeId; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostUpdateRequest.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostUpdateRequest.java new file mode 100644 index 0000000..11ada4d --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostUpdateRequest.java @@ -0,0 +1,56 @@ +package kt.aivle.sns.adapter.in.web.dto; + +public class PostUpdateRequest { + private String postId; // 게시글 ID + private Long storeId; + private String title; + private String description; + private String[] tags; + private Object detail; // SNS별 세부정보 + + public PostUpdateRequest() {}; + + public String getPostId() { + return postId; + } + + public void setPostId(String postId) { + this.postId = postId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String[] getTags() { + return tags; + } + + public void setTags(String[] tags) { + this.tags = tags; + } + + public Object getDetail() { + return detail; + } + + public void setDetail(Object detail) { + this.detail = detail; + } + + public Long getStoreId() { + return storeId; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostUploadRequest.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostUploadRequest.java new file mode 100644 index 0000000..ec5f7ff --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/PostUploadRequest.java @@ -0,0 +1,55 @@ +package kt.aivle.sns.adapter.in.web.dto; + +public class PostUploadRequest { + private Long storeId; + private String title; + private String description; + private String contentPath; + private String[] tags; + private Object detail; // SNS별 세부정보 + + public PostUploadRequest() {} + + public void setTitle(String title) { + this.title = title; + } + + public void setDescription(String description) { + this.description = description; + } + public void setContentPath(String contentPath) { + this.contentPath = contentPath; + } + + public void setTags(String[] tags) { + this.tags = tags; + } + + public void setDetail(Object detail) { + this.detail = detail; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public String getContentPath() { + return contentPath; + } + + public String[] getTags() { + return tags; + } + + public Object getDetail() { + return detail; + } + + public Long getStoreId() { + return storeId; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/SnsAccountResponse.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/SnsAccountResponse.java new file mode 100644 index 0000000..199d042 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/SnsAccountResponse.java @@ -0,0 +1,40 @@ +package kt.aivle.sns.adapter.in.web.dto; + +import kt.aivle.sns.domain.model.SnsAccount; +import kt.aivle.sns.domain.model.SnsType; +import lombok.Builder; + +import java.util.List; + +@Builder +public record SnsAccountResponse ( + Long id, + Long userId, + Long storeId, + SnsType snsType, + String snsAccountId, + String snsAccountName, + String snsAccountDescription, + String snsAccountUrl, + Integer follower, + Integer postCount, + Integer viewCount, + List keyword +){ + public static SnsAccountResponse from(SnsAccount account) { + return SnsAccountResponse.builder() + .id(account.getId()) + .userId(account.getUserId()) + .storeId(account.getStoreId()) + .snsType(account.getSnsType()) + .snsAccountId(account.getSnsAccountId()) + .snsAccountName(account.getSnsAccountName()) + .snsAccountDescription(account.getSnsAccountDescription()) + .snsAccountUrl(account.getSnsAccountUrl()) + .follower(account.getFollower()) + .postCount(account.getPostCount()) + .viewCount(account.getViewCount()) + .keyword(account.getKeywords()) + .build(); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/SnsAccountUpdateRequest.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/SnsAccountUpdateRequest.java new file mode 100644 index 0000000..61ebb95 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/SnsAccountUpdateRequest.java @@ -0,0 +1,40 @@ +package kt.aivle.sns.adapter.in.web.dto; + +public class SnsAccountUpdateRequest { + + private Long storeId; + private String snsAccountId; // 유튜브 채널 id + + private String snsAccountDescription; // 채널 설명 + private String[] keywords; // 채널 키워드 + + public SnsAccountUpdateRequest() {}; + + public String getSnsAccountId() { + return snsAccountId; + } + + public void setSnsAccountId(String snsAccountId) { + this.snsAccountId = snsAccountId; + } + + public String getSnsAccountDescription() { + return snsAccountDescription; + } + + public void setSnsAccountDescription(String snsAccountDescription) { + this.snsAccountDescription = snsAccountDescription; + } + + public String[] getKeywords() { + return keywords; + } + + public void setKeywords(String[] keywords) { + this.keywords = keywords; + } + + public Long getStoreId() { + return storeId; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/YoutubeUpdateDetail.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/YoutubeUpdateDetail.java new file mode 100644 index 0000000..bc2ca39 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/YoutubeUpdateDetail.java @@ -0,0 +1,15 @@ +package kt.aivle.sns.adapter.in.web.dto; + +public class YoutubeUpdateDetail { + private String categoryId; // 카테고리 번호로 입력 (String) + + public YoutubeUpdateDetail() {}; + + public String getCategoryId() { + return categoryId; + } + + public void setCategoryId(String categoryId) { + this.categoryId = categoryId; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/YoutubeUploadDetail.java b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/YoutubeUploadDetail.java new file mode 100644 index 0000000..f0f2277 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/in/web/dto/YoutubeUploadDetail.java @@ -0,0 +1,64 @@ +package kt.aivle.sns.adapter.in.web.dto; + +import java.time.OffsetDateTime; + +public class YoutubeUploadDetail { + private String categoryId; // 카테고리 번호로 입력 (String) +// 1 영화/애니메이션 Film & Animation +// 2 자동차 및 차량 Autos & Vehicles +// 10 음악 Music +// 15 동물 Pets & Animals +// 17 스포츠 Sports +// 18 단편 영화 Short Movies +// 19 여행 및 이벤트 Travel & Events +// 20 게임 Gaming +// 21 블로그 Videoblogging +// 22 사람 & 블로그 People & Blogs +// 23 코미디 Comedy +// 24 엔터테인먼트 Entertainment +// 25 뉴스 및 정치 News & Politics +// 26 강의 Howto & Style +// 27 교육 Education +// 28 과학기술 Science & Technology +// 30 영화 Movies +// 31 애니메이션 Anime/Animation +// 32 액션/어드벤처 Action/Adventure +// 34 드라마 Drama +// 35 가족 Family +// 36 외국 Foreign +// 37 공포 Horror +// 38 SF Sci-Fi/Fantasy +// 39 스릴러 Thriller +// 40 단편 영화 Shorts +// 41 쇼 Shows +// 42 예고편 Trailers + private boolean notifySubscribers; + private OffsetDateTime publishAt; + + + public YoutubeUploadDetail() {} + + public OffsetDateTime getPublishAt() { + return publishAt; + } + + public void setPublishAt(OffsetDateTime publishAt) { + this.publishAt = publishAt; + } + + public String getCategoryId() { + return categoryId; + } + + public void setCategoryId(String categoryId) { + this.categoryId = categoryId; + } + + public boolean isNotifySubscribers() { + return notifySubscribers; + } + + public void setNotifySubscribers(boolean notifySubscribers) { + this.notifySubscribers = notifySubscribers; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaOAuthStateRepository.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaOAuthStateRepository.java new file mode 100644 index 0000000..153e6bf --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaOAuthStateRepository.java @@ -0,0 +1,6 @@ +package kt.aivle.sns.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaOAuthStateRepository extends JpaRepository { +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaPostRepository.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaPostRepository.java new file mode 100644 index 0000000..c41ed21 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaPostRepository.java @@ -0,0 +1,10 @@ +package kt.aivle.sns.adapter.out.persistence; + +import kt.aivle.sns.domain.model.PostEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface JpaPostRepository extends JpaRepository { + Optional findByPostId(String postId); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaSnsAccountRepository.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaSnsAccountRepository.java new file mode 100644 index 0000000..5578c28 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaSnsAccountRepository.java @@ -0,0 +1,14 @@ +package kt.aivle.sns.adapter.out.persistence; + +import kt.aivle.sns.domain.model.SnsAccount; +import kt.aivle.sns.domain.model.SnsType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface JpaSnsAccountRepository extends JpaRepository { + + Optional findByUserIdAndSnsType(Long userId, SnsType snsType); + + Optional findBySnsAccountId(String snsAccountId); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaSnsTokenRepository.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaSnsTokenRepository.java new file mode 100644 index 0000000..a223346 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/JpaSnsTokenRepository.java @@ -0,0 +1,15 @@ +package kt.aivle.sns.adapter.out.persistence; + +import kt.aivle.sns.domain.model.SnsToken; +import kt.aivle.sns.domain.model.SnsType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface JpaSnsTokenRepository extends JpaRepository { + Optional findByUserId(Long userId); + + Optional findByUserIdAndSnsType(Long userId, SnsType snsType); + + Optional findByUserIdAndStoreIdAndSnsType(Long userId, Long storeId, SnsType snsType); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/OAuthStateEntity.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/OAuthStateEntity.java new file mode 100644 index 0000000..52f67ad --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/OAuthStateEntity.java @@ -0,0 +1,21 @@ +package kt.aivle.sns.adapter.out.persistence; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name="oauth_state") +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor +@Builder +public class OAuthStateEntity { + @Id + private String state; + private Long userId; + private Long storeId; + private Instant expiresAt; +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/SnsAccountPersistenceAdapter.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/SnsAccountPersistenceAdapter.java new file mode 100644 index 0000000..b0bca03 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/SnsAccountPersistenceAdapter.java @@ -0,0 +1,31 @@ +package kt.aivle.sns.adapter.out.persistence; + +import kt.aivle.sns.application.port.out.SnsAccountRepositoryPort; +import kt.aivle.sns.domain.model.SnsAccount; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class SnsAccountPersistenceAdapter implements SnsAccountRepositoryPort { + + private final JpaSnsAccountRepository snsAccountRepository; + + @Override + public SnsAccount save(SnsAccount snsAccount) { + return snsAccountRepository.save(snsAccount); + } + + @Override + public Optional findByUserIdAndSnsType(Long userId, SnsType snsType) { + return snsAccountRepository.findByUserIdAndSnsType(userId, snsType); + } + + @Override + public Optional findBySnsAccountId(String snsAccountId) { + return snsAccountRepository.findBySnsAccountId(snsAccountId); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/SnsTokenPersistenceAdapter.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/SnsTokenPersistenceAdapter.java new file mode 100644 index 0000000..414973b --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/persistence/SnsTokenPersistenceAdapter.java @@ -0,0 +1,41 @@ +package kt.aivle.sns.adapter.out.persistence; + +import kt.aivle.sns.application.port.out.SnsTokenRepositoryPort; +import kt.aivle.sns.domain.model.SnsToken; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class SnsTokenPersistenceAdapter implements SnsTokenRepositoryPort { + + private final JpaSnsTokenRepository snsTokenRepository; + + @Override + public SnsToken save(SnsToken snsToken) { + return snsTokenRepository.save(snsToken); + } + + @Override + public Optional findById(Long id) { + return snsTokenRepository.findById(id); + } + + @Override + public Optional findByUserId(Long userId) { + return snsTokenRepository.findByUserId(userId); + } + + @Override + public Optional findByUserIdAndSnsType(Long userId, SnsType snsType) { + return snsTokenRepository.findByUserIdAndSnsType(userId, snsType); + } + + @Override + public Optional findByUserIdAndStoreIdAndSnsType(Long userId, Long storeId, SnsType snsType) { + return snsTokenRepository.findByUserIdAndStoreIdAndSnsType(userId, storeId, snsType); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/web/FastApiClient.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/web/FastApiClient.java new file mode 100644 index 0000000..5f719d9 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/web/FastApiClient.java @@ -0,0 +1,47 @@ +// FastApiClient에서 호출하는 외부 FastAPI 서버 경로 (Spring Boot → FastAPI) +package kt.aivle.sns.adapter.out.web; + +import kt.aivle.sns.adapter.in.web.dto.request.CreateHashtagRequest; +import kt.aivle.sns.adapter.in.web.dto.request.CreatePostRequest; +import kt.aivle.sns.adapter.in.web.dto.response.CreateHashtagResponse; +import kt.aivle.sns.adapter.in.web.dto.response.CreatePostResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FastApiClient { + + private final WebClient fastApiWebClient; + + public CreatePostResponse createPost(CreatePostRequest request) { + try { + return fastApiWebClient.post() + .uri("/sns-post/agent/post") + .bodyValue(request) + .retrieve() + .bodyToMono(CreatePostResponse.class) + .block(); + } catch (Exception e) { + log.error("FastAPI 게시물 생성 실패: {}", e.getMessage()); + throw new RuntimeException("게시물 생성에 실패했습니다."); + } + } + + public CreateHashtagResponse createHashtags(CreateHashtagRequest request) { + try { + return fastApiWebClient.post() + .uri("/sns-post/agent/hashtags") + .bodyValue(request) + .retrieve() + .bodyToMono(CreateHashtagResponse.class) + .block(); + } catch (Exception e) { + log.error("FastAPI 해시태그 생성 실패: {}", e.getMessage()); + throw new RuntimeException("해시태그 생성에 실패했습니다."); + } + } +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeChannelListApi.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeChannelListApi.java new file mode 100644 index 0000000..2c4dd75 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeChannelListApi.java @@ -0,0 +1,83 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.services.youtube.YouTube; +import com.google.api.services.youtube.model.*; +import kt.aivle.sns.adapter.in.web.dto.SnsAccountResponse; +import kt.aivle.sns.application.port.out.SnsAccountRepositoryPort; +import kt.aivle.sns.domain.model.SnsAccount; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +@Component +@RequiredArgsConstructor +public class YoutubeChannelListApi { + + private final YoutubeClientFactory youtubeClientFactory; + + private final SnsAccountRepositoryPort snsAccountRepositoryPort; + + public SnsAccountResponse getYoutubeChannelInfo(Long userId, Long storeId) { + + try { + + // userId 기반으로 인증된 YouTube 객체 생성 + YouTube youtube = youtubeClientFactory.youtube(userId, storeId); + + // 업데이트 요청 api 생성 및 실행 + YouTube.Channels.List request = youtube.channels() + .list("snippet,contentDetails,statistics,brandingSettings"); + ChannelListResponse response = request.setMine(true).execute(); + + Channel channel = response.getItems().get(0); + ChannelSnippet snippet = channel.getSnippet(); + ChannelStatistics statistics = channel.getStatistics(); + ChannelSettings settings = channel.getBrandingSettings().getChannel(); + + + // SnsAccount Info 저장 + SnsAccount account = snsAccountRepositoryPort.findByUserIdAndSnsType(userId, SnsType.youtube) + .map(existing -> { + existing = SnsAccount.builder() + .id(existing.getId()) + .userId(userId) + .storeId(storeId) + .snsType(SnsType.youtube) + .snsAccountId(channel.getId()) // 유튜브 채널 id + .snsAccountName(snippet.getTitle()) // 채널명 + .snsAccountDescription(snippet.getDescription()) // 채널 설명 + .snsAccountUrl(snippet.getCustomUrl()) // 채널 url + .follower(statistics.getSubscriberCount().intValue()) // 구독자 수 + .postCount(statistics.getVideoCount().intValue()) // 업로드 동영상 수 + .viewCount(statistics.getViewCount().intValue()) // 전체 동영상 조회수 + .keywords(Arrays.asList(settings.getKeywords())) + .build(); + return existing; + }) + .orElse(SnsAccount.builder() + .userId(userId) + .storeId(storeId) + .snsType(SnsType.youtube) + .snsAccountId(channel.getId()) // 유튜브 채널 id + .snsAccountName(snippet.getTitle()) // 채널명 + .snsAccountDescription(snippet.getDescription()) // 채널 설명 + .snsAccountUrl(snippet.getCustomUrl()) // 채널 url + .follower(statistics.getSubscriberCount().intValue()) // 구독자 수 + .postCount(statistics.getVideoCount().intValue()) // 업로드 동영상 수 + .viewCount(statistics.getViewCount().intValue()) // 전체 동영상 조회수 + .keywords(Arrays.asList(settings.getKeywords())) + .build()); + + snsAccountRepositoryPort.save(account); + + return SnsAccountResponse.from(account); + + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("채널정보 불러오기 실패", e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeChannelUpdateApi.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeChannelUpdateApi.java new file mode 100644 index 0000000..6449639 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeChannelUpdateApi.java @@ -0,0 +1,74 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.services.youtube.YouTube; +import com.google.api.services.youtube.model.Channel; +import com.google.api.services.youtube.model.ChannelBrandingSettings; +import com.google.api.services.youtube.model.ChannelSettings; +import kt.aivle.sns.application.port.out.SnsAccountRepositoryPort; +import kt.aivle.sns.domain.model.SnsAccount; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class YoutubeChannelUpdateApi { + + private final YoutubeClientFactory youtubeClientFactory; + + private final SnsAccountRepositoryPort snsAccountRepositoryPort; + + public void updateAccount(Long userId, + Long storeId, + String accountId, + String description, + String[] keywords) { + + try { + // userId 기반으로 인증된 YouTube 객체 생성 + YouTube youtube = youtubeClientFactory.youtube(userId, storeId); + + // Define the Channel object, which will be uploaded as the request body. + Channel channel = new Channel(); + + // Add the id string property to the Channel object. + channel.setId(accountId); + + // Add the brandingSettings object property to the Channel object. + ChannelBrandingSettings brandingSettings = new ChannelBrandingSettings(); + ChannelSettings channelSettings = new ChannelSettings(); + channelSettings.setDescription(description); + String strKeywords = String.join(",", keywords); + channelSettings.setKeywords(strKeywords); + brandingSettings.setChannel(channelSettings); + channel.setBrandingSettings(brandingSettings); + + // Define and execute the API request + YouTube.Channels.Update request = youtube.channels() + .update("brandingSettings", channel); + Channel response = request.execute(); + System.out.println("업데이트된 Channel ID: " + response.getId()); // 유튜브 채널 id + + Optional optionalSnsAccount = snsAccountRepositoryPort.findBySnsAccountId(accountId); + if(optionalSnsAccount.isPresent()) { + SnsAccount snsAccount = optionalSnsAccount.get(); + snsAccount.setSnsAccountDescription(description); + snsAccount.setKeywords(new ArrayList<>(Arrays.asList(keywords))); + + snsAccountRepositoryPort.save(snsAccount); + }else { + System.err.println("해당 videoId를 가진 게시물을 찾을 수 없습니다: " + accountId); + } + + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("YouTube 채널 업데이트 실패", e); + } + + } + +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeClientFactory.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeClientFactory.java new file mode 100644 index 0000000..cb03276 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeClientFactory.java @@ -0,0 +1,40 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.youtube.YouTube; +import lombok.RequiredArgsConstructor; + +import com.google.api.services.youtubeAnalytics.v2.YouTubeAnalytics; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +@Component +@RequiredArgsConstructor +public class YoutubeClientFactory { + + private final YoutubeCredentialProvider credentialProvider; + + public YouTube youtube(Long userId, Long storeId) throws IOException, GeneralSecurityException { + // SnsTokenStore에서 OAuth 인증 완료된 Credential을 가져옵니다. + var credential = credentialProvider.getCredential(userId, storeId); + + return new YouTube.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + credential + ).setApplicationName("sns-video-service").build(); + } + + public YouTubeAnalytics analytics(Long userId, Long storeId) throws IOException, GeneralSecurityException { + var credential = credentialProvider.getCredential(userId, storeId); + + return new YouTubeAnalytics.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + credential + ).setApplicationName("sns-analytics-service").build(); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeCredentialProvider.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeCredentialProvider.java new file mode 100644 index 0000000..6d1dfeb --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeCredentialProvider.java @@ -0,0 +1,61 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest; +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import kt.aivle.sns.application.service.youtube.YoutubeTokenService; +import kt.aivle.sns.config.YoutubeOAuthProperties; +import kt.aivle.sns.domain.model.SnsToken; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +@Component +@RequiredArgsConstructor +public class YoutubeCredentialProvider { + + private final YoutubeTokenService tokenService; + private final YoutubeOAuthProperties properties; + + public Credential getCredential(Long userId, Long storeId) throws IOException, GeneralSecurityException { + + SnsToken token = tokenService.getTokenOrThrow(userId, storeId); + + if (token.getExpiresAt() != null && token.getExpiresAt() <= System.currentTimeMillis() + 60_000) { + token = refresh(userId, storeId, token); + } + + return new GoogleCredential.Builder() + .setTransport(new NetHttpTransport()) + .setJsonFactory(GsonFactory.getDefaultInstance()) + .setClientSecrets(properties.getClientId(), properties.getClientSecret()) + .build() + .setAccessToken(token.getAccessToken()) + .setRefreshToken(token.getRefreshToken()) + .setExpirationTimeMilliseconds(token.getExpiresAt()); + } + + private SnsToken refresh(Long userId, Long storeId, SnsToken token) { + if (token.getRefreshToken() == null || token.getRefreshToken().isBlank()) { + throw new IllegalStateException("Refresh token이 없어 access token을 갱신할 수 없습니다."); + } + try { + var resp = new GoogleAuthorizationCodeTokenRequest( + new NetHttpTransport(), GsonFactory.getDefaultInstance(), + properties.getClientId(), properties.getClientSecret(), + token.getRefreshToken(), "" // refresh grant에는 redirect 불필요 + ).setGrantType("refresh_token").execute(); + + var newAccess = resp.getAccessToken(); + var expiresIn = resp.getExpiresInSeconds() == null ? 3600 : resp.getExpiresInSeconds(); + tokenService.saveToken(userId, storeId, newAccess, null, expiresIn); + return tokenService.getTokenOrThrow(userId, storeId); + } catch (Exception e) { + throw new RuntimeException("YouTube access token 갱신 실패", e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeSearchListApi.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeSearchListApi.java new file mode 100644 index 0000000..d954934 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeSearchListApi.java @@ -0,0 +1,35 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.services.youtube.YouTube; +import com.google.api.services.youtube.model.SearchListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +@Component +@RequiredArgsConstructor +public class YoutubeSearchListApi { + + private final YoutubeClientFactory youtubeClientFactory; + + public void getYoutubeMyVideoList(Long userId, Long storeId) { + + try { + // userId 기반으로 인증된 YouTube 객체 생성 + YouTube youtube = youtubeClientFactory.youtube(userId, storeId); + + YouTube.Search.List request = youtube.search() + .list("id,snippet"); + SearchListResponse response = request.setForMine(true) + .setMaxResults(25L) + .setType("video") + .execute(); + System.out.println(response); + + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoDeleteApi.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoDeleteApi.java new file mode 100644 index 0000000..b1d68b9 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoDeleteApi.java @@ -0,0 +1,44 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.services.youtube.YouTube; +import kt.aivle.sns.adapter.out.persistence.JpaPostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +@Component +@RequiredArgsConstructor +public class YoutubeVideoDeleteApi { + + private final YoutubeClientFactory youtubeClientFactory; + + private final JpaPostRepository jpaPostRepository; + + public void deleteVideo(Long userId, + Long storeId, + String postId) { + + try { + YouTube youTube = youtubeClientFactory.youtube(userId, storeId); + + // 삭제 요청 api 생성 + YouTube.Videos.Delete videoDelete = youTube.videos() + .delete(postId); + + // 삭제 실행 + videoDelete.execute(); + System.out.println("삭제됨"); + + // 게시물(DB)Post 삭제 + jpaPostRepository.findByPostId(postId) + .ifPresent(jpaPostRepository::delete); + + + + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("YouTube 업데이트 실패", e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoInsertApi.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoInsertApi.java new file mode 100644 index 0000000..2bd9007 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoInsertApi.java @@ -0,0 +1,144 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.client.googleapis.media.MediaHttpUploader; +import com.google.api.client.googleapis.media.MediaHttpUploaderProgressListener; +import com.google.api.client.http.InputStreamContent; +import com.google.api.client.util.DateTime; +import com.google.api.services.youtube.YouTube; +import com.google.api.services.youtube.model.Video; +import com.google.api.services.youtube.model.VideoSnippet; +import com.google.api.services.youtube.model.VideoStatus; +import kt.aivle.sns.adapter.out.persistence.JpaPostRepository; +import kt.aivle.sns.domain.model.PostEntity; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.OffsetDateTime; +import java.util.Arrays; + +@Component +@RequiredArgsConstructor +public class YoutubeVideoInsertApi { + + private final YoutubeClientFactory youtubeClientFactory; + + private final JpaPostRepository jpaPostRepository; + + public void uploadVideo(Long userId, + Long storeId, + String contentPath, + String title, + String description, + String[] tags, + String categoryId, + boolean notifySubscribers, + OffsetDateTime publishAt) { + try { + // userId 기반으로 인증된 YouTube 객체 생성 + YouTube youtube = youtubeClientFactory.youtube(userId, storeId); + + // 1. 동영상 메타데이터 생성 + Video videoMetadata = new Video(); + + // status 설정 + VideoStatus status = new VideoStatus(); + status.setPrivacyStatus("private"); // 예약공개는 반드시 private + if(publishAt != null) { + status.setPublishAt(new DateTime(publishAt.toInstant().toEpochMilli())); + } + videoMetadata.setStatus(status); + + // snippet 설정 + VideoSnippet snippet = new VideoSnippet(); + snippet.setTitle(title); + snippet.setDescription(description); + snippet.setTags(Arrays.asList(tags)); + snippet.setCategoryId(categoryId); + videoMetadata.setSnippet(snippet); + + // 2. 파일 읽기 및 업로드 준비 (테스트 : contentPath가 로컬) + InputStreamContent mediaContent = new InputStreamContent( + "video/*", new FileInputStream(contentPath)); + mediaContent.setLength(new java.io.File(contentPath).length()); + /* 2-1. contentPath가 S3 URL + InputStream inputStream = new URL(contentPath).openStream(); // IOException 처리 필요 + InputStreamContent mediaContent = new InputStreamContent( + "video/*", + new BufferedInputStream(inputStream) + ); + */ + + + // 3. 업로드 요청 + YouTube.Videos.Insert videoInsert = youtube.videos() + .insert("snippet,status", videoMetadata, mediaContent); + videoInsert.setNotifySubscribers(notifySubscribers); + + // 4. 업로드 진행 상황 출력 + MediaHttpUploader uploader = videoInsert.getMediaHttpUploader(); + uploader.setDirectUploadEnabled(false); // resumable upload + uploader.setProgressListener(new MediaHttpUploaderProgressListener() { + @Override + public void progressChanged(MediaHttpUploader uploader) throws IOException { + switch (uploader.getUploadState()) { + case INITIATION_STARTED: + System.out.println("업로드 시작..."); + break; + case MEDIA_IN_PROGRESS: + System.out.printf("업로드 중... %.2f%% 완료%n", uploader.getProgress() * 100); + break; + case MEDIA_COMPLETE: + System.out.println("업로드 완료!"); + break; + default: + break; + } + } + }); + + // 5. 업로드 실행 + Video uploadedVideo = videoInsert.execute(); + System.out.println("업로드된 비디오 ID: " + uploadedVideo.getId()); // youtube에 저장된 비디오 ID + + // 6. Post 저장 + String videoId = uploadedVideo.getId(); + PostEntity post = jpaPostRepository.findByPostId(videoId) + .map(existing -> { + existing = PostEntity.builder() + .id(existing.getId()) + .userId(userId) + .snsType(SnsType.youtube) + .postId(videoId) + .title(title) + .description(description) + .contentPath(contentPath) + .tags(Arrays.asList(tags)) + .categoryId(categoryId) + .notifySubscribers(notifySubscribers) + .publishAt(publishAt) + .build(); + return existing; + }) + .orElse(PostEntity.builder() + .userId(userId) + .snsType(SnsType.youtube) + .postId(videoId) + .title(title) + .description(description) + .contentPath(contentPath) + .tags(Arrays.asList(tags)) + .categoryId(categoryId) + .notifySubscribers(notifySubscribers) + .publishAt(publishAt) + .build()); + jpaPostRepository.save(post); + + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("YouTube 업로드 실패", e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoUpdateApi.java b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoUpdateApi.java new file mode 100644 index 0000000..d18429a --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/adapter/out/youtube/YoutubeVideoUpdateApi.java @@ -0,0 +1,85 @@ +package kt.aivle.sns.adapter.out.youtube; + +import com.google.api.services.youtube.YouTube; +import com.google.api.services.youtube.model.Video; +import com.google.api.services.youtube.model.VideoSnippet; +import com.google.api.services.youtube.model.VideoStatus; +import kt.aivle.sns.adapter.out.persistence.JpaPostRepository; +import kt.aivle.sns.domain.model.PostEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class YoutubeVideoUpdateApi { + + private final YoutubeClientFactory youtubeClientFactory; + + private final JpaPostRepository jpaPostRepository; + + public void updateVideo(Long userId, + Long storeId, + String postId, + String title, + String description, + String[] tags, + String categoryId) { + + try { + // userId 기반으로 인증된 YouTube 객체 생성 + YouTube youtube = youtubeClientFactory.youtube(userId, storeId); + + // 1. 동영상 메타데이터 생성 + Video videoMetadata = new Video(); + + // 동영상 지정 + videoMetadata.setId(postId); + + // status 설정 + VideoStatus status = new VideoStatus(); + videoMetadata.setStatus(status); + + // snippet 설정 + VideoSnippet snippet = new VideoSnippet(); + snippet.setTitle(title); + snippet.setDescription(description); + snippet.setTags(Arrays.asList(tags)); + snippet.setCategoryId(categoryId); + videoMetadata.setSnippet(snippet); + + // 3. 업데이트 요청 api 생성 + YouTube.Videos.Update videoUpdate = youtube.videos() + .update("snippet,status,localizations", videoMetadata); + + // 4. 업로드 실행 + Video updatedVideo = videoUpdate.execute(); + System.out.println("업데이트된 비디오 ID: " + updatedVideo.getId()); // youtube에 저장된 비디오 ID + + // 5. 게시물(DB)Post 저장 + String videoId = updatedVideo.getId(); + Optional optionalPost = jpaPostRepository.findByPostId(videoId); + if(optionalPost.isPresent()) { + PostEntity post = optionalPost.get(); + post.setTitle(title); + post.setDescription(description); + post.setTags(new ArrayList<>(Arrays.asList(tags))); + post.setCategoryId(categoryId); + + jpaPostRepository.save(post); + } else { + System.err.println("해당 videoId를 가진 게시물을 찾을 수 없습니다: " + videoId); + } + + + + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("YouTube 업데이트 실패", e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/port/in/AiPostUseCase.java b/sns-service/src/main/java/kt/aivle/sns/application/port/in/AiPostUseCase.java new file mode 100644 index 0000000..b14dbe8 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/port/in/AiPostUseCase.java @@ -0,0 +1,13 @@ +package kt.aivle.sns.application.port.in; + +import kt.aivle.sns.adapter.in.web.dto.request.CreateHashtagRequest; +import kt.aivle.sns.adapter.in.web.dto.request.CreatePostRequest; +import kt.aivle.sns.adapter.in.web.dto.response.CreateHashtagResponse; +import kt.aivle.sns.adapter.in.web.dto.response.CreatePostResponse; + +public interface AiPostUseCase { + + CreatePostResponse createPost(CreatePostRequest request); + + CreateHashtagResponse createHashtags(CreateHashtagRequest request); +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsAccountUseCase.java b/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsAccountUseCase.java new file mode 100644 index 0000000..e488bb3 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsAccountUseCase.java @@ -0,0 +1,15 @@ +package kt.aivle.sns.application.port.in; + +import kt.aivle.sns.adapter.in.web.dto.SnsAccountResponse; +import kt.aivle.sns.adapter.in.web.dto.SnsAccountUpdateRequest; +import kt.aivle.sns.domain.model.SnsType; + +public interface SnsAccountUseCase { + SnsType supportSnsType(); + + SnsAccountResponse getSnsAccountInfo(Long userId, Long storeId); + + void updateSnsAccount(Long userId, SnsAccountUpdateRequest request); + + void getPostList(Long userId, Long storeId); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsOAuthUseCase.java b/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsOAuthUseCase.java new file mode 100644 index 0000000..18cc768 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsOAuthUseCase.java @@ -0,0 +1,17 @@ +package kt.aivle.sns.application.port.in; + +import kt.aivle.sns.domain.model.SnsType; + +public interface SnsOAuthUseCase { + SnsType supportSnsType(); + + /** + * 인증 URL 생성 + */ + String getAuthUrl(Long userId, Long storeId); + + /** + * callback code 처리 및 토큰 저장 + */ + void handleCallback(String state, String code) throws Exception; +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsPostUseCase.java b/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsPostUseCase.java new file mode 100644 index 0000000..2e874f8 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/port/in/SnsPostUseCase.java @@ -0,0 +1,25 @@ +package kt.aivle.sns.application.port.in; + +import kt.aivle.sns.adapter.in.web.dto.PostDeleteRequest; +import kt.aivle.sns.adapter.in.web.dto.PostUpdateRequest; +import kt.aivle.sns.domain.model.SnsType; +import kt.aivle.sns.adapter.in.web.dto.PostUploadRequest; + +public interface SnsPostUseCase { + SnsType supportSnsType(); + + /** + * post upload + */ + void upload(Long userId, PostUploadRequest request); + + /** + * post update + */ + void update(Long userId, PostUpdateRequest request); + + /** + * post delete + */ + void delete(Long userId, PostDeleteRequest request); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/port/out/SnsAccountRepositoryPort.java b/sns-service/src/main/java/kt/aivle/sns/application/port/out/SnsAccountRepositoryPort.java new file mode 100644 index 0000000..d3a72eb --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/port/out/SnsAccountRepositoryPort.java @@ -0,0 +1,14 @@ +package kt.aivle.sns.application.port.out; + +import kt.aivle.sns.domain.model.SnsAccount; +import kt.aivle.sns.domain.model.SnsType; + +import java.util.Optional; + +public interface SnsAccountRepositoryPort { + SnsAccount save(SnsAccount snsAccount); + + Optional findByUserIdAndSnsType(Long userId, SnsType snsType); + + Optional findBySnsAccountId(String snsAccountId); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/port/out/SnsTokenRepositoryPort.java b/sns-service/src/main/java/kt/aivle/sns/application/port/out/SnsTokenRepositoryPort.java new file mode 100644 index 0000000..34cbd7c --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/port/out/SnsTokenRepositoryPort.java @@ -0,0 +1,18 @@ +package kt.aivle.sns.application.port.out; + +import kt.aivle.sns.domain.model.SnsToken; +import kt.aivle.sns.domain.model.SnsType; + +import java.util.Optional; + +public interface SnsTokenRepositoryPort { + SnsToken save(SnsToken snsToken); + + Optional findById(Long id); + + Optional findByUserId(Long userId); + + Optional findByUserIdAndSnsType(Long userId, SnsType snsType); + + Optional findByUserIdAndStoreIdAndSnsType(Long userId, Long storeId, SnsType snsType); +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/AiPostService.java b/sns-service/src/main/java/kt/aivle/sns/application/service/AiPostService.java new file mode 100644 index 0000000..9c44d03 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/AiPostService.java @@ -0,0 +1,27 @@ +package kt.aivle.sns.application.service; + +import kt.aivle.sns.adapter.in.web.dto.request.CreateHashtagRequest; +import kt.aivle.sns.adapter.in.web.dto.request.CreatePostRequest; +import kt.aivle.sns.adapter.in.web.dto.response.CreateHashtagResponse; +import kt.aivle.sns.adapter.in.web.dto.response.CreatePostResponse; +import kt.aivle.sns.adapter.out.web.FastApiClient; +import kt.aivle.sns.application.port.in.AiPostUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AiPostService implements AiPostUseCase { + + private final FastApiClient fastApiClient; + + @Override + public CreatePostResponse createPost(CreatePostRequest request) { + return fastApiClient.createPost(request); + } + + @Override + public CreateHashtagResponse createHashtags(CreateHashtagRequest request) { + return fastApiClient.createHashtags(request); + } +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/SnsAccountDelegator.java b/sns-service/src/main/java/kt/aivle/sns/application/service/SnsAccountDelegator.java new file mode 100644 index 0000000..58abaa5 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/SnsAccountDelegator.java @@ -0,0 +1,36 @@ +package kt.aivle.sns.application.service; + +import kt.aivle.sns.adapter.in.web.dto.SnsAccountResponse; +import kt.aivle.sns.application.port.in.SnsAccountUseCase; +import kt.aivle.sns.adapter.in.web.dto.SnsAccountUpdateRequest; +import kt.aivle.sns.domain.model.SnsType; +import org.springframework.stereotype.Service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Service +public class SnsAccountDelegator { + + private final Map snsAccountServiceMap; + + public SnsAccountDelegator(List services) { + this.snsAccountServiceMap = new EnumMap<>(SnsType.class); + for(SnsAccountUseCase service : services) { + snsAccountServiceMap.put(service.supportSnsType(), service); + } + } + + public SnsAccountResponse getAccountInfo(SnsType type, Long userId, Long storeId) { + return snsAccountServiceMap.get(type).getSnsAccountInfo(userId, storeId); + } + + public void updateAccount(SnsType type, Long userId, SnsAccountUpdateRequest request) { + snsAccountServiceMap.get(type).updateSnsAccount(userId, request); + } + + public void getPostList(SnsType type, Long userId, Long storeId) { + snsAccountServiceMap.get(type).getPostList(userId, storeId); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/SnsOAuthDelegator.java b/sns-service/src/main/java/kt/aivle/sns/application/service/SnsOAuthDelegator.java new file mode 100644 index 0000000..1a32795 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/SnsOAuthDelegator.java @@ -0,0 +1,30 @@ +package kt.aivle.sns.application.service; + +import kt.aivle.sns.application.port.in.SnsOAuthUseCase; +import kt.aivle.sns.domain.model.SnsType; +import org.springframework.stereotype.Service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Service +public class SnsOAuthDelegator { + + private final Map serviceMap; + + public SnsOAuthDelegator(List services) { + this.serviceMap = new EnumMap<>(SnsType.class); + for(SnsOAuthUseCase service : services) { + serviceMap.put(service.supportSnsType(), service); + } + } + + public String getAuthUrl(SnsType type, Long userId, Long storeId) { + return serviceMap.get(type).getAuthUrl(userId, storeId); + } + + public void handleCallback(SnsType type, String state, String code) throws Exception { + serviceMap.get(type).handleCallback(state, code); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/SnsPostDelegator.java b/sns-service/src/main/java/kt/aivle/sns/application/service/SnsPostDelegator.java new file mode 100644 index 0000000..0e44442 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/SnsPostDelegator.java @@ -0,0 +1,37 @@ +package kt.aivle.sns.application.service; + +import kt.aivle.sns.application.port.in.SnsPostUseCase; +import kt.aivle.sns.adapter.in.web.dto.PostDeleteRequest; +import kt.aivle.sns.domain.model.SnsType; +import kt.aivle.sns.adapter.in.web.dto.PostUpdateRequest; +import kt.aivle.sns.adapter.in.web.dto.PostUploadRequest; +import org.springframework.stereotype.Service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Service +public class SnsPostDelegator { + + private final Map snsPostServiceMap; + + public SnsPostDelegator(List services) { + this.snsPostServiceMap = new EnumMap<>(SnsType.class); + for(SnsPostUseCase service : services) { + snsPostServiceMap.put(service.supportSnsType(), service); + } + } + + public void upload(SnsType type, Long userId, PostUploadRequest request) { + snsPostServiceMap.get(type).upload(userId, request); + } + + public void update(SnsType type, Long userId, PostUpdateRequest request) { + snsPostServiceMap.get(type).update(userId, request); + } + + public void delete(SnsType type, Long userId, PostDeleteRequest request) { + snsPostServiceMap.get(type).delete(userId, request); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/oauth/OAuthStateService.java b/sns-service/src/main/java/kt/aivle/sns/application/service/oauth/OAuthStateService.java new file mode 100644 index 0000000..424b758 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/oauth/OAuthStateService.java @@ -0,0 +1,39 @@ +package kt.aivle.sns.application.service.oauth; + +import kt.aivle.sns.adapter.out.persistence.JpaOAuthStateRepository; +import kt.aivle.sns.adapter.out.persistence.OAuthStateEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuthStateService { + + private final JpaOAuthStateRepository repository; + + public String issue(Long userId, Long storeId) { + String raw = userId + ":" + storeId + ":" + UUID.randomUUID(); + String state = Base64.getUrlEncoder().withoutPadding() + .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + repository.save(OAuthStateEntity.builder() + .state(state).userId(userId).storeId(storeId) + .expiresAt(Instant.now().plusSeconds(600)).build()); + return state; + } + + public Pair consume(String state) { + OAuthStateEntity e = repository.findById(state).orElseThrow(() -> new IllegalStateException("invalid state")); + if(e.getExpiresAt().isBefore(Instant.now())) { + repository.deleteById(state); + throw new IllegalStateException("state expired"); + } + repository.deleteById(state); + return Pair.of(e.getUserId(), e.getStoreId()); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeChannelService.java b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeChannelService.java new file mode 100644 index 0000000..dc4fb1f --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeChannelService.java @@ -0,0 +1,49 @@ +package kt.aivle.sns.application.service.youtube; + +import kt.aivle.sns.adapter.in.web.dto.SnsAccountResponse; +import kt.aivle.sns.adapter.out.youtube.YoutubeChannelListApi; +import kt.aivle.sns.adapter.out.youtube.YoutubeChannelUpdateApi; +import kt.aivle.sns.adapter.out.youtube.YoutubeSearchListApi; +import kt.aivle.sns.application.port.in.SnsAccountUseCase; +import kt.aivle.sns.adapter.in.web.dto.SnsAccountUpdateRequest; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class YoutubeChannelService implements SnsAccountUseCase { + + private final YoutubeChannelListApi youtubeChannelListApi; + private final YoutubeChannelUpdateApi youtubeChannelUpdateApi; + private final YoutubeSearchListApi youtubeSearchListApi; + + @Override + public SnsType supportSnsType() { + return SnsType.youtube; + } + + @Override + public SnsAccountResponse getSnsAccountInfo(Long userId, Long storeId) { + + return youtubeChannelListApi.getYoutubeChannelInfo(userId, storeId); + + } + + @Override + public void updateSnsAccount(Long userId, SnsAccountUpdateRequest request) { + + youtubeChannelUpdateApi.updateAccount( + userId, + request.getStoreId(), + request.getSnsAccountId(), + request.getSnsAccountDescription(), + request.getKeywords() + ); + } + + @Override + public void getPostList(Long userId, Long storeId) { + youtubeSearchListApi.getYoutubeMyVideoList(userId, storeId); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeOAuthService.java b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeOAuthService.java new file mode 100644 index 0000000..175bbb5 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeOAuthService.java @@ -0,0 +1,90 @@ +package kt.aivle.sns.application.service.youtube; + +import com.google.api.client.auth.oauth2.TokenResponse; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import kt.aivle.sns.application.port.in.SnsOAuthUseCase; +import kt.aivle.sns.application.service.oauth.OAuthStateService; +import kt.aivle.sns.config.YoutubeOAuthProperties; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class YoutubeOAuthService implements SnsOAuthUseCase { + private static final List SCOPES = List.of( + // video.insert + "https://www.googleapis.com/auth/youtube.upload", + // video.update, video.delete, channel.update + "https://www.googleapis.com/auth/youtube.force-ssl", + // channel.list, youtubeAnalyticsService.reports + "https://www.googleapis.com/auth/youtube.readonly"); + + private final YoutubeOAuthProperties properties; + + private static final GsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + + private final YoutubeTokenService youtubeTokenService; + + private final OAuthStateService stateService; + + @Override + public SnsType supportSnsType() { + return SnsType.youtube; + } + + @Override + public String getAuthUrl(Long userId, Long storeId) { + try { + NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder( + httpTransport, + JSON_FACTORY, + properties.getClientId(), + properties.getClientSecret(), + SCOPES + ).setAccessType("offline").build(); + + String state = stateService.issue(userId, storeId); + return flow.newAuthorizationUrl() + .setRedirectUri(properties.getRedirectUri()) + .setState(state) + .set("prompt", "consent") // refresh token 새로 발급 받게 + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create Youtube auth URL", e); + } + } + + @Override + public void handleCallback(String state, String code) throws Exception { + NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + var ids = stateService.consume(state); + Long userId = ids.getFirst(); + Long storeId = ids.getSecond(); + + TokenResponse tokens = new GoogleAuthorizationCodeTokenRequest( + httpTransport, + JSON_FACTORY, + properties.getTokenUri(), + properties.getClientId(), + properties.getClientSecret(), + code, + properties.getRedirectUri() + ).execute(); + + youtubeTokenService.saveToken( + userId,storeId, + tokens.getAccessToken(), + tokens.getRefreshToken(), + tokens.getExpiresInSeconds()); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeSnsPostService.java b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeSnsPostService.java new file mode 100644 index 0000000..3b3423b --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeSnsPostService.java @@ -0,0 +1,84 @@ +package kt.aivle.sns.application.service.youtube; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kt.aivle.sns.adapter.in.web.dto.*; +import kt.aivle.sns.adapter.out.youtube.YoutubeVideoDeleteApi; +import kt.aivle.sns.adapter.out.youtube.YoutubeVideoInsertApi; +import kt.aivle.sns.adapter.out.youtube.YoutubeVideoUpdateApi; +import kt.aivle.sns.application.port.in.SnsPostUseCase; +import kt.aivle.sns.domain.model.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class YoutubeSnsPostService implements SnsPostUseCase { + + private final YoutubeVideoInsertApi youtubeVideoInsertApi; + private final YoutubeVideoUpdateApi youtubeVideoUpdateApi; + private final YoutubeVideoDeleteApi youtubeVideoDeleteApi; + + private final ObjectMapper objectMapper; + + @Override + public SnsType supportSnsType() { + return SnsType.youtube; + } + + @Override + public void upload(Long userId, PostUploadRequest request) { + +// System.out.println("ObjectMapper: " + objectMapper); + YoutubeUploadDetail detail = objectMapper.convertValue(request.getDetail(), YoutubeUploadDetail.class); + + try { + youtubeVideoInsertApi.uploadVideo( + userId, + request.getStoreId(), + request.getContentPath(), + request.getTitle(), + request.getDescription(), + request.getTags(), + detail.getCategoryId(), + detail.isNotifySubscribers(), + detail.getPublishAt() + ); + } catch (Exception e) { + throw new RuntimeException("YouTube 업로드 실패", e); + } + } + + @Override + public void update(Long userId, PostUpdateRequest request) { + + YoutubeUpdateDetail detail = objectMapper.convertValue(request.getDetail(), YoutubeUpdateDetail.class); + + try { + youtubeVideoUpdateApi.updateVideo( + userId, + request.getStoreId(), + request.getPostId(), + request.getTitle(), + request.getDescription(), + request.getTags(), + detail.getCategoryId() + ); + } catch (Exception e) { + throw new RuntimeException("YouTube 업데이트 실패", e); + } + } + + @Override + public void delete(Long userId, PostDeleteRequest request) { + + try { + youtubeVideoDeleteApi.deleteVideo( + userId, + request.getStoreId(), + request.getPostId() + ); + } catch (Exception e) { + throw new RuntimeException("YouTube 딜리트 실패", e); + } + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeTokenService.java b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeTokenService.java new file mode 100644 index 0000000..3c45e3f --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/application/service/youtube/YoutubeTokenService.java @@ -0,0 +1,50 @@ +package kt.aivle.sns.application.service.youtube; + +import kt.aivle.sns.application.port.out.SnsTokenRepositoryPort; +import kt.aivle.sns.domain.model.SnsToken; +import kt.aivle.sns.domain.model.SnsType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class YoutubeTokenService { + private final SnsTokenRepositoryPort snsTokenRepositoryPort; + + public void saveToken(Long userId, + Long storeId, + String accessToken, + String refreshToken, + Long expiresInSeconds) { + long expiresAt = System.currentTimeMillis() + expiresInSeconds * 1000L; + + SnsToken token = snsTokenRepositoryPort.findByUserIdAndSnsType(userId, SnsType.youtube) + .map(existing -> { + existing = SnsToken.builder() + .id(existing.getId()) + .userId(userId) + .storeId(storeId) + .snsType(SnsType.youtube) + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresAt(expiresAt) + .build(); + return existing; + }) + .orElse(SnsToken.builder() + .userId(userId) + .storeId(storeId) + .snsType(SnsType.youtube) + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresAt(expiresAt) + .build()); + + snsTokenRepositoryPort.save(token); + } + + public SnsToken getTokenOrThrow(Long userId, Long storeId) { + return snsTokenRepositoryPort.findByUserIdAndStoreIdAndSnsType(userId, storeId, SnsType.youtube) + .orElseThrow(() -> new IllegalStateException("연동된 Youtube 토큰이 없습니다.")); + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/config/WebClientConfig.java b/sns-service/src/main/java/kt/aivle/sns/config/WebClientConfig.java new file mode 100644 index 0000000..7a9f8db --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/config/WebClientConfig.java @@ -0,0 +1,19 @@ +package kt.aivle.sns.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient fastApiWebClient( + @Value("${fastapi.base-url}") String baseUrl + ) { + return WebClient.builder() + .baseUrl(baseUrl) + .build(); + } +} \ No newline at end of file diff --git a/sns-service/src/main/java/kt/aivle/sns/config/YoutubeOAuthProperties.java b/sns-service/src/main/java/kt/aivle/sns/config/YoutubeOAuthProperties.java new file mode 100644 index 0000000..28f0e1d --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/config/YoutubeOAuthProperties.java @@ -0,0 +1,34 @@ +package kt.aivle.sns.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "youtube.oauth") +public class YoutubeOAuthProperties { + private String clientId; + private String clientSecret; + private String redirectUri; + private String authUri; + private String tokenUri; + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public void setAuthUri(String authUri) { + this.authUri = authUri; + } + + public void setTokenUri(String tokenUri) { + this.tokenUri = tokenUri; + } +} diff --git a/sns-service/src/main/java/kt/aivle/sns/domain/model/PostEntity.java b/sns-service/src/main/java/kt/aivle/sns/domain/model/PostEntity.java new file mode 100644 index 0000000..cf09522 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/domain/model/PostEntity.java @@ -0,0 +1,59 @@ +package kt.aivle.sns.domain.model; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + +@Entity +@Table(name = "posts") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class PostEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + @Enumerated(EnumType.STRING) + private SnsType snsType; + + private String postId; // 유튜브 videoId + + private String title; + + private String description; + + private String contentPath; // S3경로 + + @ElementCollection + @CollectionTable(name = "post_tags", joinColumns = @JoinColumn(name = "post_id")) + @Column(name = "tag") + private List tags; // (유튜브에선 사용자에게만 보임 알고리즘을 위한 태그) + // tags를 별도 테이블로 분리하여 @ElementCollection으로 저장 (단순 문자열 리스트일 경우 유용) + + private String categoryId; + + private OffsetDateTime publishAt; + + private Boolean notifySubscribers; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + +} diff --git a/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsAccount.java b/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsAccount.java new file mode 100644 index 0000000..8e49ecd --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsAccount.java @@ -0,0 +1,47 @@ +package kt.aivle.sns.domain.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SnsAccount { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; // 사용자 id + + private Long storeId; + + @Enumerated(EnumType.STRING) + private SnsType snsType; // sns 타입 + + private String snsAccountId; // 유튜브 채널 id + + private String snsAccountName; // 채널명 + + private String snsAccountDescription; // 채널 설명 + + private String snsAccountUrl; // 사용자 채널 url ("https://www.youtube.com/" + url) + + private Integer follower; // 구독자 수 + + private Integer postCount; // 업로드 동영상 수 + + private Integer viewCount; // 전체 동영상 조회수 + + @ElementCollection + @CollectionTable(name = "account_keywords", joinColumns = @JoinColumn(name = "sns_account_id")) + @Column(name = "keyword") + private List keywords; // (유튜브에선 사용자에게만 보임 알고리즘을 위한 키워드) + + +} diff --git a/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsToken.java b/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsToken.java new file mode 100644 index 0000000..3528dd4 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsToken.java @@ -0,0 +1,33 @@ +package kt.aivle.sns.domain.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(uniqueConstraints = @UniqueConstraint(columnNames={"user_id","store_id","sns_type"})) +public class SnsToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + private Long storeId; + + @Enumerated(EnumType.STRING) + private SnsType snsType; + + @Lob + private String accessToken; + + @Lob + private String refreshToken; + + private Long expiresAt; +} diff --git a/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsType.java b/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsType.java new file mode 100644 index 0000000..9704769 --- /dev/null +++ b/sns-service/src/main/java/kt/aivle/sns/domain/model/SnsType.java @@ -0,0 +1,7 @@ +package kt.aivle.sns.domain.model; + +public enum SnsType { + youtube; +// facebook, +// instagram; +}