Add attachment support to agreements and enhance image handling

This commit is contained in:
Rinjae
2026-01-07 15:24:35 +09:00
parent f7dff3e8db
commit 868fb9a85d
8 changed files with 230 additions and 13 deletions
@@ -35,6 +35,12 @@ public class AgreementsDTO {
private LocalDateTime updatedDate;
/* 첨부파일 ID */
private String attachedFileId;
/* 첨부파일명 (확장자 포함) */
private String attachedFileName;
// 기본 생성자 추가
public AgreementsDTO() {
this.contents = ""; // null 방지를 위한 기본값 설정
@@ -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.user.service.PortalUserPrivacyAgreementService;
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.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
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.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
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 AgreementsMapper agreementsMapper;
private final PortalUserPrivacyAgreementService userAgreementService;
private final FileService fileService;
@Value("${editor.image.base-url:/file/view}")
private String editorImageBaseUrl;
@Autowired
public AgreementsFacadeImpl(AgreementsService agreementsService,
AgreementsMapper agreementsMapper,
PortalUserPrivacyAgreementService userAgreementService) {
PortalUserPrivacyAgreementService userAgreementService,
FileService fileService) {
this.agreementsService = agreementsService;
this.agreementsMapper = agreementsMapper;
this.userAgreementService = userAgreementService;
this.fileService = fileService;
}
@Override
@@ -71,15 +89,79 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
}
}
// HTML 언이스케이프 처리
// HTML 언이스케이프 및 이미지 플레이스홀더 변환 처리
private AgreementsDTO convertToDTO(Agreements agreement) {
AgreementsDTO dto = agreementsMapper.map(agreement);
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;
}
/**
* 플레이스홀더를 실제 이미지 태그로 변환
* {{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
public AgreementsDTO getAgreement(String agreementsTypeCode) {
LocalDateTime date = LocalDateTime.now();
@@ -142,15 +224,7 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
return Collections.singletonList(createDefaultAgreement(String.valueOf(agreementTypeCode)));
}
return agreementsList.stream()
.map(agreement -> {
AgreementsDTO dto = agreementsMapper.map(agreement);
// HTML 언이스케이프 처리
if (dto.getContents() != null) {
dto.setContents(StringEscapeUtils.unescapeHtml4(dto.getContents()));
}
return dto;
})
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@@ -39,4 +39,54 @@ public class FileDownloadController {
IOUtils.write(fileContents, response.getOutputStream());
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";
}
}
}
+12
View File
@@ -211,6 +211,13 @@ page:
corp:
name: "법인회원가입"
path: "/signup/portalOrg"
agreements:
name: 약관
path: "#"
children:
terms:
name: "이용약관 및 개인정보처리방침"
path: "/agreements/terms"
about:
name: 안내
path: "#"
@@ -311,3 +318,8 @@ page:
api_statistics:
name: "이용 통계"
path: "/statistics/api"
# 에디터 이미지 설정 (약관 이미지 표시용)
editor:
image:
base-url: /file/view
+30
View File
@@ -17506,6 +17506,32 @@ body.commission-print-page .btn-primary:hover {
.terms-content {
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;
background-color: #F6F9FB;
}
@@ -17516,6 +17542,8 @@ body.commission-print-page .btn-primary:hover {
}
}
.terms-content .content-body {
padding-top: 24px;
padding-bottom: 24px;
font-size: 16px;
color: #1A1A2E;
line-height: 1.8;
@@ -17523,6 +17551,8 @@ body.commission-print-page .btn-primary:hover {
}
@media (max-width: 768px) {
.terms-content .content-body {
padding-top: 16px;
padding-bottom: 16px;
font-size: 14px;
}
}
File diff suppressed because one or more lines are too long
@@ -190,6 +190,35 @@
// Terms Content (Figma: 컨텐츠 영역)
.terms-content {
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;
background-color: #F6F9FB;
@@ -199,12 +228,16 @@
}
.content-body {
padding-top: $spacing-lg;
padding-bottom: $spacing-lg;
font-size: 16px;
color: $text-dark;
line-height: 1.8;
word-wrap: break-word;
@media (max-width: $breakpoint-sm) {
padding-top: $spacing-md;
padding-bottom: $spacing-md;
font-size: $font-size-sm;
}
@@ -45,6 +45,18 @@
<!-- 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>
</div>