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.UserTypeUtil;
|
||||
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.util.List;
|
||||
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.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import javax.validation.Valid;
|
||||
@@ -56,6 +59,7 @@ public class InquiryController {
|
||||
model.addAttribute("inquiries", inquiries.getContent());
|
||||
model.addAttribute("page", inquiries);
|
||||
model.addAttribute("commentCounts", commentCounts);
|
||||
model.addAttribute("visibilityCeiling", inquiryFacade.getVisibilityCeiling());
|
||||
return "apps/community/mainInquiryList";
|
||||
}
|
||||
|
||||
@@ -75,13 +79,25 @@ public class InquiryController {
|
||||
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 등록 페이지
|
||||
*/
|
||||
@GetMapping("/new")
|
||||
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());
|
||||
addVisibilityOptions(model);
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
|
||||
@@ -93,14 +109,23 @@ public class InquiryController {
|
||||
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||
model.addAttribute("inquiry", inquiry);
|
||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||
addVisibilityOptions(model);
|
||||
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 등록 요청
|
||||
*/
|
||||
@PostMapping
|
||||
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||
RedirectAttributes redirectAttributes,
|
||||
Model model) throws IOException {
|
||||
if (bindingResult.hasErrors()) {
|
||||
@@ -108,7 +133,7 @@ public class InquiryController {
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
|
||||
inquiryFacade.createInquiry(inquiryDTO);
|
||||
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||
return REDIRECT_INQUIRY;
|
||||
@@ -119,11 +144,12 @@ public class InquiryController {
|
||||
*/
|
||||
@PostMapping("/edit")
|
||||
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||
RedirectAttributes redirectAttributes) throws IOException {
|
||||
if (bindingResult.hasErrors()) {
|
||||
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 수정이 완료되었습니다.");
|
||||
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
|
||||
}
|
||||
|
||||
@@ -51,4 +51,22 @@ public class InquiryDTO {
|
||||
|
||||
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
|
||||
public interface InquiryMapper {
|
||||
|
||||
// visibility(공개범위)는 facade 에서 상한 clamp 후 수동 세팅, viewCount 는 서버가 관리
|
||||
@Mapping(target = "visibility", ignore = true)
|
||||
@Mapping(target = "viewCount", ignore = true)
|
||||
Inquiry toEntity(InquiryDTO inquiry);
|
||||
|
||||
@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.InquirySearch;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import java.io.IOException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface InquiryFacade {
|
||||
|
||||
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
|
||||
|
||||
/** 문의 공개범위 상한(폼 옵션 노출 제어용). */
|
||||
VisibilityScope getVisibilityCeiling();
|
||||
|
||||
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
|
||||
|
||||
InquiryDTO getMyInquiry(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);
|
||||
}
|
||||
|
||||
+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.StringMaskingUtil;
|
||||
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.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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 InquiryMapper inquiryMapper;
|
||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||
private final FileService fileService;
|
||||
|
||||
@Override
|
||||
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);
|
||||
|
||||
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||
|
||||
return inquiriesPage.map(inquiry -> {
|
||||
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||
if (dto != null && dto.getInquirer() != null) {
|
||||
@@ -48,8 +65,20 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
inquiry.getInquirer().getId(),
|
||||
user.getId()
|
||||
));
|
||||
PortalOrg org = inquiry.getInquirer().getPortalOrg();
|
||||
if (org != null) {
|
||||
dto.setInquirerOrgName(org.getOrgName());
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -81,14 +110,32 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
@Override
|
||||
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String 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
|
||||
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
|
||||
public void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||
inquiry.setInquirer(current);
|
||||
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||
if (hasFile(image)) {
|
||||
inquiry.setAttachFile(storeImage(null, image));
|
||||
}
|
||||
inquiryService.createInquiry(inquiry);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
@@ -99,17 +146,91 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
}
|
||||
|
||||
@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);
|
||||
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
|
||||
|
||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||
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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementViewCount(PortalAuthenticatedUser user, String id) {
|
||||
// 접근 가능한 게시물만 조회수 증가
|
||||
inquiryService.getAccessibleInquiry(user, id);
|
||||
inquiryService.incrementViewCount(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VisibilityScope getVisibilityCeiling() {
|
||||
return inquiryService.getVisibilityCeiling();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUserInquiry(PortalAuthenticatedUser user, String 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.portaluser.entity.PortalUser;
|
||||
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.VisibilityScope;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -19,10 +21,19 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class InquiryService {
|
||||
|
||||
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.portalPropertyService = portalPropertyService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +86,7 @@ public class InquiryService {
|
||||
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
|
||||
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
|
||||
inquiry.setAttachFile(updatedInquiry.getAttachFile());
|
||||
inquiry.setVisibility(updatedInquiry.getVisibility());
|
||||
return inquiryRepository.save(inquiry);
|
||||
}
|
||||
|
||||
@@ -112,12 +124,27 @@ public class InquiryService {
|
||||
if (inquirer == null || user == null) {
|
||||
return false;
|
||||
}
|
||||
// 작성자 본인은 공개범위와 무관하게 항상 접근 가능
|
||||
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||
return true;
|
||||
}
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
return false;
|
||||
VisibilityScope effective = effectiveVisibility(inquiry);
|
||||
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 inquirerOrg = inquirer.getPortalOrg();
|
||||
return userOrg != null && inquirerOrg != null
|
||||
@@ -125,4 +152,30 @@ public class InquiryService {
|
||||
&& 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,
|
||||
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -11,4 +11,7 @@ public class InquiryCommentCreateRequest {
|
||||
@NotBlank(message = "댓글 내용을 입력해주세요.")
|
||||
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
|
||||
private String content;
|
||||
|
||||
/** 공개범위(ALL=공개, PRIVATE=비공개). 미지정 시 공개. */
|
||||
private String visibility;
|
||||
}
|
||||
|
||||
+9
@@ -26,4 +26,13 @@ public class InquiryCommentDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
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);
|
||||
|
||||
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
|
||||
InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, 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.common.exception.NotFoundException;
|
||||
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.repository.InquiryCommentRepository;
|
||||
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.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.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
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.user.entity.UserInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -37,6 +41,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
|
||||
private static final String ADMIN_N = "N";
|
||||
private static final String DEL_N = "N";
|
||||
private static final String UNKNOWN_WRITER = "(알 수 없음)";
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryCommentService commentService;
|
||||
@@ -51,21 +56,24 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
|
||||
inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
|
||||
Map<String, String> writerNames = resolveWriterNames(comments);
|
||||
Map<String, Writer> writers = resolveWriters(comments);
|
||||
return comments.stream()
|
||||
.map(c -> toDto(c, current, writerNames))
|
||||
.map(c -> toDto(c, current, writers))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@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);
|
||||
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);
|
||||
Map<String, String> writerNames = new HashMap<>();
|
||||
writerNames.put(current.getId(), current.getUserName());
|
||||
return toDto(saved, current, writerNames);
|
||||
Map<String, Writer> writers = new HashMap<>();
|
||||
writers.put(current.getId(), currentWriter(current));
|
||||
return toDto(saved, current, writers);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -102,7 +110,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
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<>();
|
||||
for (InquiryComment c : comments) {
|
||||
String createdBy = c.getCreatedBy();
|
||||
@@ -110,11 +118,11 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
ids.add(createdBy);
|
||||
}
|
||||
}
|
||||
Map<String, String> map = new HashMap<>();
|
||||
Map<String, Writer> map = new HashMap<>();
|
||||
for (String id : ids) {
|
||||
String name = lookupWriterName(id);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
map.put(id, name);
|
||||
Writer writer = lookupWriter(id);
|
||||
if (writer != null && writer.name != null && !writer.name.isEmpty()) {
|
||||
map.put(id, writer);
|
||||
} else {
|
||||
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
|
||||
}
|
||||
@@ -122,23 +130,35 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
return map;
|
||||
}
|
||||
|
||||
private String lookupWriterName(String createdBy) {
|
||||
private Writer lookupWriter(String createdBy) {
|
||||
if (isUuid(createdBy)) {
|
||||
String name = portalUserRepository.findById(createdBy)
|
||||
.map(PortalUser::getUserName).orElse(null);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
return name;
|
||||
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
|
||||
if (portalUser != null && portalUser.getUserName() != null && !portalUser.getUserName().isEmpty()) {
|
||||
return toWriter(portalUser);
|
||||
}
|
||||
return userInfoRepository.findById(createdBy)
|
||||
.map(UserInfo::getUsername).orElse(null);
|
||||
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
|
||||
return userInfo != null ? new Writer(userInfo.getUsername(), null, null) : null;
|
||||
}
|
||||
String name = userInfoRepository.findById(createdBy)
|
||||
.map(UserInfo::getUsername).orElse(null);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
return name;
|
||||
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
|
||||
if (userInfo != null && userInfo.getUsername() != null && !userInfo.getUsername().isEmpty()) {
|
||||
return new Writer(userInfo.getUsername(), null, null);
|
||||
}
|
||||
return portalUserRepository.findById(createdBy)
|
||||
.map(PortalUser::getUserName).orElse(null);
|
||||
PortalUser portalUser = portalUserRepository.findById(createdBy).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) {
|
||||
@@ -154,21 +174,50 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
}
|
||||
|
||||
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
|
||||
Map<String, String> writerNames) {
|
||||
Map<String, Writer> writers) {
|
||||
String writerId = entity.getCreatedBy();
|
||||
boolean deletable = writerId != null
|
||||
&& writerId.equals(current.getId())
|
||||
boolean isAdmin = "Y".equals(entity.getAdminYn());
|
||||
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
|
||||
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
||||
|
||||
String writerName;
|
||||
if ("Y".equals(entity.getAdminYn())) {
|
||||
if (isAdmin) {
|
||||
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
|
||||
writerName = "관리자";
|
||||
} else {
|
||||
writerName = writerId != null ? writerNames.get(writerId) : null;
|
||||
if (writerName == null || writerName.isEmpty()) {
|
||||
writerName = "(알 수 없음)";
|
||||
String base = writer != null ? writer.name : null;
|
||||
String masked = StringMaskingUtil.maskName(base, writerId, current.getId());
|
||||
if (masked == null || masked.isEmpty()) {
|
||||
masked = UNKNOWN_WRITER;
|
||||
}
|
||||
writerName = (writer != null && writer.orgName != null && !writer.orgName.isEmpty())
|
||||
? masked + " (" + writer.orgName + ")"
|
||||
: masked;
|
||||
}
|
||||
return InquiryCommentDTO.builder()
|
||||
.id(entity.getId())
|
||||
@@ -178,6 +227,34 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
.adminYn(entity.getAdminYn())
|
||||
.createdDate(entity.getCreatedDate())
|
||||
.deletable(deletable)
|
||||
.visibility(scopeName)
|
||||
.privateComment(isPrivate)
|
||||
.privatePlaceholder(false)
|
||||
.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.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -25,12 +26,13 @@ public class InquiryCommentService {
|
||||
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();
|
||||
comment.setInquiry(inquiry);
|
||||
comment.setCommentDetail(content);
|
||||
comment.setAdminYn(adminYn);
|
||||
comment.setDelYn(DEL_N);
|
||||
comment.setVisibility(visibility != null ? visibility : VisibilityScope.ALL);
|
||||
return repository.save(comment);
|
||||
}
|
||||
|
||||
|
||||
@@ -6934,6 +6934,37 @@ button.djb-comment-submit:disabled {
|
||||
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 {
|
||||
position: relative;
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
|
||||
const formEl = document.getElementById('djbCommentForm');
|
||||
const inputEl = document.getElementById('djbCommentInput');
|
||||
const counterEl = document.getElementById('djbCommentCharCounter');
|
||||
const privateEl = document.getElementById('djbCommentPrivate');
|
||||
|
||||
function getCsrfToken() {
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
@@ -74,14 +75,25 @@
|
||||
}
|
||||
comments.forEach(function (c) {
|
||||
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);
|
||||
|
||||
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 = ''
|
||||
+ '<div class="djb-comment-meta">'
|
||||
+ ' <div class="djb-comment-meta-left">'
|
||||
+ (c.adminYn === 'Y'
|
||||
? ' <span class="djb-comment-admin-badge">관리자</span>'
|
||||
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
|
||||
+ writerHtml
|
||||
+ privateTag
|
||||
+ ' </div>'
|
||||
+ ' <div class="djb-comment-meta-right">'
|
||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||
@@ -112,9 +124,10 @@
|
||||
inputEl.focus();
|
||||
return;
|
||||
}
|
||||
var visibility = (privateEl && privateEl.checked) ? 'PRIVATE' : 'ALL';
|
||||
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: content })
|
||||
body: JSON.stringify({ content: content, visibility: visibility })
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
|
||||
@@ -123,6 +136,7 @@
|
||||
})
|
||||
.then(function () {
|
||||
inputEl.value = '';
|
||||
if (privateEl) privateEl.checked = false;
|
||||
updateCounter();
|
||||
load();
|
||||
})
|
||||
|
||||
@@ -254,3 +254,41 @@ button.djb-comment-submit {
|
||||
color: #6B7280;
|
||||
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
|
||||
// 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="내용"
|
||||
required></textarea>
|
||||
<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>
|
||||
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</section>
|
||||
|
||||
<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 -->
|
||||
<div class="inquiry-detail-header">
|
||||
@@ -22,10 +22,20 @@
|
||||
답변대기
|
||||
</span>
|
||||
<div class="inquiry-header-right">
|
||||
<span class="inquiry-detail-writer"
|
||||
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">작성자</span>
|
||||
<span class="inquiry-detail-writer" th:text="${inquiry.inquirerDisplay}">작성자 (법인명)</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-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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Admin Response Section -->
|
||||
@@ -107,6 +123,7 @@
|
||||
});
|
||||
</script>
|
||||
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
|
||||
<script th:src="@{/js/djb/inquiry-view.js}"></script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- Form Content -->
|
||||
<div class="register-form-container">
|
||||
<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}">
|
||||
|
||||
<!-- Subject Field -->
|
||||
@@ -62,6 +62,35 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +157,15 @@
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<div class="table-controls">
|
||||
<h2 class="total-count">
|
||||
총 <strong th:text="${page.totalElements}">0</strong>건
|
||||
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}">
|
||||
공개범위 설정: <span th:text="${visibilityCeiling.label()}">법인공개</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="search-field">
|
||||
@@ -47,6 +50,7 @@
|
||||
<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: 120px;">처리상태</div>
|
||||
<div class="header-cell" style="width: 80px;">조회수</div>
|
||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||
</div>
|
||||
|
||||
@@ -54,20 +58,33 @@
|
||||
<div class="list-table-body">
|
||||
<div class="list-table-row"
|
||||
th:each="inquiry, status : ${inquiries}"
|
||||
th:onclick="'location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\''"
|
||||
style="cursor: pointer;">
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' list-table-row--private'"
|
||||
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"
|
||||
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="제목">
|
||||
<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 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"
|
||||
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||
</a>
|
||||
</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="처리상태">
|
||||
<span class="inquiry-status-badge"
|
||||
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||
@@ -75,6 +92,7 @@
|
||||
답변대기
|
||||
</span>
|
||||
</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="등록일"
|
||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user