친구톡 발송 진행중

This commit is contained in:
hehihoho3@gmail.com 2025-08-29 18:17:58 +09:00
parent bc09c23a0d
commit f5029faba2
26 changed files with 1325 additions and 95 deletions

View File

@ -3,14 +3,19 @@ package com.itn.mjonApi.cmn.apiServer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itn.mjonApi.cmn.domain.StatusResponse;
import com.itn.mjonApi.cmn.domain.biz.template.BizTemplateRequest;
import com.itn.mjonApi.cmn.domain.biz.template.detail.TemplateDetailResponse;
import com.itn.mjonApi.cmn.domain.biz.template.list.TemplateListResponse;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import com.itn.mjonApi.mjon.api.msg.send.mapper.domain.MjonResponseVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
/**
@ -126,4 +131,36 @@ public class ApiService <T> {
return returnEntity;
}
public StatusResponse postMultipartForEntity(String url, MsgFtRequestVO msgFtRequestVO, MultipartFile templateImage) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
// 파일 추가
body.add("templateImage", templateImage.getResource());
// MsgFtRequestVO 필드들을 kakaoVO에 맞게 매핑
if (msgFtRequestVO.getSendKind() != null) {
body.add("sendKind", msgFtRequestVO.getSendKind());
}
if (msgFtRequestVO.getTemplateCode() != null) {
body.add("templateCode", msgFtRequestVO.getTemplateCode());
}
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
ResponseEntity<StatusResponse> response = restTemplate.exchange(
url,
HttpMethod.POST,
requestEntity,
StatusResponse.class
);
log.info("Upload response :: [{}]", response.getBody());
return response.getBody();
}
}

View File

@ -0,0 +1,41 @@
package com.itn.mjonApi.cmn.domain;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.http.HttpStatus;
/*
* 1XX : 조건부 응답
* 2XX : 성공
* 3XX : 리다이렉션 완료
* 4XX : 요청 오류
* 500 : 서버 오류
*
* 참고 : https://km0830.tistory.com/33
*
* ====== 자주 사용하는 코드 =====
* 200 : Ok : 서버가 클라이언트의 요청을 성공적으로 처리, 페이지에서는 페이지 요청이 정상적으로 완료 (Ok)
* 400 : Bad Request : 잘못 요청 (Bad Request)
* 401 : Unauthorized : 권한 없음, 예를 들면, 로그인 페이지가 필요한 페이지를 로그인 없이 접속하려는 경우 반환되는 코드 (인증 실패) (Unauthorized)
*
* */
@Getter
@Setter
@NoArgsConstructor
@ToString
public class StatusResponse {
private HttpStatus status;
private String message;
private Object object;
private Object apiReturn;
private String messageTemp;
// private String timestamp;
}

View File

