검증게시판 기능 추가

This commit is contained in:
Macbook
2026-05-27 15:41:19 +09:00
parent 7a0af36b9b
commit 63693d47c0
57 changed files with 4480 additions and 14 deletions
@@ -114,6 +114,8 @@ public class AppSettingsService {
int cols = Integer.parseInt(d.get("tradeAlertPopupGridCols").toString());
s.setTradeAlertPopupGridCols(Math.min(4, Math.max(2, cols)));
}
if (d.containsKey("verificationIssueNotify")) s.setVerificationIssueNotify(
Boolean.parseBoolean(d.get("verificationIssueNotify").toString()));
if (d.containsKey("liveStrategyCheck")) s.setLiveStrategyCheck(
Boolean.parseBoolean(d.get("liveStrategyCheck").toString()));
if (d.containsKey("liveStrategyId")) {
@@ -196,6 +198,7 @@ public class AppSettingsService {
m.put("tradeAlertPopupPosition", s.getTradeAlertPopupPosition() != null ? s.getTradeAlertPopupPosition() : "right");
m.put("tradeAlertPopupLayout", s.getTradeAlertPopupLayout() != null ? s.getTradeAlertPopupLayout() : "stack");
m.put("tradeAlertPopupGridCols", s.getTradeAlertPopupGridCols() != null ? s.getTradeAlertPopupGridCols() : 2);
m.put("verificationIssueNotify", s.getVerificationIssueNotify() != null ? s.getVerificationIssueNotify() : true);
m.put("liveStrategyCheck", s.getLiveStrategyCheck() != null ? s.getLiveStrategyCheck() : false);
m.put("liveStrategyId", s.getLiveStrategyId());
m.put("liveExecutionType", s.getLiveExecutionType() != null ? s.getLiveExecutionType() : "CANDLE_CLOSE");
@@ -0,0 +1,98 @@
package com.goldenchart.service;
import com.goldenchart.dto.VerificationIssueCommentDto;
import com.goldenchart.entity.GcVerificationIssueComment;
import com.goldenchart.repository.GcVerificationIssueCommentRepository;
import com.goldenchart.repository.GcVerificationIssueRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class VerificationIssueCommentService {
private final GcVerificationIssueCommentRepository commentRepository;
private final GcVerificationIssueRepository issueRepository;
public List<VerificationIssueCommentDto> listByIssue(Long issueId) {
return commentRepository.findByIssueIdOrderByCreatedAtAsc(issueId).stream()
.map(this::toDto)
.toList();
}
@Transactional
public VerificationIssueCommentDto create(
Long issueId,
VerificationIssueCommentDto dto,
Long userId,
String deviceId) {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("issue not found: " + issueId);
}
GcVerificationIssueComment entity = GcVerificationIssueComment.builder()
.issueId(issueId)
.content(requireContent(dto.getContent()))
.authorName(trimAuthor(dto.getAuthorName()))
.userId(userId)
.deviceId(deviceId)
.build();
return toDto(commentRepository.save(entity));
}
@Transactional
public Optional<VerificationIssueCommentDto> update(Long issueId, Long commentId, VerificationIssueCommentDto dto) {
return commentRepository.findById(commentId)
.filter(c -> issueId.equals(c.getIssueId()))
.map(c -> {
c.setContent(requireContent(dto.getContent()));
if (dto.getAuthorName() != null) {
c.setAuthorName(trimAuthor(dto.getAuthorName()));
}
return toDto(commentRepository.save(c));
});
}
@Transactional
public boolean delete(Long issueId, Long commentId) {
return commentRepository.findById(commentId)
.filter(c -> issueId.equals(c.getIssueId()))
.map(c -> {
commentRepository.delete(c);
return true;
})
.orElse(false);
}
private VerificationIssueCommentDto toDto(GcVerificationIssueComment e) {
return VerificationIssueCommentDto.builder()
.id(e.getId())
.issueId(e.getIssueId())
.content(e.getContent())
.authorName(e.getAuthorName())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private String trimContent(String content) {
if (content == null) return "";
return content.trim();
}
private String requireContent(String content) {
String trimmed = trimContent(content);
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("comment content required");
}
return trimmed;
}
private String trimAuthor(String name) {
if (name == null || name.isBlank()) return null;
return name.trim();
}
}
@@ -0,0 +1,250 @@
package com.goldenchart.service;
import com.goldenchart.dto.VerificationIssueImageDto;
import com.goldenchart.entity.GcVerificationIssueImage;
import com.goldenchart.repository.GcVerificationIssueImageRepository;
import com.goldenchart.repository.GcVerificationIssueRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Service
@Slf4j
public class VerificationIssueImageService {
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"
);
private final GcVerificationIssueImageRepository imageRepository;
private final GcVerificationIssueRepository issueRepository;
private final Path uploadRoot;
private final long maxFileSizeBytes;
private final int maxImagesPerIssue;
public VerificationIssueImageService(
GcVerificationIssueImageRepository imageRepository,
GcVerificationIssueRepository issueRepository,
@Value("${goldenchart.verification.upload-dir:data/verification-images}") String uploadDir,
@Value("${goldenchart.verification.max-image-size-mb:5}") int maxImageSizeMb,
@Value("${goldenchart.verification.max-images-per-issue:30}") int maxImagesPerIssue) throws IOException {
this.imageRepository = imageRepository;
this.issueRepository = issueRepository;
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
this.maxFileSizeBytes = maxImageSizeMb * 1024L * 1024L;
this.maxImagesPerIssue = maxImagesPerIssue;
Files.createDirectories(this.uploadRoot);
}
public List<VerificationIssueImageDto> listByIssue(Long issueId) {
return imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).stream()
.map(this::toDto)
.toList();
}
@Transactional
public List<VerificationIssueImageDto> upload(Long issueId, List<MultipartFile> files) throws IOException {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("issue not found: " + issueId);
}
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("no files");
}
long current = imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).size();
if (current + files.size() > maxImagesPerIssue) {
throw new IllegalArgumentException("max images exceeded: " + maxImagesPerIssue);
}
Path issueDir = uploadRoot.resolve("issue_" + issueId);
Files.createDirectories(issueDir);
int sort = (int) current;
List<VerificationIssueImageDto> saved = new java.util.ArrayList<>();
for (MultipartFile file : files) {
if (file == null || file.isEmpty()) continue;
byte[] data = file.getBytes();
String contentType = resolveContentType(file, data);
validateFile(data.length, contentType);
String original = sanitizeFileName(file.getOriginalFilename());
String storedName = UUID.randomUUID() + "_" + original;
Path target = issueDir.resolve(storedName);
Files.write(target, data);
String relative = uploadRoot.relativize(target).toString().replace('\\', '/');
GcVerificationIssueImage entity = GcVerificationIssueImage.builder()
.issueId(issueId)
.fileName(original)
.contentType(contentType)
.storagePath(relative)
.fileSize((long) data.length)
.sortOrder(sort++)
.build();
saved.add(toDto(imageRepository.save(entity)));
}
if (saved.isEmpty()) {
throw new IllegalArgumentException("no valid image files");
}
return saved;
}
public Optional<Resource> loadFile(Long issueId, Long imageId) {
return imageRepository.findById(imageId)
.filter(img -> issueId.equals(img.getIssueId()))
.map(img -> {
try {
Path path = uploadRoot.resolve(img.getStoragePath()).normalize();
if (!path.startsWith(uploadRoot) || !Files.exists(path)) {
return null;
}
Resource resource = new UrlResource(path.toUri());
return resource.exists() && resource.isReadable() ? resource : null;
} catch (Exception e) {
log.warn("image load failed id={}: {}", imageId, e.getMessage());
return null;
}
});
}
public Optional<String> contentType(Long issueId, Long imageId) {
return imageRepository.findById(imageId)
.filter(img -> issueId.equals(img.getIssueId()))
.map(GcVerificationIssueImage::getContentType);
}
@Transactional
public boolean delete(Long issueId, Long imageId) throws IOException {
Optional<GcVerificationIssueImage> opt = imageRepository.findById(imageId)
.filter(img -> issueId.equals(img.getIssueId()));
if (opt.isEmpty()) return false;
GcVerificationIssueImage img = opt.get();
deletePhysicalFile(img.getStoragePath());
imageRepository.delete(img);
return true;
}
@Transactional
public void deleteAllForIssue(Long issueId) throws IOException {
List<GcVerificationIssueImage> images =
imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId);
for (GcVerificationIssueImage img : images) {
deletePhysicalFile(img.getStoragePath());
}
imageRepository.deleteByIssueId(issueId);
Path issueDir = uploadRoot.resolve("issue_" + issueId);
if (Files.isDirectory(issueDir)) {
try { Files.deleteIfExists(issueDir); } catch (IOException ignored) { /* ok */ }
}
}
private void validateFile(long sizeBytes, String contentType) {
if (contentType == null) {
throw new IllegalArgumentException("unsupported image type");
}
if (sizeBytes > maxFileSizeBytes) {
throw new IllegalArgumentException("file too large");
}
}
private String resolveContentType(MultipartFile file, byte[] data) {
String type = file.getContentType();
if (type != null && !type.isBlank()) {
type = type.split(";")[0].trim().toLowerCase();
if ("image/jpg".equals(type) || "image/pjpeg".equals(type)) {
return "image/jpeg";
}
if ("image/x-png".equals(type)) {
return "image/png";
}
if (ALLOWED_TYPES.contains(type)) {
return type;
}
// application/octet-stream 등 — 매직 바이트로 판별
if ("application/octet-stream".equals(type) || type.startsWith("image/")) {
String sniffed = sniffImageType(data);
if (sniffed != null) return sniffed;
}
}
String name = file.getOriginalFilename();
if (name != null) {
String lower = name.toLowerCase();
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".bmp")) return "image/bmp";
}
return sniffImageType(data);
}
private String sniffImageType(byte[] header) {
if (header == null) return null;
if (header.length >= 8
&& header[0] == (byte) 0x89 && header[1] == 0x50
&& header[2] == 0x4E && header[3] == 0x47) {
return "image/png";
}
if (header.length >= 3
&& header[0] == (byte) 0xFF && header[1] == (byte) 0xD8 && header[2] == (byte) 0xFF) {
return "image/jpeg";
}
if (header.length >= 6
&& header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46) {
return "image/gif";
}
if (header.length >= 12
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
&& header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50) {
return "image/webp";
}
if (header.length >= 2 && header[0] == 0x42 && header[1] == 0x4D) {
return "image/bmp";
}
return null;
}
private void deletePhysicalFile(String storagePath) throws IOException {
Path path = uploadRoot.resolve(storagePath).normalize();
if (path.startsWith(uploadRoot)) {
Files.deleteIfExists(path);
}
}
private String sanitizeFileName(String name) {
if (name == null || name.isBlank()) return "image.png";
String base = Paths.get(name).getFileName().toString();
return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_");
}
private VerificationIssueImageDto toDto(GcVerificationIssueImage e) {
return VerificationIssueImageDto.builder()
.id(e.getId())
.issueId(e.getIssueId())
.fileName(e.getFileName())
.contentType(e.getContentType())
.fileSize(e.getFileSize())
.sortOrder(e.getSortOrder())
.url("/verification-issues/" + e.getIssueId() + "/images/" + e.getId() + "/file")
.createdAt(e.getCreatedAt())
.build();
}
}
@@ -0,0 +1,93 @@
package com.goldenchart.service;
import com.goldenchart.dto.VerificationIssueDto;
import com.goldenchart.entity.GcVerificationIssue;
import com.goldenchart.entity.VerificationIssueStage;
import com.goldenchart.repository.GcVerificationIssueRepository;
import com.goldenchart.websocket.VerificationIssueEventBroker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class VerificationIssueService {
private final GcVerificationIssueRepository repository;
private final VerificationIssueImageService imageService;
private final VerificationIssueEventBroker eventBroker;
public List<VerificationIssueDto> list(VerificationIssueStage stage) {
List<GcVerificationIssue> entities = stage != null
? repository.findByStageOrderByUpdatedAtDesc(stage)
: repository.findAllByOrderByUpdatedAtDesc();
return entities.stream().map(this::toDto).toList();
}
public Optional<VerificationIssueDto> findById(Long id) {
return repository.findById(id).map(this::toDto);
}
@Transactional
public VerificationIssueDto save(VerificationIssueDto dto, Long userId, String deviceId) {
GcVerificationIssue entity;
boolean isNew = dto.getId() == null;
VerificationIssueStage previousStage = null;
if (dto.getId() != null) {
entity = repository.findById(dto.getId()).orElseGet(GcVerificationIssue::new);
if (entity.getId() != null) {
previousStage = entity.getStage();
}
} else {
entity = new GcVerificationIssue();
entity.setUserId(userId);
entity.setDeviceId(deviceId);
}
entity.setTitle(dto.getTitle() != null ? dto.getTitle().trim() : "제목 없음");
entity.setContent(dto.getContent() != null ? dto.getContent() : "");
String path = dto.getReproductionPath();
entity.setReproductionPath(path != null && !path.isBlank() ? path.trim() : null);
entity.setStage(dto.getStage() != null ? dto.getStage() : VerificationIssueStage.REGISTERED);
GcVerificationIssue saved = repository.save(entity);
VerificationIssueDto result = toDto(saved);
if (isNew) {
eventBroker.publishCreated(result, deviceId);
} else if (previousStage != null && previousStage != saved.getStage()) {
eventBroker.publishStageChanged(result, previousStage, deviceId);
}
return result;
}
@Transactional
public void delete(Long id) {
try {
imageService.deleteAllForIssue(id);
} catch (IOException e) {
log.warn("issue image cleanup failed id={}: {}", id, e.getMessage());
}
repository.deleteById(id);
}
private VerificationIssueDto toDto(GcVerificationIssue e) {
return VerificationIssueDto.builder()
.id(e.getId())
.title(e.getTitle())
.content(e.getContent())
.reproductionPath(e.getReproductionPath())
.stage(e.getStage())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}