Q&A 문의 공개범위 기능 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run

- 공개범위 설정 기능 개발(ALL/ORG/PRIVATE) - 게시물/댓글 정교화
- 요청 및 DB 처리 확장 - UI, DTO, Service 계층 업데이트
- 감사 로그 및 조회수 처리 로직 추가
This commit is contained in:
Rinjae
2026-07-13 11:32:34 +09:00
parent 9c01db9d00
commit a97378b84d
21 changed files with 721 additions and 63 deletions
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
@@ -11,4 +11,7 @@ public class InquiryCommentCreateRequest {
@NotBlank(message = "댓글 내용을 입력해주세요.")
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
private String content;
/** 공개범위(ALL=공개, PRIVATE=비공개). 미지정 시 공개. */
private String visibility;
}
@@ -26,4 +26,13 @@ public class InquiryCommentDTO {
private LocalDateTime createdDate;
private boolean deletable;
/** 공개범위(ALL/PRIVATE). */
private String visibility;
/** 비공개 댓글 여부 — 허용 독자에게는 "비공개" 태그로 표시. */
private boolean privateComment;
/** 비공개 댓글이지만 열람 권한이 없어 "비공개 댓글"로만 노출되는지 여부. */
private boolean privatePlaceholder;
}
@@ -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);
@@ -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;
}
}
}
@@ -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);
}
+112
View File
@@ -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>