Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ public ArticleImage() {
public void updateArticle(Article article) {
this.article = article;
}

public void updateImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
import java.util.List;
import java.util.Objects;
import java.util.UUID;

import static site.codemonster.comon.global.images.enums.ImageConstant.DEFAULT_MEMBER_PROFILE_IMAGE_URL;
import site.codemonster.comon.global.images.enums.ImageConstant;

@Entity
@Getter
Expand All @@ -33,7 +32,7 @@ public class Member extends TimeStamp {

private String memberName;

private String imageUrl = DEFAULT_MEMBER_PROFILE_IMAGE_URL;
private String imageUrl = ImageConstant.DEFAULT_MEMBER_PROFILE.getObjectKey();

private String description;

Expand Down Expand Up @@ -76,6 +75,10 @@ public void updateProfile(String memberName, String description, String imageUrl
}
}

public void updateImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}

public boolean isMyUuid(String uuid){
return this.uuid.equals(uuid);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package site.codemonster.comon.domain.recommendation.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -12,7 +11,7 @@

import java.util.List;
import java.util.stream.Collectors;
import site.codemonster.comon.global.util.convertUtils.ConvertUtils;
import site.codemonster.comon.global.util.convertUtils.JsonListConvertUtils;

@Slf4j
@Service
Expand All @@ -21,7 +20,7 @@
public class PlatformRecommendationService {

private final PlatformRecommendationRepository platformRecommendationRepository;
private final ConvertUtils convertUtils;
private final JsonListConvertUtils convertUtils;

public PlatformRecommendation createPlatformRecommendation(TeamRecommendationRequest.PlatformRecommendationSetting setting,
TeamRecommendation teamRecommendation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import java.util.*;
import java.util.stream.Collectors;
import site.codemonster.comon.global.error.recommendation.TeamRecommendationNotFoundException;
import site.codemonster.comon.global.util.convertUtils.ConvertUtils;
import site.codemonster.comon.global.util.convertUtils.JsonListConvertUtils;
import site.codemonster.comon.global.util.responseUtils.ResponseUtils;

@Slf4j
Expand All @@ -43,7 +43,7 @@ public class TeamRecommendationService {
private final MemberService memberService;
private final ProblemService problemService;
private final ObjectMapper objectMapper;
private final ConvertUtils convertUtils;
private final JsonListConvertUtils convertUtils;

@Value("${app.system-admin-id:1}")
private Long adminId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@

import java.util.ArrayList;
import java.util.List;

import static site.codemonster.comon.global.images.enums.ImageConstant.DEFAULT_TEAM_IMAGE_URL;
import site.codemonster.comon.global.images.enums.ImageConstant;

@Entity
@Getter
Expand All @@ -23,7 +22,7 @@ public class Team extends TimeStamp {

private String teamName;

private String teamIconUrl = DEFAULT_TEAM_IMAGE_URL;
private String teamIconUrl = ImageConstant.DEFAULT_TEAM.getObjectKey();

private Topic teamTopic;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class TeamRecruit extends TimeStamp {

private String teamRecruitTitle;

@Column(columnDefinition = "TEXT")
private String teamRecruitBody;

private String chatUrl;
Expand Down Expand Up @@ -92,4 +93,4 @@ public void addTeam(Team team){
this.team = team;
team.addTeamRecruit(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ public TeamRecruitImage(String imageUrl, TeamRecruit teamRecruit){
public void updateTeamRecruit(TeamRecruit teamRecruit){
this.teamRecruit = teamRecruit;
}
}

public void updateImageUrl(String imageUrl){
this.imageUrl = imageUrl;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
package site.codemonster.comon.global.images.enums;

public class ImageConstant {
public static final String DEFAULT_MEMBER_PROFILE_IMAGE_URL = "https://pnu-comon-s3-bucket.s3.ap-northeast-2.amazonaws.com/profile/default-image.png";
public static final String DEFAULT_TEAM_IMAGE_URL = "https://pnu-comon-s3-bucket.s3.ap-northeast-2.amazonaws.com/team/default-image.png";
public enum ImageConstant {
DEFAULT_MEMBER_PROFILE("profile/default-image.png"),
DEFAULT_TEAM("team/default-image.png");

private final String objectKey;

ImageConstant(String objectKey) {
this.objectKey = objectKey;
}

public String getObjectKey() {
return objectKey;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package site.codemonster.comon.global.images.migration;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import site.codemonster.comon.domain.article.entity.Article;
import site.codemonster.comon.domain.article.entity.ArticleImage;
import site.codemonster.comon.domain.article.repository.ArticleImageRepository;

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
@Order(2)
public class ArticleBodyImageMigration implements CommandLineRunner {

private final ArticleImageRepository articleImageRepository;

@Value("${cloud.aws.bucket}")
private String s3Bucket;

@Value("${cloud.aws.region}")
private String s3Region;

@Transactional
@Override
public void run(String... args) {
String entityType = "게시글 본문 이미지";
log.info("🚀 {} 마이그레이션 시작", entityType);

List<ArticleImage> articleImages = articleImageRepository.findAll();
log.info("📊 마이그레이션 대상 {} 개수: {}", entityType, articleImages.size());

int placeholderReplacedCount = 0; // 경우 1 : ? 치환
int oldBucketReplacedCount = 0; // 경우 2 : 구버전 버킷 URL 치환
int alreadyUpdatedCount = 0; // 경우 3 : 이미 업데이트됨
int orphanedImageCount = 0; // 경우 4 : 사용되지 않는 이미지

for (ArticleImage articleImage : articleImages) {
Article article = articleImage.getArticle();
String originalBody = article.getArticleBody();
String imageObjectKey = articleImage.getImageUrl();
String fullImageUrl = String.format("https://%s.s3.%s.amazonaws.com/%s", s3Bucket, s3Region, imageObjectKey);
String updatedBody = originalBody;

if (originalBody.contains("img src=\"?\"") || originalBody.contains("img src=\"\"")) {
// 경우 1: img src="?" 또는 img src=""인 경우
updatedBody = originalBody.replaceFirst("img src=\"(\\?|\")\"", "img src=\"" + fullImageUrl + "\"");
article.updateArticle(article.getArticleTitle(), updatedBody);
placeholderReplacedCount++;
log.info("🔄 임시 이미지 태그 치환 완료 - 게시글 ID: {}, 이미지 ID: {}",
article.getArticleId(), articleImage.getArticleImageId());

} else if (originalBody.matches(".*https://[^/]+\\.s3\\.[^/]+\\.amazonaws\\.com/.*") &&
!originalBody.contains(s3Bucket)) {
// 경우 2: 현재 버킷이 아닌 모든 S3 URL (구버킷으로 간주)
updatedBody = originalBody.replaceAll(
"https://[^/]+\\.s3\\.[^/]+\\.amazonaws\\.com/[^\"\\s<>]+",
fullImageUrl
);
article.updateArticle(article.getArticleTitle(), updatedBody);
oldBucketReplacedCount++;
log.info("🔄 구버전 버킷 URL 치환 완료 - 게시글 ID: {}, 이미지 ID: {}",
article.getArticleId(), articleImage.getArticleImageId());

} else if (originalBody.contains(fullImageUrl) || originalBody.contains(imageObjectKey)) {
// 경우 3: 이미 이미지 URL이 업데이트됨 (전체 URL이나 객체 키 둘 다 체크)
alreadyUpdatedCount++;
log.debug("✅ 이미 이미지 URL이 업데이트됨 - 게시글 ID: {}, 이미지 ID: {}",
article.getArticleId(), articleImage.getArticleImageId());

} else {
// 경우 4: 게시글에서 이미지가 삭제되었지만 articleImage 테이블에는 남아있음
orphanedImageCount++;
log.warn("⚠️ 본문에서 이미지를 찾을 수 없음 (고아 이미지) - 게시글 ID: {}, 이미지 ID: {}",
article.getArticleId(), articleImage.getArticleImageId());
}
}

logMigrationStats(entityType, articleImages.size(), placeholderReplacedCount,
oldBucketReplacedCount, alreadyUpdatedCount, orphanedImageCount);
}

private void logMigrationStats(String entityType, int totalCount, int placeholderReplacedCount,
int oldBucketReplacedCount, int alreadyUpdatedCount, int orphanedImageCount) {
log.info("🎉 {} 마이그레이션 완료", entityType);
log.info("📈 마이그레이션 통계 - 플레이스홀더 치환: {}개, 구버전 URL 치환: {}개, 이미 완료: {}개, 고아 이미지: {}개, 전체: {}개",
placeholderReplacedCount, oldBucketReplacedCount, alreadyUpdatedCount, orphanedImageCount, totalCount);

int successCount = placeholderReplacedCount + oldBucketReplacedCount;
log.info("✨ 총 {}개의 게시글 본문 이미지 치환 완료", successCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package site.codemonster.comon.global.images.migration;

import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import site.codemonster.comon.domain.article.entity.ArticleImage;
import site.codemonster.comon.domain.article.repository.ArticleImageRepository;

import java.util.List;

@Component
@RequiredArgsConstructor
@Order(1)
public class ArticleImageMigration extends BaseImageMigration<ArticleImage> {

private final ArticleImageRepository articleImageRepository;

@Override
protected String getEntityType() {
return "게시글";
}

@Override
protected List<ArticleImage> getAllEntities() {
return articleImageRepository.findAll();
}

@Override
protected String getCurrentImageUrl(ArticleImage entity) {
return entity.getImageUrl();
}

@Override
protected Object getEntityId(ArticleImage entity) {
return entity.getArticleImageId();
}

@Override
protected void updateImageUrl(ArticleImage entity, String objectKey) {
entity.updateImageUrl(objectKey);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package site.codemonster.comon.global.images.migration;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Slf4j
public abstract class BaseImageMigration<T> implements CommandLineRunner {

private static final Pattern S3_URL_PATTERN = Pattern.compile("https://[^/]+\\.s3\\.[^/]+\\.amazonaws\\.com/(.+)");

@Transactional
@Override
public void run(String... args) {
String entityType = getEntityType();
log.info("🚀 {} 이미지 URL 객체키만 가지도록 마이그레이션 시작", entityType);

List<T> entities = getAllEntities();
log.info("📊 마이그레이션 대상 {} 개수: {}", entityType, entities.size());

int migratedCount = 0;
int alreadyMigratedCount = 0;
int defaultImageCount = 0;

for (T entity : entities) {
String currentImageUrl = getCurrentImageUrl(entity);

if (hasDefaultImage() && isDefaultImage(currentImageUrl)) {
updateToDefaultObjectKey(entity);
defaultImageCount++;
continue;
}

if (isAlreadyObjectKey(currentImageUrl)) {
alreadyMigratedCount++;
continue;
}

String objectKey = extractObjectKeyFromS3ImageUrl(currentImageUrl);
if (objectKey != null) {
updateImageUrl(entity, objectKey);
migratedCount++;
log.info("🔄 URL 마이그레이션 완료 - {} ID: {}, {} -> {}",
entityType, getEntityId(entity), currentImageUrl, objectKey);
} else {
log.warn("⚠️ S3 URL 패턴이 아님 - {} ID: {}, URL: {}",
entityType, getEntityId(entity), currentImageUrl);
}
}

logMigrationStats(entityType, entities.size(), migratedCount, defaultImageCount, alreadyMigratedCount);
}

protected abstract String getEntityType();
protected abstract List<T> getAllEntities();
protected abstract String getCurrentImageUrl(T entity);
protected abstract Object getEntityId(T entity);
protected abstract void updateImageUrl(T entity, String objectKey);

protected boolean hasDefaultImage() {
return false;
}

protected boolean isDefaultImage(String imageUrl) {
return false;
}

protected void updateToDefaultObjectKey(T entity) {
}

protected String extractObjectKeyFromS3ImageUrl(String s3Url) {
if (s3Url == null || s3Url.trim().isEmpty()) {
return null;
}

Matcher matcher = S3_URL_PATTERN.matcher(s3Url.trim());
if (matcher.matches()) {
return matcher.group(1);
}

return null;
}

protected boolean isAlreadyObjectKey(String imageUrl) {
return imageUrl != null &&
!imageUrl.trim().isEmpty() &&
!imageUrl.startsWith("https://") &&
!imageUrl.startsWith("http://");
}

private void logMigrationStats(String entityType, int totalCount, int migratedCount,
int defaultImageCount, int alreadyMigratedCount) {
log.info("🎉 {} 이미지 URL 객체키만 가지도록 마이그레이션 완료", entityType);

if (defaultImageCount > 0) {
log.info("📈 마이그레이션 통계 - S3 URL 변경: {}개, 기본 이미지 변경: {}개, 이미 완료: {}개, 전체: {}개",
migratedCount, defaultImageCount, alreadyMigratedCount, totalCount);
} else {
log.info("📈 마이그레이션 통계 - 변경: {}개, 이미 완료: {}개, 전체: {}개",
migratedCount, alreadyMigratedCount, totalCount);
}
}
}
Loading