Add attachment support to agreements and enhance image handling
This commit is contained in:
@@ -35,6 +35,12 @@ public class AgreementsDTO {
|
|||||||
|
|
||||||
private LocalDateTime updatedDate;
|
private LocalDateTime updatedDate;
|
||||||
|
|
||||||
|
/* 첨부파일 ID */
|
||||||
|
private String attachedFileId;
|
||||||
|
|
||||||
|
/* 첨부파일명 (확장자 포함) */
|
||||||
|
private String attachedFileName;
|
||||||
|
|
||||||
// 기본 생성자 추가
|
// 기본 생성자 추가
|
||||||
public AgreementsDTO() {
|
public AgreementsDTO() {
|
||||||
this.contents = ""; // null 방지를 위한 기본값 설정
|
this.contents = ""; // null 방지를 위한 기본값 설정
|
||||||
|
|||||||
+86
-12
@@ -7,30 +7,48 @@ import com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO;
|
|||||||
import com.eactive.apim.portal.apps.agreements.mapper.AgreementsMapper;
|
import com.eactive.apim.portal.apps.agreements.mapper.AgreementsMapper;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserPrivacyAgreementService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserPrivacyAgreementService;
|
||||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||||
|
import com.eactive.apim.portal.file.entity.FileDetail;
|
||||||
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringEscapeUtils;
|
import org.apache.commons.lang3.StringEscapeUtils;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class AgreementsFacadeImpl implements AgreementsFacade {
|
public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||||
|
|
||||||
|
// 플레이스홀더 패턴: {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|속성}}
|
||||||
|
private static final Pattern PLACEHOLDER_PATTERN =
|
||||||
|
Pattern.compile("\\{\\{IMG:([a-f0-9\\-]+):(\\d+)(\\|[^}]+)?\\}\\}");
|
||||||
|
|
||||||
private final AgreementsService agreementsService;
|
private final AgreementsService agreementsService;
|
||||||
private final AgreementsMapper agreementsMapper;
|
private final AgreementsMapper agreementsMapper;
|
||||||
private final PortalUserPrivacyAgreementService userAgreementService;
|
private final PortalUserPrivacyAgreementService userAgreementService;
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
|
@Value("${editor.image.base-url:/file/view}")
|
||||||
|
private String editorImageBaseUrl;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AgreementsFacadeImpl(AgreementsService agreementsService,
|
public AgreementsFacadeImpl(AgreementsService agreementsService,
|
||||||
AgreementsMapper agreementsMapper,
|
AgreementsMapper agreementsMapper,
|
||||||
PortalUserPrivacyAgreementService userAgreementService) {
|
PortalUserPrivacyAgreementService userAgreementService,
|
||||||
|
FileService fileService) {
|
||||||
this.agreementsService = agreementsService;
|
this.agreementsService = agreementsService;
|
||||||
this.agreementsMapper = agreementsMapper;
|
this.agreementsMapper = agreementsMapper;
|
||||||
this.userAgreementService = userAgreementService;
|
this.userAgreementService = userAgreementService;
|
||||||
|
this.fileService = fileService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -71,15 +89,79 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML 언이스케이프 처리
|
// HTML 언이스케이프 및 이미지 플레이스홀더 변환 처리
|
||||||
private AgreementsDTO convertToDTO(Agreements agreement) {
|
private AgreementsDTO convertToDTO(Agreements agreement) {
|
||||||
AgreementsDTO dto = agreementsMapper.map(agreement);
|
AgreementsDTO dto = agreementsMapper.map(agreement);
|
||||||
if (dto.getContents() != null) {
|
if (dto.getContents() != null) {
|
||||||
dto.setContents(StringEscapeUtils.unescapeHtml4(dto.getContents()));
|
String contents = StringEscapeUtils.unescapeHtml4(dto.getContents());
|
||||||
|
// 이미지 플레이스홀더를 HTML img 태그로 변환
|
||||||
|
contents = convertPlaceholdersToHtml(contents);
|
||||||
|
dto.setContents(contents);
|
||||||
|
}
|
||||||
|
// 첨부파일 정보 설정
|
||||||
|
if (StringUtils.isNotBlank(agreement.getAttachedFileId())) {
|
||||||
|
dto.setAttachedFileId(agreement.getAttachedFileId());
|
||||||
|
try {
|
||||||
|
FileDetail fileDetail = fileService.getFileDetailByIds(agreement.getAttachedFileId(), 1);
|
||||||
|
String fileName = fileDetail.getOriginalFileName();
|
||||||
|
if (StringUtils.isNotBlank(fileDetail.getFileExtension())) {
|
||||||
|
fileName += "." + fileDetail.getFileExtension();
|
||||||
|
}
|
||||||
|
dto.setAttachedFileName(fileName);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("첨부파일 정보 조회 실패: fileId={}", agreement.getAttachedFileId(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이스홀더를 실제 이미지 태그로 변환
|
||||||
|
* {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|width=300|height=200|style=...}}
|
||||||
|
* → <img src="/file/view?fileId={fileId}&fileSn={fileSn}" width="300" height="200" style="..." />
|
||||||
|
*/
|
||||||
|
private String convertPlaceholdersToHtml(String contents) {
|
||||||
|
if (contents == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = PLACEHOLDER_PATTERN.matcher(contents);
|
||||||
|
StringBuffer sb = new StringBuffer();
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
String fileId = matcher.group(1);
|
||||||
|
String fileSn = matcher.group(2);
|
||||||
|
String attrs = matcher.group(3);
|
||||||
|
|
||||||
|
StringBuilder img = new StringBuilder();
|
||||||
|
img.append("<img src=\"").append(editorImageBaseUrl)
|
||||||
|
.append("?fileId=").append(fileId)
|
||||||
|
.append("&fileSn=").append(fileSn).append("\"");
|
||||||
|
|
||||||
|
// 속성 파싱 및 추가
|
||||||
|
if (attrs != null && !attrs.isEmpty()) {
|
||||||
|
String[] parts = attrs.substring(1).split("\\|"); // 앞의 | 제거 후 분리
|
||||||
|
for (String part : parts) {
|
||||||
|
String[] kv = part.split("=", 2);
|
||||||
|
if (kv.length == 2) {
|
||||||
|
img.append(" ").append(kv[0]).append("=\"").append(kv[1]).append("\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 스타일 (style 속성이 없으면)
|
||||||
|
if (attrs == null || !attrs.contains("style=")) {
|
||||||
|
img.append(" style=\"max-width:100%;height:auto;\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
img.append(" />");
|
||||||
|
matcher.appendReplacement(sb, Matcher.quoteReplacement(img.toString()));
|
||||||
|
}
|
||||||
|
matcher.appendTail(sb);
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgreementsDTO getAgreement(String agreementsTypeCode) {
|
public AgreementsDTO getAgreement(String agreementsTypeCode) {
|
||||||
LocalDateTime date = LocalDateTime.now();
|
LocalDateTime date = LocalDateTime.now();
|
||||||
@@ -142,15 +224,7 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
|||||||
return Collections.singletonList(createDefaultAgreement(String.valueOf(agreementTypeCode)));
|
return Collections.singletonList(createDefaultAgreement(String.valueOf(agreementTypeCode)));
|
||||||
}
|
}
|
||||||
return agreementsList.stream()
|
return agreementsList.stream()
|
||||||
.map(agreement -> {
|
.map(this::convertToDTO)
|
||||||
AgreementsDTO dto = agreementsMapper.map(agreement);
|
|
||||||
|
|
||||||
// HTML 언이스케이프 처리
|
|
||||||
if (dto.getContents() != null) {
|
|
||||||
dto.setContents(StringEscapeUtils.unescapeHtml4(dto.getContents()));
|
|
||||||
}
|
|
||||||
return dto;
|
|
||||||
})
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
@@ -39,4 +39,54 @@ public class FileDownloadController {
|
|||||||
IOUtils.write(fileContents, response.getOutputStream());
|
IOUtils.write(fileContents, response.getOutputStream());
|
||||||
response.flushBuffer();
|
response.flushBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 인라인 표시용 (브라우저에서 직접 렌더링)
|
||||||
|
* 약관 등에서 이미지를 표시할 때 사용
|
||||||
|
*/
|
||||||
|
@GetMapping(value = "/view")
|
||||||
|
public void viewImage(@RequestParam(value = "fileId") String fileId,
|
||||||
|
@RequestParam(value = "fileSn", defaultValue = "1") int fileSn,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
|
||||||
|
FileDetail fileDetail = fileService.getFileDetailByIds(fileId, fileSn);
|
||||||
|
|
||||||
|
// Content-Type 설정 (이미지용)
|
||||||
|
String contentType = getImageContentType(fileDetail.getFileExtension());
|
||||||
|
response.setContentType(contentType);
|
||||||
|
response.setContentLength(fileDetail.getFileSize());
|
||||||
|
|
||||||
|
// inline으로 설정 (브라우저에서 직접 표시)
|
||||||
|
response.setHeader("Content-Disposition", "inline");
|
||||||
|
|
||||||
|
// 캐시 설정 (1일)
|
||||||
|
response.setHeader("Cache-Control", "public, max-age=86400");
|
||||||
|
|
||||||
|
byte[] fileContents = fileService.retrieveFileContent(fileDetail);
|
||||||
|
IOUtils.write(fileContents, response.getOutputStream());
|
||||||
|
response.flushBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getImageContentType(String extension) {
|
||||||
|
if (extension == null) {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
switch (extension.toLowerCase()) {
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case "png":
|
||||||
|
return "image/png";
|
||||||
|
case "gif":
|
||||||
|
return "image/gif";
|
||||||
|
case "bmp":
|
||||||
|
return "image/bmp";
|
||||||
|
case "webp":
|
||||||
|
return "image/webp";
|
||||||
|
case "svg":
|
||||||
|
return "image/svg+xml";
|
||||||
|
default:
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ page:
|
|||||||
corp:
|
corp:
|
||||||
name: "법인회원가입"
|
name: "법인회원가입"
|
||||||
path: "/signup/portalOrg"
|
path: "/signup/portalOrg"
|
||||||
|
agreements:
|
||||||
|
name: 약관
|
||||||
|
path: "#"
|
||||||
|
children:
|
||||||
|
terms:
|
||||||
|
name: "이용약관 및 개인정보처리방침"
|
||||||
|
path: "/agreements/terms"
|
||||||
about:
|
about:
|
||||||
name: 안내
|
name: 안내
|
||||||
path: "#"
|
path: "#"
|
||||||
@@ -311,3 +318,8 @@ page:
|
|||||||
api_statistics:
|
api_statistics:
|
||||||
name: "이용 통계"
|
name: "이용 통계"
|
||||||
path: "/statistics/api"
|
path: "/statistics/api"
|
||||||
|
|
||||||
|
# 에디터 이미지 설정 (약관 이미지 표시용)
|
||||||
|
editor:
|
||||||
|
image:
|
||||||
|
base-url: /file/view
|
||||||
|
|||||||
@@ -17506,6 +17506,32 @@ body.commission-print-page .btn-primary:hover {
|
|||||||
|
|
||||||
.terms-content {
|
.terms-content {
|
||||||
padding: 0 32px;
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
.terms-content .terms-attachment {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.terms-content .terms-attachment .attachment-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border: 1px solid #0049b4;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #0049b4;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.terms-content .terms-attachment .attachment-link:hover {
|
||||||
|
background: #0049b4;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.terms-content .terms-attachment .attachment-link svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.terms-content {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
background-color: #F6F9FB;
|
background-color: #F6F9FB;
|
||||||
}
|
}
|
||||||
@@ -17516,6 +17542,8 @@ body.commission-print-page .btn-primary:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.terms-content .content-body {
|
.terms-content .content-body {
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #1A1A2E;
|
color: #1A1A2E;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
@@ -17523,6 +17551,8 @@ body.commission-print-page .btn-primary:hover {
|
|||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.terms-content .content-body {
|
.terms-content .content-body {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -190,6 +190,35 @@
|
|||||||
// Terms Content (Figma: 컨텐츠 영역)
|
// Terms Content (Figma: 컨텐츠 영역)
|
||||||
.terms-content {
|
.terms-content {
|
||||||
padding: 0 $spacing-xl;
|
padding: 0 $spacing-xl;
|
||||||
|
|
||||||
|
// Attachment Download (본문 영역 내)
|
||||||
|
.terms-attachment {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
|
.attachment-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $primary-blue;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
color: $primary-blue;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-blue;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
background-color: #F6F9FB;
|
background-color: #F6F9FB;
|
||||||
|
|
||||||
@@ -199,12 +228,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content-body {
|
.content-body {
|
||||||
|
padding-top: $spacing-lg;
|
||||||
|
padding-bottom: $spacing-lg;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: $text-dark;
|
color: $text-dark;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
padding-bottom: $spacing-md;
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,18 @@
|
|||||||
|
|
||||||
<!-- Terms Content -->
|
<!-- Terms Content -->
|
||||||
<div class="terms-content">
|
<div class="terms-content">
|
||||||
|
<!-- Attachment Download -->
|
||||||
|
<div class="terms-attachment" th:if="${selectedAgreement.attachedFileId != null}">
|
||||||
|
<a th:href="@{/file/download(fileId=${selectedAgreement.attachedFileId}, fileSn=1)}"
|
||||||
|
class="attachment-link">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
<span th:text="${selectedAgreement.attachedFileName} ?: '첨부파일 다운로드'">첨부파일 다운로드</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="content-body editor-content" th:utext="${selectedAgreement.contents}"></div>
|
<div class="content-body editor-content" th:utext="${selectedAgreement.contents}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user