diff --git a/src/main/java/gigedi/dev/domain/auth/dao/FigmaRepository.java b/src/main/java/gigedi/dev/domain/auth/dao/FigmaRepository.java index adc6224..a41326f 100644 --- a/src/main/java/gigedi/dev/domain/auth/dao/FigmaRepository.java +++ b/src/main/java/gigedi/dev/domain/auth/dao/FigmaRepository.java @@ -4,9 +4,12 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import gigedi.dev.domain.auth.domain.Figma; +import gigedi.dev.domain.file.domain.File; import gigedi.dev.domain.member.domain.Member; @Repository @@ -19,5 +22,8 @@ public interface FigmaRepository extends JpaRepository { Optional findByFigmaUserIdAndDeletedAtIsNull(String figmaUserId); - Optional findByFigmaName(String name); + @Query( + "SELECT f FROM Figma f WHERE f.figmaName = :figmaName AND f.figmaId IN (SELECT a.figma.figmaId FROM Authority a WHERE a.file = :file)") + Optional findByFigmaNameAndFile( + @Param("figmaName") String figmaName, @Param("file") File file); } diff --git a/src/main/java/gigedi/dev/domain/discord/application/AlarmService.java b/src/main/java/gigedi/dev/domain/discord/application/AlarmService.java index cad8bf6..03685b8 100644 --- a/src/main/java/gigedi/dev/domain/discord/application/AlarmService.java +++ b/src/main/java/gigedi/dev/domain/discord/application/AlarmService.java @@ -7,14 +7,17 @@ import org.springframework.transaction.annotation.Transactional; import gigedi.dev.domain.auth.domain.Figma; +import gigedi.dev.domain.block.domain.Block; import gigedi.dev.domain.discord.domain.Discord; import gigedi.dev.domain.discord.dto.response.AlarmFileResponse; import gigedi.dev.domain.discord.dto.response.GetAlarmFileListResponse; import gigedi.dev.domain.figma.application.FigmaService; import gigedi.dev.domain.file.application.AuthorityService; import gigedi.dev.domain.file.domain.Authority; +import gigedi.dev.domain.file.domain.File; import gigedi.dev.domain.member.domain.Member; import gigedi.dev.global.util.MemberUtil; +import gigedi.dev.global.util.ShootUtil; import lombok.RequiredArgsConstructor; @Service @@ -23,6 +26,7 @@ public class AlarmService { private final DiscordService discordService; private final AuthorityService authorityService; private final FigmaService figmaService; + private final DiscordDmApiService discordDmApiService; private final MemberUtil memberUtil; public GetAlarmFileListResponse getAlarmFileList() { @@ -54,4 +58,28 @@ private Authority getAuthorityByFileId(Long fileId) { List figmaList = figmaService.getFigmaListByMember(currentMember); return authorityService.getAuthorityByFileIdAndFigmaList(fileId, figmaList); } + + public void sendAlarmToDiscord( + List tags, Block block, Figma senderFigma, String message) { + String sender = senderFigma.getFigmaName(); + String blockTitle = block.getTitle(); + String archiveTitle = block.getArchive().getTitle(); + File currentFile = block.getArchive().getFile(); + List receiverList = + authorityService.getAlarmTargetListByFigmaName(currentFile, tags); + + receiverList.forEach( + receiver -> { + String channelId = discordService.getDmChannelByMember(receiver.getMember()); + if (channelId != null) { + discordDmApiService.sendDMMessage( + channelId, + sender, + receiver.getFigmaName(), + ShootUtil.highlightText(archiveTitle), + ShootUtil.highlightText(blockTitle), + ShootUtil.highlightMentions(message)); + } + }); + } } diff --git a/src/main/java/gigedi/dev/domain/discord/application/DiscordDmApiService.java b/src/main/java/gigedi/dev/domain/discord/application/DiscordDmApiService.java index 82bfe0a..67c48bf 100644 --- a/src/main/java/gigedi/dev/domain/discord/application/DiscordDmApiService.java +++ b/src/main/java/gigedi/dev/domain/discord/application/DiscordDmApiService.java @@ -2,6 +2,9 @@ import static gigedi.dev.global.common.constants.SecurityConstants.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.HttpHeaders; @@ -23,6 +26,9 @@ public class DiscordDmApiService { private final RestClient restClient; private final DiscordProperties discordProperties; + private static final String DM_BASE_TITLE = "Please check it in OUR SHOOT !"; + private static final int DM_BASE_COLOR = 3447003; + public CreateDMChannelResponse createDMChannel(String userId) { try { Map requestBody = Map.of("recipient_id", userId); @@ -49,4 +55,64 @@ public CreateDMChannelResponse createDMChannel(String userId) { throw new CustomException(ErrorCode.DISCORD_DM_CHANNEL_CREATION_FAILED); } } + + public void sendDMMessage( + String channelId, + String sender, + String receiver, + String archiveTitle, + String blockTitle, + String content) { + try { + Map embed = new HashMap<>(); + embed.put("title", DM_BASE_TITLE); + embed.put("color", DM_BASE_COLOR); + + List> fields = new ArrayList<>(); + fields.add(createField("From", sender, true)); + fields.add(createField("To", receiver, true)); + fields.add( + createField("In", "ARCHIVE " + archiveTitle + " - BLOCK " + blockTitle, false)); + fields.add(createField("Content", content, false)); + + embed.put("fields", fields); + + Map requestBody = new HashMap<>(); + requestBody.put("embeds", List.of(embed)); + + restClient + .post() + .uri( + uriBuilder -> + uriBuilder + .scheme(HTTPS_SCHEME) + .host(DISCORD_HOST) + .path(DISCORD_SEND_DM_URL) + .build(channelId)) + .header( + HttpHeaders.AUTHORIZATION, + BOT_TOKEN_PREFIX + discordProperties.botToken()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(requestBody) + .retrieve() + .onStatus( + status -> !status.is2xxSuccessful(), + (request, response) -> { + log.error("Discord DM 메시지 전송 실패: {}", response.getStatusCode()); + throw new CustomException(ErrorCode.DISCORD_DM_MESSAGE_SEND_FAILED); + }) + .toBodilessEntity(); + } catch (Exception e) { + log.error("Discord DM 메시지 전송 중 예외 발생: {}", e.getMessage(), e); + throw new CustomException(ErrorCode.DISCORD_DM_MESSAGE_SEND_FAILED); + } + } + + private Map createField(String name, String value, boolean inline) { + Map field = new HashMap<>(); + field.put("name", name); + field.put("value", value); + field.put("inline", inline); + return field; + } } diff --git a/src/main/java/gigedi/dev/domain/discord/application/DiscordService.java b/src/main/java/gigedi/dev/domain/discord/application/DiscordService.java index b2274af..74f70e8 100644 --- a/src/main/java/gigedi/dev/domain/discord/application/DiscordService.java +++ b/src/main/java/gigedi/dev/domain/discord/application/DiscordService.java @@ -42,4 +42,9 @@ public void validateDiscordExistsForMember() { throw new CustomException(ErrorCode.DISCORD_ACCOUNT_ALREADY_EXISTS); } } + + @Transactional(readOnly = true) + public String getDmChannelByMember(Member member) { + return discordRepository.findByMember(member).map(Discord::getDmChannel).orElse(null); + } } diff --git a/src/main/java/gigedi/dev/domain/figma/application/FigmaService.java b/src/main/java/gigedi/dev/domain/figma/application/FigmaService.java index 5c80ef5..c7e4d1c 100644 --- a/src/main/java/gigedi/dev/domain/figma/application/FigmaService.java +++ b/src/main/java/gigedi/dev/domain/figma/application/FigmaService.java @@ -7,6 +7,7 @@ import gigedi.dev.domain.auth.dao.FigmaRepository; import gigedi.dev.domain.auth.domain.Figma; +import gigedi.dev.domain.file.domain.File; import gigedi.dev.domain.member.domain.Member; import gigedi.dev.global.error.exception.CustomException; import gigedi.dev.global.error.exception.ErrorCode; @@ -31,9 +32,9 @@ public Figma getFigmaByFigmaId(String figmaId) { .orElseThrow(() -> new CustomException(ErrorCode.FIGMA_NOT_CONNECTED)); } - public Figma findByTag(String tag) { + public Figma findByTag(String tag, File file) { return figmaRepository - .findByFigmaName(tag) + .findByFigmaNameAndFile(tag, file) .orElseThrow(() -> new CustomException(ErrorCode.FIGMA_USER_INFO_NOT_FOUND)); } diff --git a/src/main/java/gigedi/dev/domain/file/application/AuthorityService.java b/src/main/java/gigedi/dev/domain/file/application/AuthorityService.java index ee74b81..3de8214 100644 --- a/src/main/java/gigedi/dev/domain/file/application/AuthorityService.java +++ b/src/main/java/gigedi/dev/domain/file/application/AuthorityService.java @@ -8,6 +8,7 @@ import gigedi.dev.domain.auth.domain.Figma; import gigedi.dev.domain.file.dao.AuthorityRepository; import gigedi.dev.domain.file.domain.Authority; +import gigedi.dev.domain.file.domain.File; import gigedi.dev.global.error.exception.CustomException; import gigedi.dev.global.error.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -28,4 +29,9 @@ public Authority getAuthorityByFileIdAndFigmaList(Long fileId, List figma .findByFileAndActiveFigma(fileId, figmaList) .orElseThrow(() -> new CustomException(ErrorCode.AUTHORITY_NOT_FOUND)); } + + @Transactional(readOnly = true) + public List getAlarmTargetListByFigmaName(File file, List figmaNameList) { + return authorityRepository.getFigmaNamesWithActiveAlarmByFile(file, figmaNameList); + } } diff --git a/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryCustom.java b/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryCustom.java index 99c7f3d..b92266a 100644 --- a/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryCustom.java +++ b/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryCustom.java @@ -5,9 +5,12 @@ import gigedi.dev.domain.auth.domain.Figma; import gigedi.dev.domain.file.domain.Authority; +import gigedi.dev.domain.file.domain.File; public interface AuthorityRepositoryCustom { List findRelatedAuthorities(Long memberId); Optional findByFileAndActiveFigma(Long fileId, List figmaList); + + List getFigmaNamesWithActiveAlarmByFile(File file, List figmaNames); } diff --git a/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryImpl.java b/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryImpl.java index 107ac33..a5f2e13 100644 --- a/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryImpl.java +++ b/src/main/java/gigedi/dev/domain/file/dao/AuthorityRepositoryImpl.java @@ -11,6 +11,7 @@ import gigedi.dev.domain.auth.domain.Figma; import gigedi.dev.domain.file.domain.Authority; +import gigedi.dev.domain.file.domain.File; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -45,4 +46,18 @@ public Optional findByFileAndActiveFigma(Long fileId, List fig .and(authority.figma.deletedAt.isNull())) .fetchOne()); } + + @Override + public List getFigmaNamesWithActiveAlarmByFile(File file, List figmaNames) { + return queryFactory + .selectFrom(figma) + .join(authority) + .on(authority.figma.eq(figma)) + .where( + authority.file.eq(file), + figma.figmaName.in(figmaNames), + authority.alarm.isTrue(), + figma.deletedAt.isNull()) + .fetch(); + } } diff --git a/src/main/java/gigedi/dev/domain/shoot/application/ShootService.java b/src/main/java/gigedi/dev/domain/shoot/application/ShootService.java index 18d5164..eb96ffc 100644 --- a/src/main/java/gigedi/dev/domain/shoot/application/ShootService.java +++ b/src/main/java/gigedi/dev/domain/shoot/application/ShootService.java @@ -11,6 +11,8 @@ import gigedi.dev.domain.auth.domain.Figma; import gigedi.dev.domain.block.application.BlockService; import gigedi.dev.domain.block.domain.Block; +import gigedi.dev.domain.discord.application.AlarmService; +import gigedi.dev.domain.file.domain.File; import gigedi.dev.domain.shoot.dao.ShootRepository; import gigedi.dev.domain.shoot.dao.ShootStatusRepository; import gigedi.dev.domain.shoot.domain.Shoot; @@ -33,6 +35,7 @@ public class ShootService { private final FigmaUtil figmaUtil; private final BlockService blockService; private final ShootTagService shootTagService; + private final AlarmService alarmService; private static final String YET = "yet"; private static final String DOING = "doing"; @@ -66,14 +69,17 @@ public GetShootResponse createShoot(Long blockId, String content) { final Figma figma = figmaUtil.getCurrentFigma(); Shoot shoot = Shoot.createShoot(content, figma, block); shootRepository.save(shoot); - processTags(content, shoot); + List tags = processTags(content, shoot); + alarmService.sendAlarmToDiscord(tags, block, figma, content); return GetShootResponse.of(shoot, null, null, null); } - private void processTags(String content, Shoot shoot) { + private List processTags(String content, Shoot shoot) { + File currentFile = figmaUtil.getCurrentFile(); List tags = ShootUtil.extractTags(content); - shootTagService.createShootTags(shoot, tags); + shootTagService.createShootTags(shoot, tags, currentFile); + return tags; } @Transactional(readOnly = true) diff --git a/src/main/java/gigedi/dev/domain/shoot/application/ShootTagService.java b/src/main/java/gigedi/dev/domain/shoot/application/ShootTagService.java index d26b327..37d2e1c 100644 --- a/src/main/java/gigedi/dev/domain/shoot/application/ShootTagService.java +++ b/src/main/java/gigedi/dev/domain/shoot/application/ShootTagService.java @@ -6,6 +6,7 @@ import gigedi.dev.domain.auth.domain.Figma; import gigedi.dev.domain.figma.application.FigmaService; +import gigedi.dev.domain.file.domain.File; import gigedi.dev.domain.shoot.dao.ShootTagRepository; import gigedi.dev.domain.shoot.domain.Shoot; import gigedi.dev.domain.shoot.domain.ShootTag; @@ -17,10 +18,10 @@ public class ShootTagService { private final ShootTagRepository shootTagRepository; private final FigmaService figmaService; - public void createShootTags(Shoot shoot, List tags) { + public void createShootTags(Shoot shoot, List tags, File currentFile) { tags.forEach( tag -> { - Figma figma = figmaService.findByTag(tag); + Figma figma = figmaService.findByTag(tag, currentFile); if (figma != null) { ShootTag shootTag = ShootTag.createShootTag(shoot, figma); shootTagRepository.save(shootTag); diff --git a/src/main/java/gigedi/dev/global/common/constants/SecurityConstants.java b/src/main/java/gigedi/dev/global/common/constants/SecurityConstants.java index f8e7dc2..414281a 100644 --- a/src/main/java/gigedi/dev/global/common/constants/SecurityConstants.java +++ b/src/main/java/gigedi/dev/global/common/constants/SecurityConstants.java @@ -17,6 +17,7 @@ public final class SecurityConstants { public static final String GOOGLE_WITHDRAWAL_URL = "https://accounts.google.com/o/oauth2/revoke?token="; + public static final String DISCORD_HOST = "discord.com"; public static final String DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"; public static final String DISCORD_USER_INFO_URL = "https://discord.com/api/users/@me"; public static final String DISCORD_CREATE_DM_CHANNEL_URL = @@ -24,6 +25,7 @@ public final class SecurityConstants { public static final String DISCORD_GUILD_URL = "https://discord.com/api/v10/guilds"; public static final String DISCORD_DISCONNECT_URL = "https://discord.com/api/oauth2/token/revoke"; + public static final String DISCORD_SEND_DM_URL = "/api/channels/{channelId}/messages"; public static final String FIGMA_HOST = "api.figma.com"; public static final String FIGMA_GET_ID_TOKEN_URL = "https://www.figma.com/api/oauth/token"; diff --git a/src/main/java/gigedi/dev/global/error/exception/ErrorCode.java b/src/main/java/gigedi/dev/global/error/exception/ErrorCode.java index 182038f..fa7c14d 100644 --- a/src/main/java/gigedi/dev/global/error/exception/ErrorCode.java +++ b/src/main/java/gigedi/dev/global/error/exception/ErrorCode.java @@ -73,6 +73,7 @@ public enum ErrorCode { DISCORD_TOKEN_REISSUE_FAILED(HttpStatus.BAD_REQUEST, "디스코드 토큰 재발급 과정에서 오류가 발생했습니다."), DISCORD_DISCONNECT_FAILED(HttpStatus.BAD_REQUEST, "디스코드 연결 해제 과정에서 오류가 발생했습니다."), DISCORD_ACCOUNT_ALREADY_EXISTS(HttpStatus.NOT_FOUND, "연결된 디스코드 계정이 이미 존재합니다."), + DISCORD_DM_MESSAGE_SEND_FAILED(HttpStatus.BAD_REQUEST, "디스코드 DM 전송에 실패했습니다."), // Authority AUTHORITY_NOT_FOUND(HttpStatus.NOT_FOUND, "피그마 계정과 파일의 연관 정보가 존재하지 않습니다."), diff --git a/src/main/java/gigedi/dev/global/util/ShootUtil.java b/src/main/java/gigedi/dev/global/util/ShootUtil.java index 3d51add..5cab5ed 100644 --- a/src/main/java/gigedi/dev/global/util/ShootUtil.java +++ b/src/main/java/gigedi/dev/global/util/ShootUtil.java @@ -8,6 +8,7 @@ public class ShootUtil { private static final String SPACE_DELIMITER = "\\s+"; private static final String TAG_PREFIX = "@"; + private static final String HIGHLIGHT_FORMAT = "**%s**"; public static List extractTags(String content) { @@ -19,4 +20,25 @@ public static List extractTags(String content) { .map(word -> word.substring(1)) .collect(Collectors.toList()); } + + public static String highlightMentions(String content) { + if (content == null || content.isEmpty()) { + return content; + } + + return Arrays.stream(content.split(SPACE_DELIMITER)) + .map( + word -> + word.startsWith(TAG_PREFIX) + ? String.format(HIGHLIGHT_FORMAT, word) + : word) + .collect(Collectors.joining(" ")); + } + + public static String highlightText(String text) { + if (text == null || text.isEmpty()) { + return text; + } + return String.format(HIGHLIGHT_FORMAT, text); + } }