@ -2,6 +2,7 @@ package com.itn.mjonApi.cmn.model;
import com.itn.mjonApi.mjon.api.msg.inqry.mapper.PriceMapper;
import com.itn.mjonApi.mjon.api.msg.inqry.mapper.domain.PriceVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@ -19,6 +20,7 @@ import java.util.Map;
* 2023-07-03 hylee 최초 생성
*/
@Slf4j
@Component
public class Price {
@ -29,48 +31,88 @@ public class Price {
double mberMoney = priceMapper.selectMberMoney(mberId);
//시스템 단가 변수
double sys_shortPrice = 0.0f;
double sys_longPrice = 0.0f;
double sys_picturePrice = 0.0f;
double sys_shortPrice = 0.0f;
double sys_longPrice = 0.0f;
double sys_picturePrice = 0.0f;
double sys_kakaoAtPrice = 0.0f;
double sys_kakaoFtPrice = 0.0f;
double sys_kakaoFtImgPrice = 0.0f;
double sys_kakaoFtWideImgPrice = 0.0f;
//최종 단가 변수
double shortPrice = 0.0f;
double longPrice = 0.0f;
double picturePrice = 0.0f;
double shortPrice = 0.0f;
double longPrice = 0.0f;
double picturePrice = 0.0f;
double kakaoAtPrice = 0.0f;
double kakaoFtPrice = 0.0f;
double kakaoFtImgPrice = 0.0f;
double kakaoFtWideImgPrice = 0.0f;
//1.시스템 기본 단가, 사용자 개인단가 정보 불러오기
Map<String, String> priceMap = priceMapper.selectMberPriceInfo(mberId);
//1-2.단가 계산을 위한 set
sys_shortPrice = Double.parseDouble(String.valueOf(priceMap.get("sysShortPrice")));
sys_longPrice = Double.parseDouble(String.valueOf(priceMap.get("sysLongPrice")));
sys_picturePrice = Double.parseDouble(String.valueOf(priceMap.get("sysPicturePrice")));
sys_shortPrice = Double.parseDouble(String.valueOf(priceMap.get("sysShortPrice")));
sys_longPrice = Double.parseDouble(String.valueOf(priceMap.get("sysLongPrice")));
sys_picturePrice = Double.parseDouble(String.valueOf(priceMap.get("sysPicturePrice")));
sys_kakaoAtPrice = Double.parseDouble(String.valueOf(priceMap.get("sysKakaoAtPrice")));
sys_kakaoFtPrice = Double.parseDouble(String.valueOf(priceMap.get("sysKakaoFtPrice")));
sys_kakaoFtImgPrice = Double.parseDouble(String.valueOf(priceMap.get("sysKakaoFtImgPrice")));
sys_kakaoFtWideImgPrice = Double.parseDouble(String.valueOf(priceMap.get("sysKakaoFtWideImgPrice")));
shortPrice = Double.parseDouble(String.valueOf(priceMap.get("shortPrice")));
longPrice = Double.parseDouble(String.valueOf(priceMap.get("longPrice")));
picturePrice = Double.parseDouble(String.valueOf(priceMap.get("picturePrice")));
kakaoAtPrice = Double.parseDouble(String.valueOf(priceMap.get("kakaoAtPrice")));
kakaoFtPrice = Double.parseDouble(String.valueOf(priceMap.get("kakaoFtPrice")));
kakaoFtImgPrice = Double.parseDouble(String.valueOf(priceMap.get("kakaoFtImgPrice")));
kakaoFtWideImgPrice = Double.parseDouble(String.valueOf(priceMap.get("kakaoFtWideImgPrice")));
//1-3. 최종 단가 계산
shortPrice = shortPrice == 0.0f ? sys_shortPrice : shortPrice;
longPrice = longPrice == 0.0f ? sys_longPrice : longPrice;
picturePrice = picturePrice == 0.0f ? sys_picturePrice : picturePrice;
kakaoAtPrice = kakaoAtPrice == 0.0f ? sys_kakaoAtPrice : kakaoAtPrice;
kakaoFtPrice = kakaoFtPrice == 0.0f ? sys_kakaoFtPrice : kakaoFtPrice;
kakaoFtImgPrice = kakaoFtImgPrice == 0.0f ? sys_kakaoFtImgPrice : kakaoFtImgPrice;
kakaoFtWideImgPrice = kakaoFtWideImgPrice == 0.0f ? sys_kakaoFtWideImgPrice : kakaoFtWideImgPrice;
//2. 단가별 발송 가능건수 계산을위한 변수 set
int shortSendPsbltEa = 0;
int longSendPsbltEa = 0;
int pictureSendPsbltEa = 0;
int kakaoAtSendPsbltEa = 0;
int kakaoFtSendPsbltEa = 0;
int kakaoFtImgSendPsbltEa = 0;
int kakaoFtWideImgSendPsbltEa = 0;
//2-1. 소수점 연산을 위한 BigDecimal Casting
BigDecimal mberMoney_big = new BigDecimal(String.valueOf(mberMoney));
BigDecimal shortPrice_big = new BigDecimal(String.valueOf(priceMap.get("sysShortPrice")));
BigDecimal longPrice_big = new BigDecimal(String.valueOf(priceMap.get("sysLongPrice")));
BigDecimal picturePrice_big = new BigDecimal(String.valueOf(priceMap.get("sysPicturePrice")));
BigDecimal shortPrice_big = new BigDecimal(String.valueOf(shortPrice));
BigDecimal longPrice_big = new BigDecimal(String.valueOf(longPrice));
BigDecimal picturePrice_big = new BigDecimal(String.valueOf(picturePrice));
BigDecimal kakaoAtPrice_big = new BigDecimal(String.valueOf(kakaoAtPrice));
BigDecimal kakaoFtPrice_big = new BigDecimal(String.valueOf(kakaoFtPrice));
BigDecimal kakaoFtImgPrice_big = new BigDecimal(String.valueOf(kakaoFtImgPrice));
BigDecimal kakaoFtWideImgPrice_big = new BigDecimal(String.valueOf(kakaoFtWideImgPrice));
//2-2. mberMoney가 0일경우 제외
if(mberMoney_big.compareTo(BigDecimal.ZERO) != 0) {
shortSendPsbltEa = mberMoney_big.divide(shortPrice_big, BigDecimal.ROUND_DOWN).intValue();
longSendPsbltEa = mberMoney_big.divide(longPrice_big, BigDecimal.ROUND_DOWN).intValue();
pictureSendPsbltEa = mberMoney_big.divide(picturePrice_big, BigDecimal.ROUND_DOWN).intValue();
shortSendPsbltEa = mberMoney_big.divide(shortPrice_big, BigDecimal.ROUND_DOWN).intValue();
longSendPsbltEa = mberMoney_big.divide(longPrice_big, BigDecimal.ROUND_DOWN).intValue();
pictureSendPsbltEa = mberMoney_big.divide(picturePrice_big, BigDecimal.ROUND_DOWN).intValue();
kakaoAtSendPsbltEa = mberMoney_big.divide(kakaoAtPrice_big, BigDecimal.ROUND_DOWN).intValue();
kakaoFtSendPsbltEa = mberMoney_big.divide(kakaoFtPrice_big, BigDecimal.ROUND_DOWN).intValue();
kakaoFtImgSendPsbltEa = mberMoney_big.divide(kakaoFtImgPrice_big, BigDecimal.ROUND_DOWN).intValue();
kakaoFtWideImgSendPsbltEa = mberMoney_big.divide(kakaoFtWideImgPrice_big, BigDecimal.ROUND_DOWN).intValue();
}
//result set
@ -78,11 +120,28 @@ public class Price {
.shortPrice(shortPrice)
.longPrice(longPrice)
.picturePrice(picturePrice)
.kakaoAtPrice(kakaoAtPrice)
.kakaoFtPrice(kakaoFtPrice)
.kakaoFtImgPrice(kakaoFtImgPrice)
.kakaoFtWideImgPrice(kakaoFtWideImgPrice)
.shortSendPsbltEa(shortSendPsbltEa)
.longSendPsbltEa(longSendPsbltEa)
.pictureSendPsbltEa(pictureSendPsbltEa)
.kakaoAtSendPsbltEa(kakaoAtSendPsbltEa)
.kakaoFtSendPsbltEa(kakaoFtSendPsbltEa)
.kakaoFtImgSendPsbltEa(kakaoFtImgSendPsbltEa)
.kakaoFtWideImgSendPsbltEa(kakaoFtWideImgSendPsbltEa)
.mberMoney(mberMoney)
.build();
log.info(" + priceVO :: [{}]", priceVO);
return priceVO;
}

View File

@ -51,6 +51,15 @@ public enum StatMsg {
, STAT_2041("2041","타이틀 데이터 오류")
, STAT_2042("2042","대체문자 데이터 오류")
, STAT_2050("2050","지원하지 않는 이미지 형식입니다.")
, STAT_2051("2051","이미지 용량은 5MB 이내여야 합니다.")
, STAT_2052("2052","이미지를 읽을 수 없습니다.")
, STAT_2053("2053","지원하지 않는 이미지 형식입니다.")
, STAT_2054("2054","이미지 가로폭인 500px 미만입니다.")
, STAT_2055("2055","유효한 이미지 파일이 없습니다.")
, STAT_2056("2056","비율 2:1 이상 또는 3:4 이하만 허용됩니다.")
, STAT_2057("2057","대체문자(MMS) 이미지는 10MB를 초과할 수 없습니다.")
, STAT_2099("2099","기타 시스템 오류")

View File

@ -7,7 +7,6 @@ import com.itn.mjonApi.util.email.EmailVO;
import com.itn.mjonApi.util.email.SendMail;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.jsoup.Jsoup;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@ -16,7 +15,6 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.time.LocalDate;
/**
* packageName : com.itn.mjonApi.etc.ganpandaum.service.impl
@ -70,33 +68,16 @@ public class GdServiceImpl implements GdService {
SendMail sMail = new SendMail();
try {
emailContent = Jsoup.connect(GANPANDAUP_ESTIMATE_TEMPLATE_URL)
.data("query", "Java")
.userAgent("Mozilla")
.cookie("auth", "token")
.timeout(3000)
.post()
.toString();
// ./src/main/resources/templates/estimate.html
emailContent = emailContent
.replace("[[_Company_]]", gdVO.getGdCompany())
.replace("[[_Name_]]", gdVO.getGdName())
.replace("[[_Phone_]]", gdVO.getGdPhone())
.replace("[[_Email_]]", gdVO.getGdEmail())
.replace("[[_Addr_]]", gdVO.getGdAddr())
.replace("[[_Content_]]", gdVO.getGdContent())
;
// 메일 첨부파일을 위한 절대경로
// 메일 제목
String mailTitle = "[간판다움 견적의뢰] "+gdVO.getGdName()+"["+gdVO.getGdCompany()+"]님의 견적 의뢰입니다._"+LocalDate.now();
sMail.itnSendMail(
EmailVO.builder()
.title(mailTitle)
.contents(emailContent)
.title("현재달 월급명세서 전달드립니다.")
.contents("안녕하세요 귀하에 노고에 감사드립니다. 비밀번호는 생년워일입니다.")
.fileInfo(p_file)
.atch_file_name(fileNm)
.send_to(GANPANDAUP_RECEIVER_EMAIL)

View File

@ -1,6 +1,5 @@
package com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain;
import com.itn.mjonApi.cmn.domain.SendRequestCmnVO;
import lombok.*;
import java.io.Serializable;
@ -44,7 +43,7 @@ public class MsgAtRequestVO implements Serializable {
private String test_yn;
private List<VarListMapVO> varListMap = new ArrayList<>();
private List<VarAtListMapVO> varListMap = new ArrayList<>();

View File

@ -8,7 +8,7 @@ import lombok.ToString;
@Getter
@Setter
@ToString
public class VarListMapVO {
public class VarAtListMapVO {
/**

View File

@ -0,0 +1,90 @@
package com.itn.mjonApi.mjon.api.kakao.at.send.service;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.MsgAtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarAtListMapVO;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* fileName : IndexedParameterParserService.java
* author : hylee
* date : 2025-08-18
* description : 알림톡 인덱스된 파라미터 파싱 서비스
* parseIndexedParametersFromRequest 메서드의 아키텍처 분리를 위해 생성
* ===========================================================
* DATE AUTHOR NOTE
* -----------------------------------------------------------
* 2025-08-18 hylee 최초 생성
*/
@Service
public class AtIndexedParameterParserService {
// 정규식 패턴을 static final로 캐싱하여 성능 최적화
private static final Pattern INDEX_PATTERN =
Pattern.compile("^(callTo|templateContent|templateTitle|subMsgTxt)_(\\d+)$");
/**
* HttpServletRequest에서 동적으로 인덱스된 파라미터들을 파싱하여 VarListMapVO 리스트로 변환
* callTo_1~100, templateContent_1~100, templateTitle_1~100, subMsgTxt_1~100 등을 동적 처리
*
* @param request HTTP 요청 객체
* @return 파싱된 VarListMapVO 리스트
*/
public List<VarAtListMapVO> parseIndexedParameters(MsgAtRequestVO msgAtRequestVO, HttpServletRequest request) {
List<VarAtListMapVO> varListMap = new ArrayList<>();
// 모든 파라미터 가져오기
Map<String, String[]> parameterMap = request.getParameterMap();
// 인덱스별 데이터를 저장할
Map<Integer, Map<String, String>> indexedDataMap = new HashMap<>();
String subMsgSendYn = msgAtRequestVO.getSubMsgSendYn();
// 모든 파라미터를 순회하며 인덱스된 파라미터 찾기
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String paramName = entry.getKey();
String[] paramValues = entry.getValue();
Matcher matcher = INDEX_PATTERN.matcher(paramName);
if (matcher.matches() && paramValues.length > 0) {
String fieldName = matcher.group(1); // callTo, templateContent
int index = Integer.parseInt(matcher.group(2)); // 1, 2, 3
String value = paramValues[0]; // 파라미터
// 인덱스별 데이터 맵에 저장
indexedDataMap.computeIfAbsent(index, k -> new HashMap<>()).put(fieldName, value);
}
}
// 인덱스 순서대로 VarListMapVO 생성
List<Integer> sortedIndexes = new ArrayList<>(indexedDataMap.keySet());
Collections.sort(sortedIndexes);
for (Integer index : sortedIndexes) {
Map<String, String> dataMap = indexedDataMap.get(index);
VarAtListMapVO vo = new VarAtListMapVO();
vo.setCallToList(dataMap.get("callTo"));
vo.setTemplateContent(dataMap.get("templateContent"));
vo.setTemplateTitle(dataMap.get("templateTitle"));
// 치환 데이터는 subMsgSendYn Y일때
if("Y".equals(subMsgSendYn)){
vo.setSubMsgTxt(dataMap.get("subMsgTxt"));
}
// 필수 필드 하나라도 있으면 리스트에 추가
if (vo.getCallToList() != null || vo.getTemplateContent() != null || vo.getTemplateTitle() != null) {
varListMap.add(vo);
}
}
return varListMap;
}
}

View File

@ -8,7 +8,7 @@ import com.itn.mjonApi.cmn.msg.RestResponse;
import com.itn.mjonApi.mjon.api.kakao.at.inqry.mapper.domain.MjKakaoProfileInfoVO;
import com.itn.mjonApi.mjon.api.kakao.at.inqry.service.InqryService;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.MsgAtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarListMapVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarAtListMapVO;
import com.itn.mjonApi.util.MunjaUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@ -35,7 +35,7 @@ import java.util.List;
public class AtParameterProcessingService {
@Autowired
private IndexedParameterParserService indexedParameterParserService;
private AtIndexedParameterParserService indexedParameterParserService;
@Autowired
private InqryService inqryService;
@ -63,11 +63,11 @@ public class AtParameterProcessingService {
if (STAT_2030 != null) return STAT_2030;
// 파싱 로직을 IndexedParameterParserService에 위임
List<VarListMapVO> parsedList = indexedParameterParserService.parseIndexedParameters(msgAtRequestVO, request);
List<VarAtListMapVO> parsedList = indexedParameterParserService.parseIndexedParameters(msgAtRequestVO, request);
// 파싱된 VO에 대해 검증 수행
for (VarListMapVO vo : parsedList) {
String validationError = MunjaUtil.kakaoCmnValidate(vo, msgAtRequestVO.getSubMsgSendYn());
for (VarAtListMapVO vo : parsedList) {
String validationError = MunjaUtil.kakaoAtValidate(vo, msgAtRequestVO.getSubMsgSendYn());
if (StringUtils.isNotEmpty(validationError)) {
return validationError; // 검증 실패 오류 코드 반환

View File

@ -48,25 +48,8 @@ public class SendAtRestController {
// https://smartsms.aligo.in/alimapi.html
return ResponseEntity.ok().body(sendAtService.sendAtData(msgAtRequestVO, request));
// return ResponseEntity.ok().body(new RestResponse(msgAtRequestVO));
}
/**
*
* @param msgsRequestVO
* @description [문자 발송] 다른 내용으로 여려명에게 보냄
* @return
* @throws Exception
*/
/* @PostMapping("/api/send/sendMsgs")
public ResponseEntity<RestResponse> sendMsgs(MsgsRequestVO msgsRequestVO) throws Exception {
return ResponseEntity.ok().body(sendAtService.sendMsgsData_advc(msgsRequestVO));
}*/
}

View File

@ -0,0 +1,19 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class FtButtonVO {
private String name;
private String linkType; // AC, DS, WL, AL, BK, MD
private String linkTypeName; // 채널 추가, 배송조회, 웹링크, 앱링크, 봇키워드, 메시지전달
private String linkMo; // 모바일 링크
private String linkPc; // PC 링크
private String linkIos; // iOS Scheme
private String linkAnd; // Android Scheme
}

View File

@ -0,0 +1,46 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonProcessingException;
import lombok.*;
/**
* packageName : com.itn.mjonApi.cmn.msg
* fileName : mjonResponse
* author : hylee
* date : 2023-05-12
* description : 문자온 프로젝트에서 받은 리턴값
* ===========================================================
* DATE AUTHOR NOTE
* -----------------------------------------------------------
* 2023-05-12 hylee 최초 생성
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true) // JSON에 있지만 VO에 없는 필드를 무시하고 무사히 역직렬화해
@ToString
public class MjonFtResponseVO {
private String result;
private String message;
private String resultSts; // 전송결과 갯수
private String resultBlockSts; // 수신거부 갯수
private String msgGroupId;
private String afterCash;
private String msgType;
private String statCode;
/**
*
* @param apiReturnNode
* @return ResponseEntity vo convert
* @throws JsonProcessingException
*/
// public static MjonAtResponseVO getMjonResponse(JsonNode apiReturnNode) throws JsonProcessingException {
// ObjectMapper objectMapper = new ObjectMapper();
// return objectMapper.treeToValue(apiReturnNode, MjonAtResponseVO.class);
// }
}

View File

@ -0,0 +1,90 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* fileName : MsgAtRequestVO.java
* author : hylee
* date : 2025-07-29
* description : 알림톡
* ===========================================================
* DATE AUTHOR NOTE
* -----------------------------------------------------------
* 2025-07-29 hylee 최초 생성
*/
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Getter
@Setter
public class MsgFtRequestVO implements Serializable {
private static final long serialVersionUID = 1L;
private String sendKind = "A";
private String mberId; // value = "사용자 ID", example = "goodgkdus"
private String accessKey; // value = "Api Key", example = "0367a25ec370d1141898a0b9767103"
private String senderKey; // 카카오 알림톡 채널ID
private String templateCode; // 카카오 알림톡 템플릿 코드
private String callFrom; // value = "발신번호 :: 정책이 필요함", example = "01011112222"
// 대체문자 여부
private String subMsgSendYn;
// 광고 여부
private String adFlag;
private String test_yn;
// 실제 업로드 파일(로그/직렬화 제외)
@JsonIgnore
@ToString.Exclude
private MultipartFile templateImage;
@JsonIgnore
@ToString.Exclude
private MultipartFile subImage;
// ====== AOP/DB 저장용(문자열/숫자만) 메타데이터 필드들 ======
// template
private String templateImageName;
private String templateImageContentType;
private Long templateImageSize; // bytes
private Integer templateImageWidth; // px
private Integer templateImageHeight; // px
private String templateImageSha256; // hexdigest
private String templateImageSavedPath; // 서버 저장 경로(선택)
private String templateAtchFileId; // 내부 첨부ID(선택)
private String templateMsgType;
private String templateImageUrl;
// sub
// private String subImageName;
// private String subImageContentType;
// private Long subImageSize;
// private Integer subImageWidth;
// private Integer subImageHeight;
// private String subImageSha256;
// private String subImageSavedPath;
// private String subAtchFileId;
// private String subMsgType;
private List<VarFtListMapVO> varListMap = new ArrayList<>();
}

View File

@ -0,0 +1,64 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@ToString
public class VarFtListMapVO {
/**
* @description : 수신자번호
*/
private String phone;
/**
* @description : 친구톡 내용
*/
private String templateContent;
/**
* @description : 치환문자
*/
private String subMsgTxt;
/**
* @description : 치환문자 이미지
*/
private String filePath1;
/**
* @description : 버튼 사용
*/
private List<FtButtonVO> buttons = new ArrayList<>();
//
// /**
// * @description : [*3*] - 치환문자
// */
// private String rep3;
//
// /**
// * @description : [*4*] - 치환문자
// */
// private String rep4;
//
// /**
// * @description : 제목
// */
// private String subject;
//
// /**
// * @description : 내용
// */
// private String message;
}

View File

@ -0,0 +1,146 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.FtButtonVO;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.VarFtListMapVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* fileName : IndexedParameterParserService.java
* author : hylee
* date : 2025-08-18
* description : 알림톡 인덱스된 파라미터 파싱 서비스
* parseIndexedParametersFromRequest 메서드의 아키텍처 분리를 위해 생성
* ===========================================================
* DATE AUTHOR NOTE
* -----------------------------------------------------------
* 2025-08-18 hylee 최초 생성
*/
@Service
@Slf4j
public class FtIndexedParameterParserService {
private final ObjectMapper objectMapper = new ObjectMapper();
// 정규식 패턴을 static final로 캐싱하여 성능 최적화
private static final Pattern INDEX_PATTERN =
Pattern.compile("^(callTo|templateContent|subMsgTxt|button)_(\\d+)$");
/**
* HttpServletRequest에서 동적으로 인덱스된 파라미터들을 파싱하여 VarListMapVO 리스트로 변환
* callTo_1~100, templateContent_1~100, templateTitle_1~100, subMsgTxt_1~100 등을 동적 처리
*
* @param request HTTP 요청 객체
* @return 파싱된 VarListMapVO 리스트
*/
public List<VarFtListMapVO> parseIndexedParameters(MsgFtRequestVO msgAtRequestVO, HttpServletRequest request) {
List<VarFtListMapVO> varListMap = new ArrayList<>();
// 모든 파라미터 가져오기
Map<String, String[]> parameterMap = request.getParameterMap();
// 인덱스별 데이터를 저장할
Map<Integer, Map<String, String>> indexedDataMap = new HashMap<>();
String subMsgSendYn = msgAtRequestVO.getSubMsgSendYn();
// 모든 파라미터를 순회하며 인덱스된 파라미터 찾기
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String paramName = entry.getKey();
String[] paramValues = entry.getValue();
Matcher matcher = INDEX_PATTERN.matcher(paramName);
if (matcher.matches() && paramValues.length > 0) {
String fieldName = matcher.group(1); // callTo, templateContent
int index = Integer.parseInt(matcher.group(2)); // 1, 2, 3
String value = paramValues[0]; // 파라미터
// 인덱스별 데이터 맵에 저장
indexedDataMap.computeIfAbsent(index, k -> new HashMap<>()).put(fieldName, value);
}
}
// 인덱스 순서대로 VarListMapVO 생성
List<Integer> sortedIndexes = new ArrayList<>(indexedDataMap.keySet());
Collections.sort(sortedIndexes);
for (Integer index : sortedIndexes) {
Map<String, String> dataMap = indexedDataMap.get(index);
VarFtListMapVO vo = new VarFtListMapVO();
vo.setPhone(dataMap.get("callTo"));
vo.setTemplateContent(dataMap.get("templateContent"));
// 치환 데이터는 subMsgSendYn Y일때
if("Y".equals(subMsgSendYn)){
vo.setSubMsgTxt(dataMap.get("subMsgTxt"));
}
// 버튼 데이터 처리
String buttonData = dataMap.get("button");
if (buttonData != null && !buttonData.trim().isEmpty()) {
try {
JsonNode buttonJson = objectMapper.readTree(buttonData);
JsonNode buttonArray = buttonJson.get("button");
List<FtButtonVO> buttonList = new ArrayList<>();
if (buttonArray != null && buttonArray.isArray()) {
for (JsonNode button : buttonArray) {
FtButtonVO ftButton = FtButtonVO.builder()
.name(getJsonText(button, "name"))
.linkType(getJsonText(button, "linkType"))
.linkTypeName(getJsonText(button, "linkTypeName"))
.linkMo(getJsonText(button, "linkMo"))
.linkPc(getJsonText(button, "linkPc"))
.linkIos(getJsonText(button, "linkIos"))
.linkAnd(getJsonText(button, "linkAnd"))
.build();
buttonList.add(ftButton);
}
}
vo.setButtons(buttonList);
} catch (Exception e) {
log.error("버튼 JSON 파싱 실패 - index: {}, data: {}", index, buttonData, e);
vo.setButtons(new ArrayList<>());
}
}
// 필수 필드 하나라도 있으면 리스트에 추가
if (vo.getPhone() != null || vo.getTemplateContent() != null) {
varListMap.add(vo);
}
}
return varListMap;
}
/**
* JsonNode에서 안전하게 텍스트 값을 추출하는 헬퍼 메서드
*
* @param node JSON 노드
* @param fieldName 필드명
* @return 텍스트 (null 또는 값일 경우 null 반환)
*/
private String getJsonText(JsonNode node, String fieldName) {
if (node != null && node.has(fieldName)) {
JsonNode fieldNode = node.get(fieldName);
if (!fieldNode.isNull()) {
String text = fieldNode.asText();
return text.isEmpty() ? null : text;
}
}
return null;
}
}

View File

@ -0,0 +1,125 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itn.mjonApi.cmn.domain.biz.template.BizTemplateRequest;
import com.itn.mjonApi.cmn.domain.biz.template.detail.TemplateDetailResponse;
import com.itn.mjonApi.cmn.msg.RestResponse;
import com.itn.mjonApi.mjon.api.kakao.at.inqry.mapper.domain.MjKakaoProfileInfoVO;
import com.itn.mjonApi.mjon.api.kakao.at.inqry.service.InqryService;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.VarFtListMapVO;
import com.itn.mjonApi.util.MunjaUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* fileName : AtParameterProcessingService.java
* author : hylee
* date : 2025-08-18
* description : 알림톡 파라미터 처리 서비스
* MsgAtRequestVO의 비즈니스 로직을 담당
* ===========================================================
* DATE AUTHOR NOTE
* -----------------------------------------------------------
* 2025-08-18 hylee 최초 생성
*/
@Slf4j
@Service
public class FtParameterProcessingService {
@Autowired
private FtIndexedParameterParserService indexedParameterParserService;
@Autowired
private InqryService inqryService;
/**
* HttpServletRequest에서 동적으로 인덱스된 파라미터들을 파싱하고 검증하여
* MsgAtRequestVO의 varListMap에 설정
*
* @param msgFtRequestVO 요청 VO 객체
* @param request HTTP 요청 객체
* @return 검증 실패 오류 코드, 성공 null
*/
public String processIndexedParameters(MsgFtRequestVO msgFtRequestVO, HttpServletRequest request) {
// 기존 varListMap 초기화
msgFtRequestVO.setVarListMap(new ArrayList<>());
// 채널ID 확인
String STAT_2010 = this.validateSenderKey(msgFtRequestVO.getMberId(), msgFtRequestVO.getSenderKey());
if (STAT_2010 != null) return STAT_2010;
// 템플릿 코드 확인
// String STAT_2030 = this.validateTemplateCode(msgFtRequestVO.getMberId(), msgFtRequestVO.getSenderKey(), msgFtRequestVO.getTemplateCode());
// if (STAT_2030 != null) return STAT_2030;
// 파싱 로직을 IndexedParameterParserService에 위임
List<VarFtListMapVO> parsedList = indexedParameterParserService.parseIndexedParameters(msgFtRequestVO, request);
// 파싱된 VO에 대해 검증 수행
for (VarFtListMapVO vo : parsedList) {
String validationError = MunjaUtil.kakaoFtValidate(vo, msgFtRequestVO.getSubMsgSendYn());
if (StringUtils.isNotEmpty(validationError)) {
return validationError; // 검증 실패 오류 코드 반환
}
// 검증 통과한 VO를 리스트에 추가
msgFtRequestVO.getVarListMap().add(vo);
}
return null; // 모든 검증 통과
}
private String validateTemplateCode(String mberId, String senderKey, String templateCode) {
try {
BizTemplateRequest request = BizTemplateRequest.builder()
.mberId(mberId)
.senderKey(senderKey)
.templateCode(templateCode)
.build();
RestResponse response = inqryService.getTemplateDetail(request);
JsonNode node = new ObjectMapper().valueToTree(response.getData());
// 전체 출력
// log.info("data 전체 :: {}", node.toPrettyString());
// resultCode가 있으면 채널ID 오류 + inqryService.getTemplateDetail 참조
if (node.has("resultCode")) {
return "STAT_"+node.get("resultCode").asText();
}
TemplateDetailResponse detail = (TemplateDetailResponse)
response.getData();
// 템플릿 상세 정보 활용
log.info("template detail :: [{}]", detail);
return "200".equals(detail.getCode()) ? null : "STAT_2030";
} catch (Exception e) {
e.printStackTrace();
return "STAT_2099";
}
}
public String validateSenderKey(String mberId, String senderKey) {
if (StringUtils.isEmpty(senderKey)) {
return "STAT_2010";
}
List<MjKakaoProfileInfoVO> resultList = inqryService.getChnlId(mberId);
boolean ok = resultList.stream().anyMatch(p -> senderKey.equals(p.getSenderKey()));
return ok ? null : "STAT_2010";
}
}

View File

@ -0,0 +1,23 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.itn.mjonApi.cmn.msg.RestResponse;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
public interface SendFtService {
// RestResponse sendMsgData(MsgRequestVO msgRequestVO) throws Exception;
//
// RestResponse sendMsgData_advc(MsgRequestVO msgRequestVO) throws Exception;
//
// RestResponse sendMsgsData(MsgsRequestVO msgsRequestVO) throws Exception;
//
// RestResponse sendMsgsData_advc(MsgsRequestVO msgsRequestVO) throws Exception;
RestResponse sendFtData(MsgFtRequestVO msgFtRequestVO, HttpServletRequest request) throws IOException, NoSuchAlgorithmException;
}

View File

@ -0,0 +1,137 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.service.impl;
import com.itn.mjonApi.cmn.apiServer.ApiService;
import com.itn.mjonApi.cmn.domain.StatusResponse;
import com.itn.mjonApi.cmn.msg.FailRestResponse;
import com.itn.mjonApi.cmn.msg.RestResponse;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.ft.send.service.FtParameterProcessingService;
import com.itn.mjonApi.mjon.api.kakao.ft.send.service.SendFtService;
import com.itn.mjonApi.mjon.api.kakao.utils.FtFileMetaUtil;
import com.itn.mjonApi.mjon.api.msg.inqry.mapper.PriceMapper;
import com.itn.mjonApi.mjon.api.msg.send.mapper.SendMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.Response;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
@Slf4j
@Service
public class SendFtServiceImpl implements SendFtService {
@Autowired
private FtParameterProcessingService ftParameterProcessingService;
private ApiService<Response> apiService;
@Autowired
SendMapper sendMapper;
@Autowired
PriceMapper priceMapper;
@Autowired
public SendFtServiceImpl(ApiService<Response> apiService) {
this.apiService = apiService;
}
private static final String replaseStrList = "[*이름*],[*1*],[*2*],[*3*],[*4*]";
/**
* @param
* @return
* @throws Exception 처리 예외 발생 가능
* @date 2025-07-29
* @Discription 치환없는 알림톡 데이터
* @author hylee
*/
@Override
public RestResponse sendFtData(MsgFtRequestVO msgFtRequestVO, HttpServletRequest request) throws IOException, NoSuchAlgorithmException {
if(StringUtils.isNotEmpty(msgFtRequestVO.getTest_yn())){
// YF => 실패 테스트 데이터
// return this._getTestMsgReturnData(msgRequestVO.getTest_yn());
}
if (FtFileMetaUtil.hasFile(msgFtRequestVO.getTemplateImage())) {
String code = FtFileMetaUtil.fillTemplateMeta(msgFtRequestVO);
if (StringUtils.isNotEmpty(code)) return new RestResponse(new FailRestResponse(code, ""));
}
// if (FtFileMetaUtil.hasFile(msgFtRequestVO.getSubImage())) {
// code = FtFileMetaUtil.fillSubMeta(msgFtRequestVO);
// if (StringUtils.isNotEmpty(code)) return new RestResponse(new FailRestResponse(code, ""));
// }
if (FtFileMetaUtil.hasFile(msgFtRequestVO.getTemplateImage())) {
String code = FtFileMetaUtil.fillTemplateMeta(msgFtRequestVO);
if (StringUtils.isNotEmpty(code)) return new RestResponse(new FailRestResponse(code, ""));
// 메타데이터 처리 성공 업로드 API 호출
try {
// Option 1: ApiService에 multipart 메소드 추가
StatusResponse uploadResponse = apiService.postMultipartForEntity(
"/web/mjon/kakao/template/sendKakaoFriendsTemplateImageUploadAjax_advc.do",
msgFtRequestVO,
msgFtRequestVO.getTemplateImage()
);
log.info("전체 StatusResponse :: [{}]", uploadResponse);
log.info("object 필드만 :: [{}]", uploadResponse.getObject());
// 업로드 결과 처리
// if ("OK".equals(uploadResponse.getResult())) {
// // 성공 처리
// log.info("템플릿 이미지 업로드 성공");
// } else {
// // 실패 처리
// return new RestResponse(new FailRestResponse(uploadResponse.getStatCode(), ""));
// }
} catch (Exception e) {
log.error("템플릿 이미지 업로드 실패", e);
return new RestResponse(new FailRestResponse("UPLOAD_ERROR", ""));
}
}
// Form-data의 인덱스된 파라미터들을 VarListMapVO 리스트로 변환 (동적 처리 _1~_100)
String falseCode = ftParameterProcessingService.processIndexedParameters(msgFtRequestVO, request);
if(StringUtils.isNotEmpty(falseCode)){
log.info("falseCode :: [{}]", falseCode);
return new RestResponse(new FailRestResponse(falseCode,""));
}
//
//
//
//
// MjonResponseVO munjaSendResponse = apiService.postForEntity(
// "/web/mjon/kakao/friendstalk/kakaoFriendsTalkMsgSendAjax_advc.do"
// , msgFtRequestVO
// , String.class
// );
// convertMjonDataToApiResponse => MjonResponseVO 데이터를 ApiResponse 데이터로 변환하는 메소드
// log.info(" + munjaSendResponse :: [{}]", munjaSendResponse.toString());
// if("OK".equals(munjaSendResponse.getResult())){ // 성공
// return new RestResponse(SendSucRestResponse.convertMjonDataToApiResponse(munjaSendResponse));
// }else{ // 실패
// return new RestResponse(new FailRestResponse(munjaSendResponse.getStatCode(),""));
// }
return new RestResponse(msgFtRequestVO);
}
}

View File

@ -0,0 +1,60 @@
package com.itn.mjonApi.mjon.api.kakao.ft.send.web;
import com.itn.mjonApi.cmn.msg.RestResponse;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.ft.send.service.SendFtService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* packageName : com.itn.mjonApi.mjon.send.web
* fileName : SendRestController
* author : hylee
* date : 2023-02-15
* description :
* ===========================================================
* DATE AUTHOR NOTE
* -----------------------------------------------------------
* 2023-02-15 hylee 최초 생성
*/
// 치환문자가 있으면 , => § 치환
@CrossOrigin("*") // 모든 요청에 접근 허용
@Slf4j
@RestController
public class SendFtRestController {
@Autowired
private SendFtService sendFtService;
/**
*
* @param msgFtRequestVO
* @param request
* @Discription [문자 발송] 같은 내용으로 여려명에게 보냄
* @return
*/
@PostMapping(value = "/api/kakao/ft/sendMsg",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<RestResponse> sendMsg(
@ModelAttribute MsgFtRequestVO msgFtRequestVO // 텍스트들 바인딩
, HttpServletRequest request) throws Exception {
// https://smartsms.aligo.in/friendapi.html
return ResponseEntity.ok().body(sendFtService.sendFtData(msgFtRequestVO, request));
}
}

View File

@ -0,0 +1,260 @@
package com.itn.mjonApi.mjon.api.kakao.utils;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.MsgFtRequestVO;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.function.Consumer;
/**
* Kakao FT 이미지 메타데이터 유틸리티.
* - 템플릿/서브 이미지의 파일명, 용량, /높이, SHA-256 해시 세팅
* - 템플릿: I/W 타입 판정(2:1, 4:3, 가로500) 검증
* - 서브(MMS): jpg/jpeg/png/gif, 10MB만 검증(권장 640×960은 안내 수준)
* - 실패 코드 문자열 반환(성공 null)
*/
public class FtFileMetaUtil {
// ===== 실패코드 상수 정의 =====
private static final String OK = null; // 성공 null 반환
private static final String FT_E_EMPTY = "STAT_2055"; // 파일이 비어있음
private static final String FT_E_IMG_READ_FAIL = "STAT_2052"; // 이미지 읽기 실패
private static final String FT_E_WIDTH_LT_500 = "STAT_2054"; // 가로폭 500px 미만
private static final String FT_E_RATIO_OUT_OF_RANGE = "STAT_2056"; // 비율이 2:1~4:3 범위
private static final String FT_E_SIZE_GT_5MB = "STAT_2051"; // 파일 크기 초과(5MB 이상)
private static final String FT_E_CONTENT_TYPE = "STAT_2050"; // 허용되지 않는 콘텐츠 타입// 추가
private static final String FT_E_SIZE_GT_10MB = "STAT_2057"; // 대체문자(MMS) 이미지는 10MB를 초과할 없습니다.
/**
* 템플릿 이미지의 메타데이터(파일명, 용량, /높이, 해시, 타입) MsgFtRequestVO에 채움
* @param vo 메타데이터를 세팅할 VO
* @throws IOException
* @throws NoSuchAlgorithmException
*/
public static String fillTemplateMeta(MsgFtRequestVO vo) throws IOException, NoSuchAlgorithmException {
return fillMeta(vo.getTemplateImage(),
(name) -> vo.setTemplateImageName(name),
(ct) -> vo.setTemplateImageContentType(ct),
(size) -> vo.setTemplateImageSize(size),
(w) -> vo.setTemplateImageWidth(w),
(h) -> vo.setTemplateImageHeight(h),
(hash) -> vo.setTemplateImageSha256(hash),
(type) -> vo.setTemplateMsgType(type) // I/W 세팅
);
}
/**
* 서브(와이드) 이미지의 메타데이터를 MsgFtRequestVO에 채움
* - MMS 규격 검증(타입/10MB) 수행, I/W 판정 없음
* @param vo 메타데이터를 세팅할 VO
* @throws IOException
* @throws NoSuchAlgorithmException
*/
// public static String fillSubMeta(MsgFtRequestVO vo) throws IOException, NoSuchAlgorithmException {
// return fillMetaForSubImage(vo.getSubImage(),
// (name) -> vo.setSubImageName(name),
// (ct) -> vo.setSubImageContentType(ct),
// (size) -> vo.setSubImageSize(size),
// (w) -> vo.setSubImageWidth(w),
// (h) -> vo.setSubImageHeight(h),
// (hash) -> vo.setSubImageSha256(hash)
// );
// }
/**
* subImage(MMS) 전용 메타데이터 수집/검증 메서드
* - 검증: 파일 존재 여부, 용량(10MB), Content-Type(jpg/jpeg/png/gif)
* - 세팅: 파일명, Content-Type, 파일 크기, width/height, SHA-256
* - 비율/가로 500px 검증은 하지 않음 (권장 640×960은 안내 수준)
*
* @param f 업로드된 서브 이미지 파일
* @param nameC 파일명 setter
* @param ctC Content-Type setter
* @param sizeC 파일 크기 setter
* @param wC width setter
* @param hC height setter
* @param hashC SHA-256 setter
* @return 실패코드(String), 성공 null(OK)
* @throws IOException
* @throws NoSuchAlgorithmException
*/
private static String fillMetaForSubImage(
MultipartFile f,
Consumer<String> nameC,
Consumer<String> ctC,
Consumer<Long> sizeC,
Consumer<Integer> wC,
Consumer<Integer> hC,
Consumer<String> hashC
) throws IOException, NoSuchAlgorithmException {
// 1) 파일 존재/ 파일 체크
if (f == null || f.isEmpty()) return FT_E_EMPTY;
// 2) 용량(10MB) 제한
if (f.getSize() > 10L * 1024 * 1024) return FT_E_SIZE_GT_10MB;
// 3) 허용 Content-Type 확인 (jpg/jpeg/png/gif)
String ct = f.getContentType();
if (!isAllowedSubCt(ct)) return FT_E_CONTENT_TYPE;
// 4) 기본 메타 세팅
nameC.accept(f.getOriginalFilename());
ctC.accept(ct);
sizeC.accept(f.getSize());
// 5) 이미지 읽기 width/height 세팅 (비율 제한 없음)
int w = 0, h = 0;
try (InputStream is = f.getInputStream()) {
BufferedImage img = ImageIO.read(is);
if (img == null) return FT_E_IMG_READ_FAIL;
w = img.getWidth();
h = img.getHeight();
wC.accept(w);
hC.accept(h);
} catch (Exception e) {
return FT_E_IMG_READ_FAIL;
}
// sha-256
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(f.getBytes());
hashC.accept(bytesToHex(md.digest()));
// 권장 사이즈(640×960) 안내사항이므로 실패코드 미반환
return OK;
}
/**
* 공통 메타데이터 채우기 유틸
* - 파일명, Content-Type, 파일 크기
* - 이미지 /높이
* - 파일 SHA-256 해시
* - 가로/세로 비율 기반의 메시지 타입(I/W) 판정
*
* @param f MultipartFile (이미지 파일)
* @param nameC 파일명 setter
* @param ctC ContentType setter
* @param sizeC 파일 크기 setter
* @param wC width setter
* @param hC height setter
* @param hashC SHA-256 setter
* @param typeC 이미지 타입(I/W) setter
* @return 실패코드(String), 성공 null 반환
*/
private static String fillMeta(MultipartFile f,
Consumer<String> nameC,
Consumer<String> ctC,
Consumer<Long> sizeC,
Consumer<Integer> wC,
Consumer<Integer> hC,
Consumer<String> hashC,
Consumer<String> typeC
) throws IOException, NoSuchAlgorithmException {
if (f == null || f.isEmpty()) return FT_E_EMPTY;
// 용량/콘텐츠타입 1차 검증
if (f.getSize() > 5L * 1024 * 1024) return FT_E_SIZE_GT_5MB;
String ct = f.getContentType();
if (ct == null || !(ct.equalsIgnoreCase("image/jpeg") || ct.equalsIgnoreCase("image/png"))) {
return FT_E_CONTENT_TYPE;
}
nameC.accept(f.getOriginalFilename());
ctC.accept(ct);
sizeC.accept(f.getSize());
int w = 0, h = 0;
try (InputStream is = f.getInputStream()) {
BufferedImage img = ImageIO.read(is);
if (img == null) return FT_E_IMG_READ_FAIL;
w = img.getWidth();
h = img.getHeight();
wC.accept(w);
hC.accept(h);
} catch (Exception e) {
return FT_E_IMG_READ_FAIL;
}
// sha-256
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(f.getBytes());
hashC.accept(bytesToHex(md.digest()));
// 규칙 검증 + 타입(I/W) 판정
String typeOrCode = calcFtMsgTypeOrCode(w, h);
if (typeOrCode.length() == 1) { // "I" or "W"
typeC.accept(typeOrCode);
return OK;
}
return typeOrCode; // 실패코드 리턴
}
/**
* byte[] 16진수 문자열 변환
* @param bytes SHA-256 결과 바이트 배열
* @return Hex 문자열
*/
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
/**
* 가로/세로 크기를 기반으로 이미지 타입 판정
* - < 500px 오류코드 반환
* - 비율이 2:1( 2.0) ±10% "I"
* - 비율이 4:3( 1.333) ±10% "W"
* - 오류코드 반환
*
* @param width 이미지 가로
* @param height 이미지 세로 높이
* @return "I" 또는 "W" / 실패코드
*/
public static String calcFtMsgTypeOrCode(int width, int height) {
if (width < 500 || height <= 0) return FT_E_WIDTH_LT_500;
double r = (double) width / (double) height;
// 2:1 ~= 2.0, 4:3 ~= 1.333... (±10%)
boolean isI = (r >= 1.8 && r <= 2.2);
boolean isW = (r >= 1.28 && r <= 1.36);
if (isI) return "I";
if (isW) return "W";
return FT_E_RATIO_OUT_OF_RANGE;
}
/**
* MultipartFile 존재 여부 헬퍼
* @param f 업로드 파일
* @return 파일이 null이 아니고 비어있지 않으면 true
*/
public static boolean hasFile(MultipartFile f){ return f != null && !f.isEmpty(); }
/**
* 서브(MMS) 허용 Content-Type 검사
* 허용: image/jpeg, image/jpg, image/png, image/gif
* @param ct 요청의 Content-Type
* @return 허용 타입이면 true
*/
private static boolean isAllowedSubCt(String ct) {
return ct != null && (
ct.equalsIgnoreCase("image/jpeg") ||
ct.equalsIgnoreCase("image/jpg") || // 일부 클라이언트 대비
ct.equalsIgnoreCase("image/png") ||
ct.equalsIgnoreCase("image/gif")
);
}
}

View File

@ -1,14 +1,9 @@
package com.itn.mjonApi.mjon.api.msg.inqry.mapper.domain;
import java.time.LocalDateTime;
import lombok.*;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Setter
@Getter
@ -27,13 +22,23 @@ public class PriceResponse {
private double shortPrice; // 단문 이용단가
private double longPrice; // 장문 이용단가
private double picturePrice; // 그림 이용단가
private double kakaoAtPrice; // 그림 이용단가
private double kakaoFtPrice; // 그림 이용단가
private double kakaoFtImgPrice; // 그림 이용단가
private double kakaoFtWideImgPrice; // 그림 이용단가
private double mberMoney; // 잔액
private int shortSendPsbltEa; // 단문 발송 가능건
private int longSendPsbltEa; // 장문 발송 가능건
private int pictureSendPsbltEa; // 그림 발송 가능건
private int kakaoAtSendPsbltEa; // 그림 발송 가능건
private int kakaoFtSendPsbltEa; // 그림 발송 가능건
private int kakaoFtImgSendPsbltEa; // 그림 발송 가능건
private int kakaoFtWideImgSendPsbltEa; // 그림 발송 가능건
/*
* 200-OK : 정상접속
* 401-Unauthorized : 인증실패

View File

@ -1,16 +1,13 @@
package com.itn.mjonApi.mjon.api.msg.inqry.mapper.domain;
import java.io.Serializable;
import lombok.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class PriceVO implements Serializable{
@ -22,11 +19,21 @@ public class PriceVO implements Serializable{
private double shortPrice; // 단문 이용단가
private double longPrice; // 장문 이용단가
private double picturePrice; // 그림 이용단가
private double kakaoAtPrice; // 알림톡 이용단가
private double kakaoFtPrice; // 친구톡 이용단가
private double kakaoFtImgPrice; // 친구톡 그림 이용단가
private double kakaoFtWideImgPrice; // 친구톡 와이드 그림 이용단가
private double mberMoney; // 잔액
private int shortSendPsbltEa; // 단문 발송 가능건
private int longSendPsbltEa; // 장문 발송 가능건
private int pictureSendPsbltEa; // 그림 발송 가능건
private int kakaoAtSendPsbltEa; // 알림톡 발송 가능건
private int kakaoFtSendPsbltEa; // 친구톡 발송 가능건
private int kakaoFtImgSendPsbltEa; // 친구톡 그림 발송 가능건
private int kakaoFtWideImgSendPsbltEa; // 친구톡 와이드 그림 발송 가능건
}

View File

@ -59,10 +59,20 @@ public class PriceServiceImpl implements PriceService {
.shortPrice(priceVO.getShortPrice())
.longPrice(priceVO.getLongPrice())
.picturePrice(priceVO.getPicturePrice())
.kakaoAtPrice(priceVO.getKakaoAtPrice())
.kakaoFtPrice(priceVO.getKakaoFtPrice())
.kakaoFtImgPrice(priceVO.getKakaoFtImgPrice())
.kakaoFtWideImgPrice(priceVO.getKakaoFtWideImgPrice())
//3. 발송가능건수
.shortSendPsbltEa(priceVO.getShortSendPsbltEa())
.longSendPsbltEa(priceVO.getLongSendPsbltEa())
.pictureSendPsbltEa(priceVO.getPictureSendPsbltEa())
.kakaoAtSendPsbltEa(priceVO.getKakaoAtSendPsbltEa())
.kakaoFtSendPsbltEa(priceVO.getKakaoFtSendPsbltEa())
.kakaoFtImgSendPsbltEa(priceVO.getKakaoFtImgSendPsbltEa())
.kakaoFtWideImgSendPsbltEa(priceVO.getKakaoFtWideImgSendPsbltEa())
.build();
} catch (Exception e) {

View File

@ -1,6 +1,8 @@
package com.itn.mjonApi.util;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarListMapVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarAtListMapVO;
import com.itn.mjonApi.mjon.api.kakao.ft.send.mapper.domain.VarFtListMapVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
@ -15,6 +17,7 @@ import org.apache.commons.lang3.StringUtils;
* -----------------------------------------------------------
* 2023-05-17 hylee 최초 생성
*/
@Slf4j
public class MunjaUtil {
@ -105,7 +108,7 @@ public class MunjaUtil {
* @param subMsgSendYn 대체문자 발송 여부
* @return 검증 실패 오류 코드, 성공 null
*/
public static String kakaoCmnValidate(VarListMapVO vo, String subMsgSendYn) {
public static String kakaoAtValidate(VarAtListMapVO vo, String subMsgSendYn) {
// 수신번호 검증
String callTo = vo.getCallToList();
@ -138,5 +141,33 @@ public class MunjaUtil {
}
public static String kakaoFtValidate(VarFtListMapVO vo, String subMsgSendYn) {
log.info(" vo.toString() [{}]", vo.toString());
// 수신번호 검증
String callTo = vo.getPhone();
if (MunjaUtil.getCallToChk(callTo)) {
return "STAT_1020"; // 수신자 전화번호 오류
}
// 본문 데이터 검증
String smsTxt = vo.getTemplateContent();
if (StringUtils.isEmpty(smsTxt)) {
return "STAT_2040"; // 본문 데이터 오류
}
// 대체문자 검증 (대체문자 발송이 활성화된 경우에만)
if ("Y".equals(subMsgSendYn)) {
String subMsgTxt = vo.getSubMsgTxt();
if (StringUtils.isEmpty(subMsgTxt)) {
return "STAT_2042"; // 대체문자 데이터 오류
}
}
// 모든 검증 통과
return null;
}
}

View File

@ -19,16 +19,24 @@
resultType="hashmap"
>
SELECT a.SHORT_PRICE AS sysShortPrice,
a.LONG_PRICE AS sysLongPrice,
a.PICTURE_PRICE AS sysPicturePrice,
a.PICTURE2_PRICE AS sysPicturePrice2,
a.PICTURE3_PRICE AS sysPicturePrice3,
b.SHORT_PRICE AS shortPrice,
b.LONG_PRICE AS longPrice,
b.PICTURE_PRICE AS picturePrice,
b.PICTURE2_PRICE AS picturePrice2,
b.PICTURE3_PRICE AS picturePrice3
SELECT a.SHORT_PRICE AS sysShortPrice
, a.LONG_PRICE AS sysLongPrice
, a.PICTURE_PRICE AS sysPicturePrice
, a.PICTURE2_PRICE AS sysPicturePrice2
, a.PICTURE3_PRICE AS sysPicturePrice3
, a.KAKAO_AT_PRICE AS sysKakaoAtPrice
, a.KAKAO_FT_PRICE AS sysKakaoFtPrice
, a.KAKAO_FT_IMG_PRICE AS sysKakaoFtImgPrice
, a.KAKAO_FT_WIDE_IMG_PRICE AS sysKakaoFtWideImgPrice
, b.SHORT_PRICE AS shortPrice
, b.LONG_PRICE AS longPrice
, b.PICTURE_PRICE AS picturePrice
, b.PICTURE2_PRICE AS picturePrice2
, b.PICTURE3_PRICE AS picturePrice3
, b.KAKAO_AT_PRICE AS kakaoAtPrice
, b.KAKAO_FT_PRICE AS kakaoFtPrice
, b.KAKAO_FT_IMG_PRICE AS kakaoFtImgPrice
, b.KAKAO_FT_WIDE_IMG_PRICE AS kakaoFtWideImgPrice
FROM mj_mber_setting a ,
lettngnrlmber b
WHERE b.mber_id = #{mberId}

View File

@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.itn.mjonApi.cmn.domain.biz.template.BizTemplateRequest;
import com.itn.mjonApi.mjon.api.kakao.at.inqry.mapper.domain.MjKakaoProfileInfoVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.MsgAtRequestVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarListMapVO;
import com.itn.mjonApi.mjon.api.kakao.at.send.mapper.domain.VarAtListMapVO;
import java.util.ArrayList;
import java.util.List;
@ -38,7 +38,7 @@ public class TestUtils {
vo.setTest_yn("Y"); // 테스트 모드
// VarListMap 생성 (동적 파라미터)
List<VarListMapVO> varListMap = new ArrayList<>();
List<VarAtListMapVO> varListMap = new ArrayList<>();
varListMap.add(createVarListMapVO("01012345678", "테스트사용자1", "테스트사용자1"));
varListMap.add(createVarListMapVO("01087654321", "테스트사용자2", "테스트사용자2"));
vo.setVarListMap(varListMap);
@ -54,8 +54,8 @@ public class TestUtils {
* @param replaceValue 치환값
* @return 테스트용 VarListMapVO 객체
*/
public static VarListMapVO createVarListMapVO(String phone, String name, String replaceValue) {
VarListMapVO vo = new VarListMapVO();
public static VarAtListMapVO createVarListMapVO(String phone, String name, String replaceValue) {
VarAtListMapVO vo = new VarAtListMapVO();
vo.setCallToList(phone);
vo.setTemplateTitle(name);
vo.setTemplateContent("테스트 메시지 " + name + "님 안녕하세요");
@ -210,8 +210,8 @@ public class TestUtils {
* @param count 생성할 개수
* @return VarListMapVO 목록
*/
public static List<VarListMapVO> createVarListMap(int count) {
List<VarListMapVO> varListMap = new ArrayList<>();
public static List<VarAtListMapVO> createVarListMap(int count) {
List<VarAtListMapVO> varListMap = new ArrayList<>();
for (int i = 1; i <= count; i++) {
varListMap.add(createVarListMapVO(