Q&A 문의 공개범위 기능 추가
- 공개범위 설정 기능 개발(ALL/ORG/PRIVATE) - 게시물/댓글 정교화 - 요청 및 DB 처리 확장 - UI, DTO, Service 계층 업데이트 - 감사 로그 및 조회수 처리 로직 추가
This commit is contained in:
+29
-3
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
|||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -16,11 +17,13 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.validation.BindingResult;
|
import org.springframework.validation.BindingResult;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
@@ -56,6 +59,7 @@ public class InquiryController {
|
|||||||
model.addAttribute("inquiries", inquiries.getContent());
|
model.addAttribute("inquiries", inquiries.getContent());
|
||||||
model.addAttribute("page", inquiries);
|
model.addAttribute("page", inquiries);
|
||||||
model.addAttribute("commentCounts", commentCounts);
|
model.addAttribute("commentCounts", commentCounts);
|
||||||
|
model.addAttribute("visibilityCeiling", inquiryFacade.getVisibilityCeiling());
|
||||||
return "apps/community/mainInquiryList";
|
return "apps/community/mainInquiryList";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +79,25 @@ public class InquiryController {
|
|||||||
return "apps/community/mainInquiryDetail";
|
return "apps/community/mainInquiryDetail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2-1. 조회수 증가 (sessionStorage dedup 통과 시 JS 가 POST 호출)
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/view")
|
||||||
|
public ResponseEntity<Void> increaseViewCount(@PathVariable String id) {
|
||||||
|
inquiryFacade.incrementViewCount(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. inquiry 등록 페이지
|
* 3. inquiry 등록 페이지
|
||||||
*/
|
*/
|
||||||
@GetMapping("/new")
|
@GetMapping("/new")
|
||||||
public String newInquiryForm(Model model) {
|
public String newInquiryForm(Model model) {
|
||||||
model.addAttribute("inquiry", new InquiryDTO());
|
InquiryDTO inquiry = new InquiryDTO();
|
||||||
|
inquiry.setVisibility(VisibilityScope.ORG.name()); // 기본 공개범위: 법인공개
|
||||||
|
model.addAttribute("inquiry", inquiry);
|
||||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||||
|
addVisibilityOptions(model);
|
||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +109,23 @@ public class InquiryController {
|
|||||||
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||||
model.addAttribute("inquiry", inquiry);
|
model.addAttribute("inquiry", inquiry);
|
||||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||||
|
addVisibilityOptions(model);
|
||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 공개범위 상한에 따른 폼 옵션 노출 플래그. PRIVATE 는 항상 허용. */
|
||||||
|
private void addVisibilityOptions(Model model) {
|
||||||
|
VisibilityScope ceiling = inquiryFacade.getVisibilityCeiling();
|
||||||
|
model.addAttribute("allowAll", ceiling.width() >= VisibilityScope.ALL.width());
|
||||||
|
model.addAttribute("allowOrg", ceiling.width() >= VisibilityScope.ORG.width());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 5. inquiry 등록 요청
|
* 5. inquiry 등록 요청
|
||||||
*/
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||||
|
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||||
RedirectAttributes redirectAttributes,
|
RedirectAttributes redirectAttributes,
|
||||||
Model model) throws IOException {
|
Model model) throws IOException {
|
||||||
if (bindingResult.hasErrors()) {
|
if (bindingResult.hasErrors()) {
|
||||||
@@ -108,7 +133,7 @@ public class InquiryController {
|
|||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
inquiryFacade.createInquiry(inquiryDTO);
|
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||||
|
|
||||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||||
return REDIRECT_INQUIRY;
|
return REDIRECT_INQUIRY;
|
||||||
@@ -119,11 +144,12 @@ public class InquiryController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/edit")
|
@PostMapping("/edit")
|
||||||
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||||
|
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||||
RedirectAttributes redirectAttributes) throws IOException {
|
RedirectAttributes redirectAttributes) throws IOException {
|
||||||
if (bindingResult.hasErrors()) {
|
if (bindingResult.hasErrors()) {
|
||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO);
|
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO, image);
|
||||||
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
|
||||||
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
|
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,22 @@ public class InquiryDTO {
|
|||||||
|
|
||||||
private String maskedInquirerName;
|
private String maskedInquirerName;
|
||||||
|
|
||||||
|
/** 목록 작성자 표기용 법인명(이름과 별도 줄 표기). */
|
||||||
|
private String inquirerOrgName;
|
||||||
|
|
||||||
|
/** 상세 작성자 표기용 "이름 (법인명)". */
|
||||||
|
private String inquirerDisplay;
|
||||||
|
|
||||||
|
/** 공개범위(ALL/ORG/PRIVATE). 미지정 시 서버에서 상한으로 clamp. */
|
||||||
|
private String visibility;
|
||||||
|
|
||||||
|
/** 공개범위 표기용 한글 라벨(전체공개/법인공개/비공개). */
|
||||||
|
private String visibilityLabel;
|
||||||
|
|
||||||
|
/** 조회수(표시용). */
|
||||||
|
private long viewCount;
|
||||||
|
|
||||||
|
/** 목록에서 비공개 게시물을 "비공개 게시물"로 흐리게 표기할지 여부(비-소유자). */
|
||||||
|
private boolean privatePlaceholder;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
public interface InquiryMapper {
|
public interface InquiryMapper {
|
||||||
|
|
||||||
|
// visibility(공개범위)는 facade 에서 상한 clamp 후 수동 세팅, viewCount 는 서버가 관리
|
||||||
|
@Mapping(target = "visibility", ignore = true)
|
||||||
|
@Mapping(target = "viewCount", ignore = true)
|
||||||
Inquiry toEntity(InquiryDTO inquiry);
|
Inquiry toEntity(InquiryDTO inquiry);
|
||||||
|
|
||||||
@Mapping(target = "inquirerName", source = "inquirer.userName")
|
@Mapping(target = "inquirerName", source = "inquirer.userName")
|
||||||
|
|||||||
@@ -3,23 +3,30 @@ package com.eactive.apim.portal.apps.community.qna.service;
|
|||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
public interface InquiryFacade {
|
public interface InquiryFacade {
|
||||||
|
|
||||||
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
|
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
|
||||||
|
|
||||||
|
/** 문의 공개범위 상한(폼 옵션 노출 제어용). */
|
||||||
|
VisibilityScope getVisibilityCeiling();
|
||||||
|
|
||||||
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
|
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
|
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
|
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
|
void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
|
||||||
|
|
||||||
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
|
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
|
||||||
|
|
||||||
|
void incrementViewCount(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
|
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-4
@@ -7,26 +7,41 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
|||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||||
|
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||||
|
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||||
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.io.FilenameUtils;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InquiryFacadeImpl implements InquiryFacade {
|
public class InquiryFacadeImpl implements InquiryFacade {
|
||||||
|
|
||||||
|
private static final Set<String> IMAGE_EXTENSIONS =
|
||||||
|
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif"));
|
||||||
|
|
||||||
private final InquiryService inquiryService;
|
private final InquiryService inquiryService;
|
||||||
private final InquiryMapper inquiryMapper;
|
private final InquiryMapper inquiryMapper;
|
||||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
||||||
@@ -40,6 +55,8 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
|
|
||||||
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
|
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
|
||||||
|
|
||||||
|
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||||
|
|
||||||
return inquiriesPage.map(inquiry -> {
|
return inquiriesPage.map(inquiry -> {
|
||||||
InquiryDTO dto = inquiryMapper.map(inquiry);
|
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||||
if (dto != null && dto.getInquirer() != null) {
|
if (dto != null && dto.getInquirer() != null) {
|
||||||
@@ -48,8 +65,20 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
inquiry.getInquirer().getId(),
|
inquiry.getInquirer().getId(),
|
||||||
user.getId()
|
user.getId()
|
||||||
));
|
));
|
||||||
|
PortalOrg org = inquiry.getInquirer().getPortalOrg();
|
||||||
|
if (org != null) {
|
||||||
|
dto.setInquirerOrgName(org.getOrgName());
|
||||||
|
}
|
||||||
dto.getInquirer().setUserName(null); // 원본 데이터 제거
|
dto.getInquirer().setUserName(null); // 원본 데이터 제거
|
||||||
}
|
}
|
||||||
|
// 비공개 게시물은 본인/같은 법인 관리자 외에는 "비공개 게시물"로만 노출
|
||||||
|
VisibilityScope effective = VisibilityScope.clampTo(inquiry.getVisibility(), ceiling);
|
||||||
|
dto.setVisibility(effective.name());
|
||||||
|
dto.setVisibilityLabel(effective.label());
|
||||||
|
if (effective == VisibilityScope.PRIVATE && !canReadPrivate(user, inquiry)) {
|
||||||
|
dto.setPrivatePlaceholder(true);
|
||||||
|
dto.setInquirySubject(null);
|
||||||
|
}
|
||||||
return dto;
|
return dto;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,14 +110,32 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
@Override
|
@Override
|
||||||
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
||||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
|
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
|
||||||
return inquiryMapper.map(inquiry);
|
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||||
|
if (dto != null) {
|
||||||
|
VisibilityScope effective = inquiryService.effectiveVisibility(inquiry);
|
||||||
|
dto.setVisibility(effective.name());
|
||||||
|
dto.setVisibilityLabel(effective.label());
|
||||||
|
}
|
||||||
|
if (dto != null && inquiry.getInquirer() != null) {
|
||||||
|
// 상세 작성자 표기: "이름 (법인명)" — 본인은 owner-bypass 로 원문
|
||||||
|
String maskedName = StringMaskingUtil.maskName(
|
||||||
|
inquiry.getInquirer().getUserName(),
|
||||||
|
inquiry.getInquirer().getId(),
|
||||||
|
user.getId());
|
||||||
|
dto.setInquirerDisplay(withOrgName(maskedName, inquiry.getInquirer()));
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
|
public void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||||
inquiry.setInquirer(current);
|
inquiry.setInquirer(current);
|
||||||
|
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||||
|
if (hasFile(image)) {
|
||||||
|
inquiry.setAttachFile(storeImage(null, image));
|
||||||
|
}
|
||||||
inquiryService.createInquiry(inquiry);
|
inquiryService.createInquiry(inquiry);
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
@@ -99,17 +146,91 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
|
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||||
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
|
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
|
||||||
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
|
|
||||||
|
|
||||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||||
inquiry.setInquirer(existingInquiry.getInquirer());
|
inquiry.setInquirer(existingInquiry.getInquirer());
|
||||||
|
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||||
|
// 이미지 교체 시 기존 fileId 로 업데이트, 없으면 기존 첨부 유지
|
||||||
|
if (hasFile(image)) {
|
||||||
|
inquiry.setAttachFile(storeImage(existingInquiry.getAttachFile(), image));
|
||||||
|
} else {
|
||||||
|
inquiry.setAttachFile(existingInquiry.getAttachFile());
|
||||||
|
}
|
||||||
inquiryService.updateInquiry(id, inquiry);
|
inquiryService.updateInquiry(id, inquiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void incrementViewCount(PortalAuthenticatedUser user, String id) {
|
||||||
|
// 접근 가능한 게시물만 조회수 증가
|
||||||
|
inquiryService.getAccessibleInquiry(user, id);
|
||||||
|
inquiryService.incrementViewCount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VisibilityScope getVisibilityCeiling() {
|
||||||
|
return inquiryService.getVisibilityCeiling();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
|
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
|
||||||
inquiryService.deleteMyInquiry(user, id);
|
inquiryService.deleteMyInquiry(user, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// helpers
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/** 요청 공개범위를 기본 ORG 로 두고 property 상한으로 clamp. */
|
||||||
|
private VisibilityScope clampedVisibility(String requested) {
|
||||||
|
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||||
|
VisibilityScope scope = VisibilityScope.fromString(requested, VisibilityScope.ORG);
|
||||||
|
return VisibilityScope.clampTo(scope, ceiling);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비공개 게시물을 읽을 수 있는 사용자(작성자 본인 또는 같은 법인 관리자). */
|
||||||
|
private boolean canReadPrivate(PortalAuthenticatedUser user, Inquiry inquiry) {
|
||||||
|
PortalUser inquirer = inquiry.getInquirer();
|
||||||
|
if (inquirer == null || user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
||||||
|
&& isSameOrg(user, inquirer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
|
||||||
|
PortalOrg userOrg = user.getPortalOrg();
|
||||||
|
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||||
|
return userOrg != null && inquirerOrg != null
|
||||||
|
&& userOrg.getId() != null
|
||||||
|
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "이름 (법인명)" 조합. 법인명이 없으면 이름만. */
|
||||||
|
private String withOrgName(String name, PortalUser inquirer) {
|
||||||
|
PortalOrg org = inquirer.getPortalOrg();
|
||||||
|
if (org != null && org.getOrgName() != null && !org.getOrgName().isEmpty()) {
|
||||||
|
return name + " (" + org.getOrgName() + ")";
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasFile(MultipartFile file) {
|
||||||
|
return file != null && !file.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이미지 확장자 검증 후 단일 파일 저장, fileId 반환. */
|
||||||
|
private String storeImage(String existingFileId, MultipartFile image) throws IOException {
|
||||||
|
String ext = FilenameUtils.getExtension(image.getOriginalFilename());
|
||||||
|
if (ext == null || !IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
|
||||||
|
throw new InvalidFileException("이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.");
|
||||||
|
}
|
||||||
|
FileInfo fileInfo = fileService.createOrUpdateSingleFile(
|
||||||
|
existingFileId, image, image.getOriginalFilename(), true);
|
||||||
|
return fileInfo.getFileId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-4
@@ -7,7 +7,9 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
|||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
@@ -19,10 +21,19 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class InquiryService {
|
public class InquiryService {
|
||||||
|
|
||||||
public static final String PENDING = "PENDING";
|
public static final String PENDING = "PENDING";
|
||||||
private final InquiryRepository inquiryRepository;
|
|
||||||
|
|
||||||
public InquiryService(InquiryRepository inquiryRepository) {
|
/** 문의 공개범위 상한/기본값 property. */
|
||||||
|
private static final String PROP_GROUP = "Portal";
|
||||||
|
private static final String PROP_VISIBILITY_CEILING = "djb.inquiry.default-visibility";
|
||||||
|
private static final String PROP_VISIBILITY_DESC = "Q&A 문의 공개범위 상한/기본값 (ALL/ORG/PRIVATE)";
|
||||||
|
|
||||||
|
private final InquiryRepository inquiryRepository;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
public InquiryService(InquiryRepository inquiryRepository,
|
||||||
|
PortalPropertyService portalPropertyService) {
|
||||||
this.inquiryRepository = inquiryRepository;
|
this.inquiryRepository = inquiryRepository;
|
||||||
|
this.portalPropertyService = portalPropertyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +86,7 @@ public class InquiryService {
|
|||||||
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
|
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
|
||||||
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
|
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
|
||||||
inquiry.setAttachFile(updatedInquiry.getAttachFile());
|
inquiry.setAttachFile(updatedInquiry.getAttachFile());
|
||||||
|
inquiry.setVisibility(updatedInquiry.getVisibility());
|
||||||
return inquiryRepository.save(inquiry);
|
return inquiryRepository.save(inquiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,12 +124,27 @@ public class InquiryService {
|
|||||||
if (inquirer == null || user == null) {
|
if (inquirer == null || user == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// 작성자 본인은 공개범위와 무관하게 항상 접근 가능
|
||||||
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
VisibilityScope effective = effectiveVisibility(inquiry);
|
||||||
return false;
|
switch (effective) {
|
||||||
|
case ALL:
|
||||||
|
// 전체공개: 로그인 사용자면 접근 가능(@Secured 로 이미 인증됨)
|
||||||
|
return true;
|
||||||
|
case ORG:
|
||||||
|
return isSameOrg(user, inquirer);
|
||||||
|
case PRIVATE:
|
||||||
|
// 비공개: 본인 외에는 같은 법인 관리자만 열람
|
||||||
|
return isSameOrg(user, inquirer)
|
||||||
|
&& user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
|
||||||
PortalOrg userOrg = user.getPortalOrg();
|
PortalOrg userOrg = user.getPortalOrg();
|
||||||
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||||
return userOrg != null && inquirerOrg != null
|
return userOrg != null && inquirerOrg != null
|
||||||
@@ -125,4 +152,30 @@ public class InquiryService {
|
|||||||
&& userOrg.getId().equals(inquirerOrg.getId());
|
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물의 유효 공개범위 = 저장값을 property 상한으로 clamp 한 값.
|
||||||
|
* (property 가 항상 우선하므로 저장값이 옛 상한이라 넓더라도 재-clamp)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public VisibilityScope effectiveVisibility(Inquiry inquiry) {
|
||||||
|
return VisibilityScope.clampTo(inquiry.getVisibility(), getVisibilityCeiling());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PTL_PROPERTY 에 설정된 공개범위 상한(없으면 ORG). */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public VisibilityScope getVisibilityCeiling() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_VISIBILITY_CEILING, VisibilityScope.ORG.name(), PROP_VISIBILITY_DESC);
|
||||||
|
return VisibilityScope.fromString(value, VisibilityScope.ORG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회수 1 증가. sessionStorage dedup 을 통과한 POST 요청에서만 호출된다.
|
||||||
|
*/
|
||||||
|
public void incrementViewCount(String id) {
|
||||||
|
Inquiry inquiry = getInquiry(id);
|
||||||
|
inquiry.increaseViewCount();
|
||||||
|
inquiryRepository.save(inquiry);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -43,7 +43,8 @@ public class InquiryCommentController {
|
|||||||
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
||||||
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
||||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(inquiryId, request.getContent(), current);
|
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
|
||||||
|
inquiryId, request.getContent(), request.getVisibility(), current);
|
||||||
return ResponseEntity.ok(created);
|
return ResponseEntity.ok(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
@@ -11,4 +11,7 @@ public class InquiryCommentCreateRequest {
|
|||||||
@NotBlank(message = "댓글 내용을 입력해주세요.")
|
@NotBlank(message = "댓글 내용을 입력해주세요.")
|
||||||
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
|
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
/** 공개범위(ALL=공개, PRIVATE=비공개). 미지정 시 공개. */
|
||||||
|
private String visibility;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -26,4 +26,13 @@ public class InquiryCommentDTO {
|
|||||||
private LocalDateTime createdDate;
|
private LocalDateTime createdDate;
|
||||||
|
|
||||||
private boolean deletable;
|
private boolean deletable;
|
||||||
|
|
||||||
|
/** 공개범위(ALL/PRIVATE). */
|
||||||
|
private String visibility;
|
||||||
|
|
||||||
|
/** 비공개 댓글 여부 — 허용 독자에게는 "비공개" 태그로 표시. */
|
||||||
|
private boolean privateComment;
|
||||||
|
|
||||||
|
/** 비공개 댓글이지만 열람 권한이 없어 "비공개 댓글"로만 노출되는지 여부. */
|
||||||
|
private boolean privatePlaceholder;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ public interface InquiryCommentFacade {
|
|||||||
|
|
||||||
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
|
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
|
||||||
|
|
||||||
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
|
InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, PortalAuthenticatedUser current);
|
||||||
|
|
||||||
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
|
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
|
||||||
|
|
||||||
|
|||||||
+109
-32
@@ -3,15 +3,19 @@ package com.eactive.apim.portal.djb.community.qna.comment.service;
|
|||||||
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
|
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
|
||||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
||||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
|
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
|
||||||
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
|
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -37,6 +41,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
|
|
||||||
private static final String ADMIN_N = "N";
|
private static final String ADMIN_N = "N";
|
||||||
private static final String DEL_N = "N";
|
private static final String DEL_N = "N";
|
||||||
|
private static final String UNKNOWN_WRITER = "(알 수 없음)";
|
||||||
|
|
||||||
private final InquiryService inquiryService;
|
private final InquiryService inquiryService;
|
||||||
private final InquiryCommentService commentService;
|
private final InquiryCommentService commentService;
|
||||||
@@ -51,21 +56,24 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
|
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
|
||||||
inquiryService.getAccessibleInquiry(current, inquiryId);
|
inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||||
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
|
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
|
||||||
Map<String, String> writerNames = resolveWriterNames(comments);
|
Map<String, Writer> writers = resolveWriters(comments);
|
||||||
return comments.stream()
|
return comments.stream()
|
||||||
.map(c -> toDto(c, current, writerNames))
|
.map(c -> toDto(c, current, writers))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current) {
|
public InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, PortalAuthenticatedUser current) {
|
||||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
|
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||||
permissionChecker.assertWritable(inquiry);
|
permissionChecker.assertWritable(inquiry);
|
||||||
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N);
|
// 댓글은 공개(ALL)/비공개(PRIVATE) 2단계만 지원
|
||||||
|
VisibilityScope scope = VisibilityScope.PRIVATE == VisibilityScope.fromString(visibility, VisibilityScope.ALL)
|
||||||
|
? VisibilityScope.PRIVATE : VisibilityScope.ALL;
|
||||||
|
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N, scope);
|
||||||
publishAdminNotification(inquiry, saved, current);
|
publishAdminNotification(inquiry, saved, current);
|
||||||
Map<String, String> writerNames = new HashMap<>();
|
Map<String, Writer> writers = new HashMap<>();
|
||||||
writerNames.put(current.getId(), current.getUserName());
|
writers.put(current.getId(), currentWriter(current));
|
||||||
return toDto(saved, current, writerNames);
|
return toDto(saved, current, writers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -102,7 +110,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
|
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
|
private Map<String, Writer> resolveWriters(List<InquiryComment> comments) {
|
||||||
Set<String> ids = new HashSet<>();
|
Set<String> ids = new HashSet<>();
|
||||||
for (InquiryComment c : comments) {
|
for (InquiryComment c : comments) {
|
||||||
String createdBy = c.getCreatedBy();
|
String createdBy = c.getCreatedBy();
|
||||||
@@ -110,11 +118,11 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
ids.add(createdBy);
|
ids.add(createdBy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Map<String, String> map = new HashMap<>();
|
Map<String, Writer> map = new HashMap<>();
|
||||||
for (String id : ids) {
|
for (String id : ids) {
|
||||||
String name = lookupWriterName(id);
|
Writer writer = lookupWriter(id);
|
||||||
if (name != null && !name.isEmpty()) {
|
if (writer != null && writer.name != null && !writer.name.isEmpty()) {
|
||||||
map.put(id, name);
|
map.put(id, writer);
|
||||||
} else {
|
} else {
|
||||||
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
|
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
|
||||||
}
|
}
|
||||||
@@ -122,23 +130,35 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String lookupWriterName(String createdBy) {
|
private Writer lookupWriter(String createdBy) {
|
||||||
if (isUuid(createdBy)) {
|
if (isUuid(createdBy)) {
|
||||||
String name = portalUserRepository.findById(createdBy)
|
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
|
||||||
.map(PortalUser::getUserName).orElse(null);
|
if (portalUser != null && portalUser.getUserName() != null && !portalUser.getUserName().isEmpty()) {
|
||||||
if (name != null && !name.isEmpty()) {
|
return toWriter(portalUser);
|
||||||
return name;
|
|
||||||
}
|
}
|
||||||
return userInfoRepository.findById(createdBy)
|
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
|
||||||
.map(UserInfo::getUsername).orElse(null);
|
return userInfo != null ? new Writer(userInfo.getUsername(), null, null) : null;
|
||||||
}
|
}
|
||||||
String name = userInfoRepository.findById(createdBy)
|
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
|
||||||
.map(UserInfo::getUsername).orElse(null);
|
if (userInfo != null && userInfo.getUsername() != null && !userInfo.getUsername().isEmpty()) {
|
||||||
if (name != null && !name.isEmpty()) {
|
return new Writer(userInfo.getUsername(), null, null);
|
||||||
return name;
|
|
||||||
}
|
}
|
||||||
return portalUserRepository.findById(createdBy)
|
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
|
||||||
.map(PortalUser::getUserName).orElse(null);
|
return portalUser != null ? toWriter(portalUser) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Writer toWriter(PortalUser portalUser) {
|
||||||
|
PortalOrg org = portalUser.getPortalOrg();
|
||||||
|
return new Writer(portalUser.getUserName(),
|
||||||
|
org != null ? org.getId() : null,
|
||||||
|
org != null ? org.getOrgName() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Writer currentWriter(PortalAuthenticatedUser current) {
|
||||||
|
PortalOrg org = current.getPortalOrg();
|
||||||
|
return new Writer(current.getUserName(),
|
||||||
|
org != null ? org.getId() : null,
|
||||||
|
org != null ? org.getOrgName() : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isUuid(String value) {
|
private boolean isUuid(String value) {
|
||||||
@@ -154,21 +174,50 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
|
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
|
||||||
Map<String, String> writerNames) {
|
Map<String, Writer> writers) {
|
||||||
String writerId = entity.getCreatedBy();
|
String writerId = entity.getCreatedBy();
|
||||||
boolean deletable = writerId != null
|
boolean isAdmin = "Y".equals(entity.getAdminYn());
|
||||||
&& writerId.equals(current.getId())
|
boolean isPrivate = entity.isPrivate();
|
||||||
|
boolean owner = writerId != null && writerId.equals(current.getId());
|
||||||
|
Writer writer = writerId != null ? writers.get(writerId) : null;
|
||||||
|
|
||||||
|
// 비공개 댓글 열람 권한: 관리자 댓글은 대상 아님, 작성자 본인 또는 같은 법인 관리자만
|
||||||
|
boolean canSee = owner || canSeePrivate(current, writer);
|
||||||
|
String scopeName = entity.getVisibility() != null ? entity.getVisibility().name() : VisibilityScope.ALL.name();
|
||||||
|
|
||||||
|
if (isPrivate && !canSee) {
|
||||||
|
// 열람 권한 없는 비공개 댓글 → "비공개 댓글"로만 노출, 작성자 신원 숨김
|
||||||
|
return InquiryCommentDTO.builder()
|
||||||
|
.id(entity.getId())
|
||||||
|
.content("비공개 댓글")
|
||||||
|
.writerId(null)
|
||||||
|
.writerName("비공개")
|
||||||
|
.adminYn(entity.getAdminYn())
|
||||||
|
.createdDate(entity.getCreatedDate())
|
||||||
|
.deletable(false)
|
||||||
|
.visibility(scopeName)
|
||||||
|
.privateComment(true)
|
||||||
|
.privatePlaceholder(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean deletable = owner
|
||||||
&& entity.getInquiry() != null
|
&& entity.getInquiry() != null
|
||||||
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
||||||
|
|
||||||
String writerName;
|
String writerName;
|
||||||
if ("Y".equals(entity.getAdminYn())) {
|
if (isAdmin) {
|
||||||
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
|
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
|
||||||
writerName = "관리자";
|
writerName = "관리자";
|
||||||
} else {
|
} else {
|
||||||
writerName = writerId != null ? writerNames.get(writerId) : null;
|
String base = writer != null ? writer.name : null;
|
||||||
if (writerName == null || writerName.isEmpty()) {
|
String masked = StringMaskingUtil.maskName(base, writerId, current.getId());
|
||||||
writerName = "(알 수 없음)";
|
if (masked == null || masked.isEmpty()) {
|
||||||
|
masked = UNKNOWN_WRITER;
|
||||||
}
|
}
|
||||||
|
writerName = (writer != null && writer.orgName != null && !writer.orgName.isEmpty())
|
||||||
|
? masked + " (" + writer.orgName + ")"
|
||||||
|
: masked;
|
||||||
}
|
}
|
||||||
return InquiryCommentDTO.builder()
|
return InquiryCommentDTO.builder()
|
||||||
.id(entity.getId())
|
.id(entity.getId())
|
||||||
@@ -178,6 +227,34 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
.adminYn(entity.getAdminYn())
|
.adminYn(entity.getAdminYn())
|
||||||
.createdDate(entity.getCreatedDate())
|
.createdDate(entity.getCreatedDate())
|
||||||
.deletable(deletable)
|
.deletable(deletable)
|
||||||
|
.visibility(scopeName)
|
||||||
|
.privateComment(isPrivate)
|
||||||
|
.privatePlaceholder(false)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 비공개 댓글을 볼 수 있는 같은 법인 관리자 여부. */
|
||||||
|
private boolean canSeePrivate(PortalAuthenticatedUser current, Writer writer) {
|
||||||
|
if (writer == null || writer.orgId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (current.getRoleCode() != PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
PortalOrg org = current.getPortalOrg();
|
||||||
|
return org != null && org.getId() != null && org.getId().equals(writer.orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 댓글 작성자 정보(이름/법인). */
|
||||||
|
private static final class Writer {
|
||||||
|
private final String name;
|
||||||
|
private final String orgId;
|
||||||
|
private final String orgName;
|
||||||
|
|
||||||
|
private Writer(String name, String orgId, String orgName) {
|
||||||
|
this.name = name;
|
||||||
|
this.orgId = orgId;
|
||||||
|
this.orgName = orgName;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.common.exception.NotFoundException;
|
|||||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -25,12 +26,13 @@ public class InquiryCommentService {
|
|||||||
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
|
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
|
||||||
}
|
}
|
||||||
|
|
||||||
public InquiryComment create(Inquiry inquiry, String content, String adminYn) {
|
public InquiryComment create(Inquiry inquiry, String content, String adminYn, VisibilityScope visibility) {
|
||||||
InquiryComment comment = new InquiryComment();
|
InquiryComment comment = new InquiryComment();
|
||||||
comment.setInquiry(inquiry);
|
comment.setInquiry(inquiry);
|
||||||
comment.setCommentDetail(content);
|
comment.setCommentDetail(content);
|
||||||
comment.setAdminYn(adminYn);
|
comment.setAdminYn(adminYn);
|
||||||
comment.setDelYn(DEL_N);
|
comment.setDelYn(DEL_N);
|
||||||
|
comment.setVisibility(visibility != null ? visibility : VisibilityScope.ALL);
|
||||||
return repository.save(comment);
|
return repository.save(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6934,6 +6934,37 @@ button.djb-comment-submit:disabled {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.djb-comment-private-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: auto;
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.djb-comment-private-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #FEF3C7;
|
||||||
|
color: #92400E;
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.djb-comment-item--private {
|
||||||
|
background-color: #FFFBEB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.djb-comment-item--private-hidden .djb-comment-body,
|
||||||
|
.djb-comment-item--private-hidden .djb-comment-writer {
|
||||||
|
color: #9CA3AF;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-carousel-section {
|
.hero-carousel-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -15565,6 +15596,87 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-table-row--private {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.list-table-row--private .inquiry-private-label {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title-link--disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-detail-views {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-detail-attach {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.inquiry-detail-attach .inquiry-attach-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #CBD5E1;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #1E40AF;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.inquiry-detail-attach .inquiry-attach-link:hover {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-cell--writer {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.row-cell--writer .inquiry-writer-org {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-visibility-notice {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-table-body .inquiry-status-badge {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-lock-icon {
|
||||||
|
vertical-align: -2px;
|
||||||
|
margin-right: 3px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-detail-visibility {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: #64748B;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.inquiry-detail-visibility--private {
|
||||||
|
color: #c0392b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.inquiry-detail-visibility--private .inquiry-lock-icon {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
.org-register-page {
|
.org-register-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
|
|||||||
const formEl = document.getElementById('djbCommentForm');
|
const formEl = document.getElementById('djbCommentForm');
|
||||||
const inputEl = document.getElementById('djbCommentInput');
|
const inputEl = document.getElementById('djbCommentInput');
|
||||||
const counterEl = document.getElementById('djbCommentCharCounter');
|
const counterEl = document.getElementById('djbCommentCharCounter');
|
||||||
|
const privateEl = document.getElementById('djbCommentPrivate');
|
||||||
|
|
||||||
function getCsrfToken() {
|
function getCsrfToken() {
|
||||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||||
@@ -74,14 +75,25 @@
|
|||||||
}
|
}
|
||||||
comments.forEach(function (c) {
|
comments.forEach(function (c) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
|
var cls = 'djb-comment-item';
|
||||||
|
if (c.adminYn === 'Y') cls += ' djb-comment-item--admin';
|
||||||
|
if (c.privatePlaceholder) cls += ' djb-comment-item--private-hidden';
|
||||||
|
else if (c.privateComment) cls += ' djb-comment-item--private';
|
||||||
|
li.className = cls;
|
||||||
li.setAttribute('data-comment-id', c.id);
|
li.setAttribute('data-comment-id', c.id);
|
||||||
|
|
||||||
|
var writerHtml = (c.adminYn === 'Y')
|
||||||
|
? '<span class="djb-comment-admin-badge">관리자</span>'
|
||||||
|
: '<span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>';
|
||||||
|
// 비공개 댓글(열람 권한 있는 경우)에는 "비공개" 태그 표시
|
||||||
|
var privateTag = (c.privateComment && !c.privatePlaceholder)
|
||||||
|
? '<span class="djb-comment-private-tag">비공개</span>' : '';
|
||||||
|
|
||||||
li.innerHTML = ''
|
li.innerHTML = ''
|
||||||
+ '<div class="djb-comment-meta">'
|
+ '<div class="djb-comment-meta">'
|
||||||
+ ' <div class="djb-comment-meta-left">'
|
+ ' <div class="djb-comment-meta-left">'
|
||||||
+ (c.adminYn === 'Y'
|
+ writerHtml
|
||||||
? ' <span class="djb-comment-admin-badge">관리자</span>'
|
+ privateTag
|
||||||
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
|
|
||||||
+ ' </div>'
|
+ ' </div>'
|
||||||
+ ' <div class="djb-comment-meta-right">'
|
+ ' <div class="djb-comment-meta-right">'
|
||||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||||
@@ -112,9 +124,10 @@
|
|||||||
inputEl.focus();
|
inputEl.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var visibility = (privateEl && privateEl.checked) ? 'PRIVATE' : 'ALL';
|
||||||
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
|
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ content: content })
|
body: JSON.stringify({ content: content, visibility: visibility })
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
|
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
|
||||||
@@ -123,6 +136,7 @@
|
|||||||
})
|
})
|
||||||
.then(function () {
|
.then(function () {
|
||||||
inputEl.value = '';
|
inputEl.value = '';
|
||||||
|
if (privateEl) privateEl.checked = false;
|
||||||
updateCounter();
|
updateCounter();
|
||||||
load();
|
load();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -254,3 +254,41 @@ button.djb-comment-submit {
|
|||||||
color: #6B7280;
|
color: #6B7280;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 260710 댓글 공개범위 ──────────────────────────────
|
||||||
|
// 작성 폼 비공개 토글
|
||||||
|
.djb-comment-private-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: auto;
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비공개 댓글 태그 (열람 권한 있는 사용자에게 표시)
|
||||||
|
.djb-comment-private-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #FEF3C7;
|
||||||
|
color: #92400E;
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비공개 댓글 (열람 권한 있음) 배경 강조
|
||||||
|
.djb-comment-item--private {
|
||||||
|
background-color: #FFFBEB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 열람 권한 없는 비공개 댓글 (placeholder)
|
||||||
|
.djb-comment-item--private-hidden {
|
||||||
|
.djb-comment-body,
|
||||||
|
.djb-comment-writer {
|
||||||
|
color: #9CA3AF;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -692,3 +692,101 @@
|
|||||||
// Uses the same action button styles from .inquiry-actions above
|
// Uses the same action button styles from .inquiry-actions above
|
||||||
// Supports: action-btn-primary, action-btn-secondary
|
// Supports: action-btn-primary, action-btn-secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 260710 공개범위 / 조회수 / 첨부 ──────────────────────────────
|
||||||
|
// 비공개 게시물(목록) — 본인·소속 법인 관리자 외에는 흐리게 + 링크 비활성
|
||||||
|
.list-table-row--private {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.inquiry-private-label {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 접근 권한 없는 비공개 글 제목 — 클릭 불가
|
||||||
|
.notice-title-link--disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 조회수
|
||||||
|
.inquiry-detail-views {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 첨부 다운로드 링크 (이미지 인라인 노출 없이 강제 다운로드)
|
||||||
|
.inquiry-detail-attach {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
|
||||||
|
.inquiry-attach-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #CBD5E1;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #1E40AF;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 작성자 셀: 이름 아래 (법인명) 표기
|
||||||
|
// .row-cell 은 display:flex(가로) 이므로 세로 정렬 위해 column 지정
|
||||||
|
.row-cell--writer {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
.inquiry-writer-org {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 상단 PTL_PROPERTY 공개범위 상한 표기
|
||||||
|
.inquiry-visibility-notice {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 처리상태 배지 — 글자/크기 축소
|
||||||
|
.list-table-body .inquiry-status-badge {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비공개 글 자물쇠 아이콘 (목록 제목 앞)
|
||||||
|
.inquiry-lock-icon {
|
||||||
|
vertical-align: -2px;
|
||||||
|
margin-right: 3px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 헤더 공개범위 표기
|
||||||
|
.inquiry-detail-visibility {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: #64748B;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&--private {
|
||||||
|
color: #c0392b;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.inquiry-lock-icon {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
placeholder="내용"
|
placeholder="내용"
|
||||||
required></textarea>
|
required></textarea>
|
||||||
<div class="djb-comment-form-actions">
|
<div class="djb-comment-form-actions">
|
||||||
|
<label class="djb-comment-private-toggle">
|
||||||
|
<input type="checkbox" id="djbCommentPrivate"> 비공개
|
||||||
|
</label>
|
||||||
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
|
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
|
||||||
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section layout:fragment="contentFragment">
|
<section layout:fragment="contentFragment">
|
||||||
<div class="inquiry-detail-container">
|
<div class="inquiry-detail-container" th:attr="data-inquiry-id=${inquiry.id}">
|
||||||
|
|
||||||
<!-- Inquiry Header: Status Badge and Title -->
|
<!-- Inquiry Header: Status Badge and Title -->
|
||||||
<div class="inquiry-detail-header">
|
<div class="inquiry-detail-header">
|
||||||
@@ -22,10 +22,20 @@
|
|||||||
답변대기
|
답변대기
|
||||||
</span>
|
</span>
|
||||||
<div class="inquiry-header-right">
|
<div class="inquiry-header-right">
|
||||||
<span class="inquiry-detail-writer"
|
<span class="inquiry-detail-writer" th:text="${inquiry.inquirerDisplay}">작성자 (법인명)</span>
|
||||||
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">작성자</span>
|
|
||||||
<span class="inquiry-detail-divider">|</span>
|
<span class="inquiry-detail-divider">|</span>
|
||||||
<span class="inquiry-detail-date" th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
|
<span class="inquiry-detail-date" th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
|
||||||
|
<span class="inquiry-detail-divider">|</span>
|
||||||
|
<span class="inquiry-detail-views" th:text="|조회 ${inquiry.viewCount}|">조회 0</span>
|
||||||
|
<span class="inquiry-detail-divider">|</span>
|
||||||
|
<span class="inquiry-detail-visibility"
|
||||||
|
th:classappend="${inquiry.visibility == 'PRIVATE'} ? ' inquiry-detail-visibility--private'">
|
||||||
|
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor"/>
|
||||||
|
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span th:text="${inquiry.visibilityLabel}">법인공개</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
|
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
|
||||||
@@ -36,6 +46,12 @@
|
|||||||
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
|
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
|
||||||
문의 내용이 여기에 표시됩니다.
|
문의 내용이 여기에 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) -->
|
||||||
|
<div class="inquiry-detail-attach" th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}">
|
||||||
|
<a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link" download>
|
||||||
|
첨부 이미지 다운로드
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin Response Section -->
|
<!-- Admin Response Section -->
|
||||||
@@ -107,6 +123,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
|
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
|
||||||
|
<script th:src="@{/js/djb/inquiry-view.js}"></script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<!-- Form Content -->
|
<!-- Form Content -->
|
||||||
<div class="register-form-container">
|
<div class="register-form-container">
|
||||||
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
|
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
|
||||||
th:object="${inquiry}" method="post" class="register-form">
|
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="register-form">
|
||||||
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
|
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
|
||||||
|
|
||||||
<!-- Subject Field -->
|
<!-- Subject Field -->
|
||||||
@@ -62,6 +62,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility Field -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-label-wrapper label-offset">
|
||||||
|
<span class="form-label-text">공개범위</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-wrapper">
|
||||||
|
<select id="inquiryVisibility" th:field="*{visibility}" class="form-input">
|
||||||
|
<option th:if="${allowAll}" value="ALL">전체공개</option>
|
||||||
|
<option th:if="${allowOrg}" value="ORG">법인공개</option>
|
||||||
|
<option value="PRIVATE">비공개</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-help-text">비공개 글은 본인과 소속 법인 관리자만 열람할 수 있습니다.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Attach Field -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-label-wrapper label-offset">
|
||||||
|
<span class="form-label-text">이미지 첨부</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-wrapper">
|
||||||
|
<input type="file" id="inquiryImage" name="image" class="form-input"
|
||||||
|
accept="image/png,image/jpeg,image/gif">
|
||||||
|
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small>
|
||||||
|
<small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
|
||||||
|
class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,6 +157,15 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageInput = document.getElementById('inquiryImage');
|
||||||
|
if (imageInput && imageInput.files && imageInput.files.length > 0) {
|
||||||
|
const ext = imageInput.files[0].name.toLowerCase().split('.').pop();
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif'].indexOf(ext) === -1) {
|
||||||
|
customPopups.showAlert('이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<div class="table-controls">
|
<div class="table-controls">
|
||||||
<h2 class="total-count">
|
<h2 class="total-count">
|
||||||
총 <strong th:text="${page.totalElements}">0</strong>건
|
총 <strong th:text="${page.totalElements}">0</strong>건
|
||||||
|
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}">
|
||||||
|
공개범위 설정: <span th:text="${visibilityCeiling.label()}">법인공개</span>
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="search-field">
|
<div class="search-field">
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
||||||
<div class="header-cell" style="width: 100px;">작성자</div>
|
<div class="header-cell" style="width: 100px;">작성자</div>
|
||||||
<div class="header-cell" style="width: 120px;">처리상태</div>
|
<div class="header-cell" style="width: 120px;">처리상태</div>
|
||||||
|
<div class="header-cell" style="width: 80px;">조회수</div>
|
||||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,20 +58,33 @@
|
|||||||
<div class="list-table-body">
|
<div class="list-table-body">
|
||||||
<div class="list-table-row"
|
<div class="list-table-row"
|
||||||
th:each="inquiry, status : ${inquiries}"
|
th:each="inquiry, status : ${inquiries}"
|
||||||
th:onclick="'location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\''"
|
th:classappend="${inquiry.privatePlaceholder} ? ' list-table-row--private'"
|
||||||
style="cursor: pointer;">
|
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
||||||
|
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
||||||
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
||||||
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||||
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
|
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
|
||||||
<a th:href="@{/inquiry/detail(id=${inquiry.id})}" class="notice-title-link">
|
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
||||||
|
class="notice-title-link"
|
||||||
|
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
||||||
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
<span th:text="${inquiry.inquirySubject}">질문 제목</span>
|
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor"/>
|
||||||
|
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span>
|
||||||
|
<span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}">질문 제목</span>
|
||||||
<span class="inquiry-comment-count"
|
<span class="inquiry-comment-count"
|
||||||
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
||||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
|
<div class="row-cell row-cell--writer" style="width: 100px;" data-label="작성자">
|
||||||
|
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
||||||
|
<span class="inquiry-writer-org"
|
||||||
|
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
||||||
|
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
||||||
|
</div>
|
||||||
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
||||||
<span class="inquiry-status-badge"
|
<span class="inquiry-status-badge"
|
||||||
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||||
@@ -75,6 +92,7 @@
|
|||||||
답변대기
|
답변대기
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row-cell" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||||
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
||||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user