diff --git a/backend/Dockerfile b/backend/Dockerfile index 8fdac02..e19e8fd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,15 +28,13 @@ RUN mvn -f backend/pom.xml package -DskipTests -q FROM eclipse-temurin:21-jre-alpine AS production WORKDIR /app -RUN addgroup -S spring && adduser -S spring -G spring -USER spring +RUN addgroup -S spring && adduser -S spring -G spring \ + && apk add --no-cache su-exec COPY --from=builder /build/backend/target/goldenchart-backend-*.jar app.jar +COPY backend/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh EXPOSE 8080 -ENTRYPOINT ["java", \ - "-XX:+UseContainerSupport", \ - "-XX:MaxRAMPercentage=75.0", \ - "-Djava.security.egd=file:/dev/./urandom", \ - "-jar", "app.jar"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..89ae86f --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +UPLOAD_DIR="${GC_VERIFICATION_UPLOAD_DIR:-/app/data/verification-images}" +mkdir -p "$UPLOAD_DIR" +chown -R spring:spring "$UPLOAD_DIR" + +exec su-exec spring java \ + -XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -Djava.security.egd=file:/dev/./urandom \ + -jar app.jar diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index ed35f72..8b81549 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -7,7 +7,7 @@ public final class MenuIds { private MenuIds() {} public static final List ALL = List.of( - "dashboard", "chart", "paper", "virtual", "trend-search", "strategy", "strategy-editor", "backtest", "notifications", "settings", + "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_paper", "settings_trend-search", "settings_alert", "settings_network", "settings_admin" diff --git a/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java b/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java index 8dfec04..73a89f8 100644 --- a/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java +++ b/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java @@ -14,6 +14,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo * * 구독 채널 (명세서 5.2): * /sub/charts/{market}/{type} — 실시간 캔들 배포 + * /sub/verification-issues/events — 검증 이슈 등록·단계 변경 * * 발행 채널: * /pub/... — 클라이언트 → 서버 (향후 확장용) diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationIssueCommentController.java b/backend/src/main/java/com/goldenchart/controller/VerificationIssueCommentController.java new file mode 100644 index 0000000..d27e098 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/VerificationIssueCommentController.java @@ -0,0 +1,60 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.VerificationIssueCommentDto; +import com.goldenchart.service.VerificationIssueCommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/verification-issues/{issueId}/comments") +@RequiredArgsConstructor +public class VerificationIssueCommentController { + + private final VerificationIssueCommentService service; + + @GetMapping + public ResponseEntity> list(@PathVariable Long issueId) { + return ResponseEntity.ok(service.listByIssue(issueId)); + } + + @PostMapping + public ResponseEntity create( + @PathVariable Long issueId, + @RequestBody VerificationIssueCommentDto dto, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + try { + return ResponseEntity.ok(service.create(issueId, dto, parseUserId(userIdHeader), deviceId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PutMapping("/{commentId}") + public ResponseEntity update( + @PathVariable Long issueId, + @PathVariable Long commentId, + @RequestBody VerificationIssueCommentDto dto) { + return service.update(issueId, commentId, dto) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity delete( + @PathVariable Long issueId, + @PathVariable Long commentId) { + if (!service.delete(issueId, commentId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.noContent().build(); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationIssueController.java b/backend/src/main/java/com/goldenchart/controller/VerificationIssueController.java new file mode 100644 index 0000000..f6be253 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/VerificationIssueController.java @@ -0,0 +1,60 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.VerificationIssueDto; +import com.goldenchart.entity.VerificationIssueStage; +import com.goldenchart.service.VerificationIssueService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/verification-issues") +@RequiredArgsConstructor +public class VerificationIssueController { + + private final VerificationIssueService service; + + @GetMapping + public ResponseEntity> list( + @RequestParam(value = "stage", required = false) String stage) { + VerificationIssueStage parsed = parseStage(stage); + return ResponseEntity.ok(service.list(parsed)); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable Long id) { + return service.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity save( + @RequestBody VerificationIssueDto dto, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } + + private VerificationIssueStage parseStage(String stage) { + if (stage == null || stage.isBlank()) return null; + try { + return VerificationIssueStage.valueOf(stage); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java b/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java new file mode 100644 index 0000000..dc0c2b9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java @@ -0,0 +1,87 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.VerificationIssueImageDto; +import com.goldenchart.service.VerificationIssueImageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/verification-issues/{issueId}/images") +@RequiredArgsConstructor +@Slf4j +public class VerificationIssueImageController { + + private final VerificationIssueImageService service; + + @GetMapping + public ResponseEntity> list(@PathVariable Long issueId) { + return ResponseEntity.ok(service.listByIssue(issueId)); + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity upload( + @PathVariable Long issueId, + @RequestParam("files") List files) { + try { + return ResponseEntity.ok(service.upload(issueId, files)); + } catch (IllegalArgumentException e) { + log.warn("[VerificationImage] upload rejected issueId={}: {}", issueId, e.getMessage()); + return ResponseEntity.badRequest().body(Map.of("message", toUserMessage(e.getMessage()))); + } catch (IOException e) { + log.error("[VerificationImage] upload io error issueId={}", issueId, e); + return ResponseEntity.internalServerError() + .body(Map.of("message", "이미지 저장에 실패했습니다. 잠시 후 다시 시도하세요.")); + } + } + + private static String toUserMessage(String code) { + if (code == null) return "업로드할 수 없습니다."; + return switch (code) { + case "unsupported image type" -> "지원하지 않는 이미지 형식입니다. (jpeg, png, gif, webp, bmp)"; + case "file too large" -> "이미지 크기는 파일당 5MB 이하여야 합니다."; + case "no files", "no valid image files" -> "업로드할 이미지가 없습니다."; + default -> code.startsWith("max images exceeded") + ? "이슈당 첨부 이미지는 최대 30장까지입니다." + : code.startsWith("issue not found") ? "검증 이슈를 찾을 수 없습니다." : code; + }; + } + + @GetMapping("/{imageId}/file") + public ResponseEntity file( + @PathVariable Long issueId, + @PathVariable Long imageId) { + return service.loadFile(issueId, imageId) + .map(resource -> { + String type = service.contentType(issueId, imageId).orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(type)) + .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600") + .body(resource); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{imageId}") + public ResponseEntity delete( + @PathVariable Long issueId, + @PathVariable Long imageId) { + try { + if (!service.delete(issueId, imageId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.noContent().build(); + } catch (IOException e) { + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java b/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java new file mode 100644 index 0000000..1893eb8 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java @@ -0,0 +1,30 @@ +package com.goldenchart.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartException; + +import java.util.Map; + +@RestControllerAdvice +@Slf4j +public class VerificationUploadExceptionHandler { + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUpload(MaxUploadSizeExceededException e) { + log.warn("[VerificationUpload] size exceeded: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(Map.of("message", "업로드 용량이 제한(30MB)을 초과했습니다.")); + } + + @ExceptionHandler(MultipartException.class) + public ResponseEntity> handleMultipart(MultipartException e) { + log.warn("[VerificationUpload] multipart error: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("message", "이미지 파일 형식을 확인할 수 없습니다.")); + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/VerificationIssueCommentDto.java b/backend/src/main/java/com/goldenchart/dto/VerificationIssueCommentDto.java new file mode 100644 index 0000000..41f295f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/VerificationIssueCommentDto.java @@ -0,0 +1,16 @@ +package com.goldenchart.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class VerificationIssueCommentDto { + + private Long id; + private Long issueId; + private String content; + private String authorName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/VerificationIssueDto.java b/backend/src/main/java/com/goldenchart/dto/VerificationIssueDto.java new file mode 100644 index 0000000..c611c39 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/VerificationIssueDto.java @@ -0,0 +1,18 @@ +package com.goldenchart.dto; + +import com.goldenchart.entity.VerificationIssueStage; +import lombok.*; + +import java.time.LocalDateTime; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class VerificationIssueDto { + + private Long id; + private String title; + private String content; + private String reproductionPath; + private VerificationIssueStage stage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/VerificationIssueEventDto.java b/backend/src/main/java/com/goldenchart/dto/VerificationIssueEventDto.java new file mode 100644 index 0000000..f849779 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/VerificationIssueEventDto.java @@ -0,0 +1,22 @@ +package com.goldenchart.dto; + +import com.goldenchart.entity.VerificationIssueStage; +import lombok.*; + +import java.time.LocalDateTime; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class VerificationIssueEventDto { + + public static final String TYPE_CREATED = "CREATED"; + public static final String TYPE_STAGE_CHANGED = "STAGE_CHANGED"; + + private String eventType; + private Long issueId; + private String title; + private VerificationIssueStage stage; + private VerificationIssueStage previousStage; + private LocalDateTime updatedAt; + /** 이벤트를 발생시킨 클라이언트 (동일 기기 알림 억제용) */ + private String sourceDeviceId; +} diff --git a/backend/src/main/java/com/goldenchart/dto/VerificationIssueImageDto.java b/backend/src/main/java/com/goldenchart/dto/VerificationIssueImageDto.java new file mode 100644 index 0000000..20f6bc9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/VerificationIssueImageDto.java @@ -0,0 +1,18 @@ +package com.goldenchart.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class VerificationIssueImageDto { + + private Long id; + private Long issueId; + private String fileName; + private String contentType; + private Long fileSize; + private Integer sortOrder; + private String url; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index 5f6dfa0..3f9a9bb 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -137,6 +137,11 @@ public class GcAppSettings { @Builder.Default private Integer tradeAlertPopupGridCols = 2; + /** 검증 이슈 등록·단계 변경 알림 팝업 (기본 true) */ + @Column(name = "verification_issue_notify", nullable = false) + @Builder.Default + private Boolean verificationIssueNotify = true; + /** 실시간 전략 체크 마스터 ON/OFF — ON 이면 DB 관심종목 전체가 체크 대상 */ @Column(name = "live_strategy_check", nullable = false) @Builder.Default diff --git a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java new file mode 100644 index 0000000..fceaec0 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java @@ -0,0 +1,53 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_verification_issue") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcVerificationIssue { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title", nullable = false, length = 300) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "reproduction_path", length = 500) + private String reproductionPath; + + @Enumerated(EnumType.STRING) + @Column(name = "stage", nullable = false, length = 32) + @Builder.Default + private VerificationIssueStage stage = VerificationIssueStage.REGISTERED; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java new file mode 100644 index 0000000..10aac25 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java @@ -0,0 +1,48 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_verification_issue_comment") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcVerificationIssueComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "issue_id", nullable = false) + private Long issueId; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "author_name", length = 100) + private String authorName; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java new file mode 100644 index 0000000..bc8e11f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java @@ -0,0 +1,43 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_verification_issue_image") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcVerificationIssueImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "issue_id", nullable = false) + private Long issueId; + + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + @Column(name = "storage_path", nullable = false, length = 500) + private String storagePath; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "sort_order", nullable = false) + @Builder.Default + private Integer sortOrder = 0; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/VerificationIssueStage.java b/backend/src/main/java/com/goldenchart/entity/VerificationIssueStage.java new file mode 100644 index 0000000..7477569 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/VerificationIssueStage.java @@ -0,0 +1,10 @@ +package com.goldenchart.entity; + +/** 검증 이슈 처리 단계 */ +public enum VerificationIssueStage { + REGISTERED, + IN_FIX, + FIX_COMPLETE, + RE_REGISTERED, + COMPLETE +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueCommentRepository.java b/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueCommentRepository.java new file mode 100644 index 0000000..c031150 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueCommentRepository.java @@ -0,0 +1,13 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcVerificationIssueComment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcVerificationIssueCommentRepository extends JpaRepository { + + List findByIssueIdOrderByCreatedAtAsc(Long issueId); + + void deleteByIssueId(Long issueId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueImageRepository.java b/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueImageRepository.java new file mode 100644 index 0000000..ec78b25 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueImageRepository.java @@ -0,0 +1,13 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcVerificationIssueImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcVerificationIssueImageRepository extends JpaRepository { + + List findByIssueIdOrderBySortOrderAscCreatedAtAsc(Long issueId); + + void deleteByIssueId(Long issueId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueRepository.java b/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueRepository.java new file mode 100644 index 0000000..8590a6f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcVerificationIssueRepository.java @@ -0,0 +1,14 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcVerificationIssue; +import com.goldenchart.entity.VerificationIssueStage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcVerificationIssueRepository extends JpaRepository { + + List findAllByOrderByUpdatedAtDesc(); + + List findByStageOrderByUpdatedAtDesc(VerificationIssueStage stage); +} diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index b797c90..3715f5b 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -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"); diff --git a/backend/src/main/java/com/goldenchart/service/VerificationIssueCommentService.java b/backend/src/main/java/com/goldenchart/service/VerificationIssueCommentService.java new file mode 100644 index 0000000..3d0437c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/VerificationIssueCommentService.java @@ -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 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 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(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java b/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java new file mode 100644 index 0000000..406b614 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java @@ -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 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 listByIssue(Long issueId) { + return imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).stream() + .map(this::toDto) + .toList(); + } + + @Transactional + public List upload(Long issueId, List 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 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 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 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 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 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(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/VerificationIssueService.java b/backend/src/main/java/com/goldenchart/service/VerificationIssueService.java new file mode 100644 index 0000000..2a87f67 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/VerificationIssueService.java @@ -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 list(VerificationIssueStage stage) { + List entities = stage != null + ? repository.findByStageOrderByUpdatedAtDesc(stage) + : repository.findAllByOrderByUpdatedAtDesc(); + return entities.stream().map(this::toDto).toList(); + } + + public Optional 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(); + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/VerificationIssueEventBroker.java b/backend/src/main/java/com/goldenchart/websocket/VerificationIssueEventBroker.java new file mode 100644 index 0000000..14c9bee --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/VerificationIssueEventBroker.java @@ -0,0 +1,56 @@ +package com.goldenchart.websocket; + +import com.goldenchart.dto.VerificationIssueEventDto; +import com.goldenchart.dto.VerificationIssueDto; +import com.goldenchart.entity.VerificationIssueStage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class VerificationIssueEventBroker { + + public static final String TOPIC = "/sub/verification-issues/events"; + + private final SimpMessagingTemplate messagingTemplate; + + public void publishCreated(VerificationIssueDto issue, String sourceDeviceId) { + publish(VerificationIssueEventDto.builder() + .eventType(VerificationIssueEventDto.TYPE_CREATED) + .issueId(issue.getId()) + .title(issue.getTitle()) + .stage(issue.getStage()) + .updatedAt(issue.getUpdatedAt()) + .sourceDeviceId(sourceDeviceId) + .build()); + } + + public void publishStageChanged( + VerificationIssueDto issue, + VerificationIssueStage previousStage, + String sourceDeviceId) { + publish(VerificationIssueEventDto.builder() + .eventType(VerificationIssueEventDto.TYPE_STAGE_CHANGED) + .issueId(issue.getId()) + .title(issue.getTitle()) + .stage(issue.getStage()) + .previousStage(previousStage) + .updatedAt(issue.getUpdatedAt()) + .sourceDeviceId(sourceDeviceId) + .build()); + } + + private void publish(VerificationIssueEventDto event) { + try { + messagingTemplate.convertAndSend(TOPIC, event); + if (log.isDebugEnabled()) { + log.debug("[VerificationIssueEvent] {} issueId={}", event.getEventType(), event.getIssueId()); + } + } catch (Exception e) { + log.warn("[VerificationIssueEvent] publish failed: {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8acce6a..057cf4e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -2,6 +2,11 @@ spring: application: name: goldenchart-backend + servlet: + multipart: + max-file-size: 6MB + max-request-size: 30MB + profiles: active: ${SPRING_PROFILES_ACTIVE:local} @@ -95,6 +100,10 @@ goldenchart: enabled: true tick-ms: 1000 queue-capacity: 16 + verification: + upload-dir: ${GC_VERIFICATION_UPLOAD_DIR:data/verification-images} + max-image-size-mb: 5 + max-images-per-issue: 30 cors: allowed-origins: - http://localhost:5173 diff --git a/backend/src/main/resources/db/migration/V36__gc_verification_issue.sql b/backend/src/main/resources/db/migration/V36__gc_verification_issue.sql new file mode 100644 index 0000000..6895efa --- /dev/null +++ b/backend/src/main/resources/db/migration/V36__gc_verification_issue.sql @@ -0,0 +1,13 @@ +-- 검증게시판 이슈 +CREATE TABLE gc_verification_issue ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(300) NOT NULL, + content TEXT NOT NULL, + stage VARCHAR(32) NOT NULL DEFAULT 'REGISTERED', + user_id BIGINT NULL, + device_id VARCHAR(100) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_verification_issue_stage (stage), + INDEX idx_verification_issue_updated (updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V37__verification_board_menu.sql b/backend/src/main/resources/db/migration/V37__verification_board_menu.sql new file mode 100644 index 0000000..b66e253 --- /dev/null +++ b/backend/src/main/resources/db/migration/V37__verification_board_menu.sql @@ -0,0 +1,6 @@ +-- 검증게시판 메뉴 권한 +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('ADMIN', 'verification-board', 1), +('USER', 'verification-board', 1), +('GUEST', 'verification-board', 0) +ON DUPLICATE KEY UPDATE allowed = VALUES(allowed); diff --git a/backend/src/main/resources/db/migration/V38__gc_verification_issue_comment.sql b/backend/src/main/resources/db/migration/V38__gc_verification_issue_comment.sql new file mode 100644 index 0000000..9c10edb --- /dev/null +++ b/backend/src/main/resources/db/migration/V38__gc_verification_issue_comment.sql @@ -0,0 +1,14 @@ +-- 검증 이슈 댓글 +CREATE TABLE gc_verification_issue_comment ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + issue_id BIGINT NOT NULL, + content TEXT NOT NULL, + author_name VARCHAR(100) NULL, + user_id BIGINT NULL, + device_id VARCHAR(100) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_vic_issue_id (issue_id), + CONSTRAINT fk_vic_issue FOREIGN KEY (issue_id) + REFERENCES gc_verification_issue (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V39__gc_verification_issue_image.sql b/backend/src/main/resources/db/migration/V39__gc_verification_issue_image.sql new file mode 100644 index 0000000..43c68e9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__gc_verification_issue_image.sql @@ -0,0 +1,14 @@ +-- 검증 이슈 첨부 이미지 +CREATE TABLE gc_verification_issue_image ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + issue_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + storage_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_vii_issue_id (issue_id), + CONSTRAINT fk_vii_issue FOREIGN KEY (issue_id) + REFERENCES gc_verification_issue (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V40__verification_issue_reproduction_path.sql b/backend/src/main/resources/db/migration/V40__verification_issue_reproduction_path.sql new file mode 100644 index 0000000..a12909c --- /dev/null +++ b/backend/src/main/resources/db/migration/V40__verification_issue_reproduction_path.sql @@ -0,0 +1,2 @@ +ALTER TABLE gc_verification_issue + ADD COLUMN reproduction_path VARCHAR(500) NULL AFTER content; diff --git a/backend/src/main/resources/db/migration/V41__verification_issue_notify.sql b/backend/src/main/resources/db/migration/V41__verification_issue_notify.sql new file mode 100644 index 0000000..ccb64fc --- /dev/null +++ b/backend/src/main/resources/db/migration/V41__verification_issue_notify.sql @@ -0,0 +1,4 @@ +-- 검증 이슈 등록·단계 변경 알림 팝업 ON/OFF +ALTER TABLE gc_app_settings + ADD COLUMN verification_issue_notify TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '검증 이슈 등록·단계 변경 알림 팝업'; diff --git a/docker-compose.yml b/docker-compose.yml index 781f00a..dc05513 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ networks: volumes: mysql-data: driver: local + verification-images: + driver: local services: @@ -50,7 +52,7 @@ services: # ── Spring Boot Backend ────────────────────────────────────────────────── backend: build: - context: . # 루트를 컨텍스트로 (ta4j-master 포함) + context: . dockerfile: backend/Dockerfile container_name: gc-backend restart: unless-stopped @@ -62,10 +64,12 @@ services: DB_URL: "jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-stockAnalyzer}?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true" DB_USERNAME: ${MYSQL_USER:-stock} DB_PASSWORD: ${MYSQL_PASSWORD:-analyzer} - # 업비트 API 키 DB 암호화 (운영 환경에서 반드시 변경) GC_SECRETS_ENCRYPTION_KEY: ${GC_SECRETS_ENCRYPTION_KEY:-goldenchart-local-dev-key-change-me} + GC_VERIFICATION_UPLOAD_DIR: /app/data/verification-images ports: - "${BACKEND_PORT:-8080}:8080" + volumes: + - verification-images:/app/data/verification-images healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/indicators/list || exit 1"] interval: 15s diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 01346c6..79d2de1 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -10,6 +10,9 @@ server { root /usr/share/nginx/html; index index.html; + # multipart 이미지 업로드 (Spring max-request-size 30MB와 맞춤) + client_max_body_size 32m; + # 외부 호스트명 DNS 조회용 resolver (Upbit + Backend 프록시에 필요) resolver 127.0.0.11 8.8.8.8 8.8.4.4 valid=30s; resolver_timeout 5s; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd221a9..90f98cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,7 @@ import BacktestPanel from './components/BacktestPanel'; import { useBacktest } from './hooks/useBacktest'; import LiveStrategyPanel from './components/LiveStrategyPanel'; import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; +import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext'; import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier'; import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings'; import { AppNotificationLayer } from './components/AppNotificationLayer'; @@ -78,6 +79,7 @@ import SettingsPage from './components/SettingsPage'; import PaperTradingPage from './components/PaperTradingPage'; import VirtualTradingPage from './components/VirtualTradingPage'; import TrendSearchPage from './components/TrendSearchPage'; +import VerificationBoardPage from './components/VerificationBoardPage'; import DashboardPage from './components/DashboardPage'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; @@ -203,6 +205,7 @@ function App() { const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade'); const [menuPage, setMenuPage] = useState('chart'); + const [verificationFocusIssueId, setVerificationFocusIssueId] = useState(null); const chartVisible = menuPage === 'chart'; const isMobile = useIsMobile(); @@ -1598,6 +1601,10 @@ function App() { soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true} soundId={appDefaults.tradeAlertSound ?? 'bell'} > +
)} + {menuPage === 'verification-board' && ( + setVerificationFocusIssueId(null)} + /> + )} + {/* ── 설정 화면 ──────────────────────────────────────────────────── */} {menuPage === 'notifications' && ( saveAppDef({ fcmPushEnabled: v })} displayTimezone={displayTimezone} onDisplayTimezoneChange={handleTimezoneChange} + verificationIssueNotify={appDefaults.verificationIssueNotify} + onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })} onFcmTest={async () => { const { sendFcmTest } = await import('./utils/backendApi'); const r = await sendFcmTest(); @@ -2563,6 +2580,10 @@ function App() { menuPage={menuPage} onPage={guardedSetMenuPage} onGoToChart={goToMarketChart} + onGoToVerificationIssue={issueId => { + setVerificationFocusIssueId(issueId); + guardedSetMenuPage('verification-board'); + }} tradingMode={appDefaults.tradingMode} hasUpbitKeys={appDefaults.hasUpbitKeys} paperTradingEnabled={paperTradingEnabled} @@ -2578,6 +2599,7 @@ function App() { />
+
); } diff --git a/frontend/src/components/AppNotificationLayer.tsx b/frontend/src/components/AppNotificationLayer.tsx index dd06b17..36b5e9e 100644 --- a/frontend/src/components/AppNotificationLayer.tsx +++ b/frontend/src/components/AppNotificationLayer.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; import TradeSignalSnackbar from './TradeSignalSnackbar'; import TradeAlertModal from './TradeAlertModal'; +import VerificationIssueToastStack from './verificationBoard/VerificationIssueToastStack'; import { useTradeNotification } from '../contexts/TradeNotificationContext'; import { normalizeTradeAlertGridCols, @@ -12,11 +13,13 @@ import { normalizeTradeAlertPopupPosition, } from '../utils/tradeAlertPopupLayout'; import type { MenuPage } from './TopMenuBar'; +import '../styles/verificationBoard.css'; interface Props { menuPage: MenuPage; onPage: (page: MenuPage) => void; onGoToChart: (market: string) => void; + onGoToVerificationIssue?: (issueId: number) => void; tradingMode?: string; hasUpbitKeys?: boolean; paperTradingEnabled?: boolean; @@ -32,6 +35,7 @@ export const AppNotificationLayer: React.FC = ({ menuPage, onPage, onGoToChart, + onGoToVerificationIssue, tradingMode, hasUpbitKeys, paperTradingEnabled, @@ -73,6 +77,9 @@ export const AppNotificationLayer: React.FC = ({ onOrderDone={onOrderDone} /> )} + {onGoToVerificationIssue && ( + + )} ); diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 28a8571..7ff64c1 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -142,6 +142,8 @@ interface SettingsPageProps { displayTimezone?: string; onDisplayTimezoneChange?: (tz: string) => void; menuPermissions?: Record; + verificationIssueNotify?: boolean; + onVerificationIssueNotify?: (v: boolean) => void; trendSearchSettings?: TrendSearchAppSettings; onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void; } @@ -534,7 +536,16 @@ const PaperPanel: React.FC = ({ const GeneralPanel: React.FC<{ displayTimezone: string; onDisplayTimezoneChange: (tz: string) => void; -}> = ({ displayTimezone, onDisplayTimezoneChange }) => { + verificationIssueNotify?: boolean; + onVerificationIssueNotify?: (v: boolean) => void; + showVerificationNotify?: boolean; +}> = ({ + displayTimezone, + onDisplayTimezoneChange, + verificationIssueNotify = true, + onVerificationIssueNotify, + showVerificationNotify = true, +}) => { const [lang, setLang] = useState('ko'); const [dateFormat, setDateFmt] = useState('YYYY-MM-DD'); const [startPage, setStart] = useState('chart'); @@ -567,6 +578,24 @@ const GeneralPanel: React.FC<{ + {showVerificationNotify && ( + + + + + + )} + { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={e => { + if (!showList) { + if (e.key === 'ArrowDown' && suggestions.length > 0) { + setOpen(true); + setActiveIdx(0); + e.preventDefault(); + } + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx(i => Math.min(i + 1, suggestions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && activeIdx >= 0) { + e.preventDefault(); + pick(suggestions[activeIdx]!.path); + } else if (e.key === 'Escape') { + setOpen(false); + setActiveIdx(-1); + } + }} + /> + {showList && ( +
    + {suggestions.map((s, i) => ( +
  • + +
  • + ))} +
+ )} + {open && value.trim() && suggestions.length === 0 && ( +

일치하는 메뉴 경로가 없습니다. 직접 입력한 경로가 저장됩니다.

+ )} + + ); +}; + +export default ReproductionPathInput; diff --git a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx new file mode 100644 index 0000000..70383af --- /dev/null +++ b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx @@ -0,0 +1,394 @@ +/** + * 검증 이슈 첨부 이미지 — 파일 선택·드래그·클립보드 붙여넣기 + * issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드) + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + deleteVerificationIssueImage, + loadVerificationIssueImages, + uploadVerificationIssueImages, + verificationIssueImageUrl, + type VerificationIssueImageDto, +} from '../../utils/verificationBoardApi'; +import { + filesFromClipboardRead, + filesFromDataTransfer, + pickImageFiles, + prepareImagesForUpload, +} from '../../utils/clipboardImageFiles'; + +function uploadErrorMessage(e: unknown): string { + if (e instanceof Error) { + if (e.message === 'IMAGE_TOO_LARGE') { + return '이미지가 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.'; + } + if (e.message === 'IMAGE_READ_FAILED') { + return '이미지를 읽을 수 없습니다.'; + } + if (e.message) return e.message; + } + return '이미지 업로드에 실패했습니다.'; +} + +interface Props { + /** 저장된 이슈 ID — 수정·상세 화면 */ + issueId?: number | null; + /** 등록 draft — 저장 전 로컬 파일 */ + draftFiles?: File[]; + onDraftFilesChange?: (files: File[]) => void; + /** 상세 화면 — 저장 시 삭제할 서버 이미지 ID */ + pendingDeleteIds?: number[]; + onPendingDeleteIdsChange?: (ids: number[]) => void; + /** true: 추가·삭제를 저장 버튼까지 지연 (상세 편집) */ + deferUpload?: boolean; + disabled?: boolean; + /** 모달 등 좁은 영역 */ + compact?: boolean; +} + +interface CtxMenuState { + x: number; + y: number; +} + +const VerificationIssueAttachmentsSection: React.FC = ({ + issueId = null, + draftFiles = [], + onDraftFilesChange, + pendingDeleteIds = [], + onPendingDeleteIdsChange, + deferUpload = false, + disabled = false, + compact = false, +}) => { + const isDraft = issueId == null; + const saveOnSubmit = deferUpload && issueId != null; + const [images, setImages] = useState([]); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const [ctxMenu, setCtxMenu] = useState(null); + const fileInputRef = useRef(null); + const zoneRef = useRef(null); + + const reload = useCallback(async () => { + if (issueId == null) return; + setLoading(true); + try { + setImages(await loadVerificationIssueImages(issueId)); + } catch (e) { + console.warn('[VerificationBoard] images load', e); + } finally { + setLoading(false); + } + }, [issueId]); + + useEffect(() => { + if (issueId != null) void reload(); + else setImages([]); + }, [issueId, reload]); + + const draftPreviews = useMemo( + () => draftFiles.map((file, index) => ({ + key: `${file.name}-${file.size}-${file.lastModified}-${index}`, + index, + fileName: file.name, + src: URL.createObjectURL(file), + })), + [draftFiles], + ); + + useEffect(() => () => { + draftPreviews.forEach(p => URL.revokeObjectURL(p.src)); + }, [draftPreviews]); + + useEffect(() => { + const close = () => setCtxMenu(null); + window.addEventListener('click', close); + window.addEventListener('scroll', close, true); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('scroll', close, true); + }; + }, []); + + const addDraftFiles = useCallback(async (files: File[]) => { + const imgs = pickImageFiles(files); + if (imgs.length === 0) { + window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.'); + return; + } + setUploading(true); + try { + const prepared = await prepareImagesForUpload(imgs); + onDraftFilesChange?.([...draftFiles, ...prepared]); + } catch (e) { + window.alert(uploadErrorMessage(e)); + } finally { + setUploading(false); + } + }, [draftFiles, onDraftFilesChange]); + + const uploadFiles = useCallback(async (files: File[]) => { + if (issueId == null || saveOnSubmit) { + await addDraftFiles(files); + return; + } + const imgs = pickImageFiles(files); + if (imgs.length === 0) { + window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.'); + return; + } + setUploading(true); + try { + const prepared = await prepareImagesForUpload(imgs); + await uploadVerificationIssueImages(issueId, prepared); + await reload(); + } catch (e) { + console.warn('[VerificationBoard] image upload', e); + window.alert(uploadErrorMessage(e)); + } finally { + setUploading(false); + } + }, [issueId, saveOnSubmit, addDraftFiles, reload]); + + const pasteFromClipboard = useCallback(async () => { + setCtxMenu(null); + try { + const files = await filesFromClipboardRead(); + if (files.length === 0) { + window.alert('클립보드에 붙여넣을 수 있는 이미지(png, jpeg 등)가 없습니다.'); + return; + } + await uploadFiles(files); + } catch (e) { + if (e instanceof Error && e.message === 'CLIPBOARD_UNSUPPORTED') { + window.alert('이 브라우저에서는 클립보드 이미지 읽기를 지원하지 않습니다.'); + return; + } + window.alert('클립보드 접근이 거부되었습니다. 브라우저 권한을 확인하세요.'); + } + }, [uploadFiles]); + + const handleDeleteServer = async (imageId: number) => { + if (issueId == null) return; + if (!window.confirm('이 이미지를 삭제할까요?')) return; + if (saveOnSubmit) { + onPendingDeleteIdsChange?.( + pendingDeleteIds.includes(imageId) + ? pendingDeleteIds + : [...pendingDeleteIds, imageId], + ); + return; + } + try { + await deleteVerificationIssueImage(issueId, imageId); + await reload(); + } catch (e) { + console.warn('[VerificationBoard] image delete', e); + window.alert('이미지 삭제에 실패했습니다.'); + } + }; + + const handleDeleteDraft = (index: number) => { + onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index)); + }; + + const openImage = (src: string) => { + window.open(src, '_blank', 'noopener,noreferrer'); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + if (disabled) return; + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ x: e.clientX, y: e.clientY }); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + if (disabled) return; + const files = filesFromDataTransfer(e.clipboardData); + if (files.length === 0) return; + e.preventDefault(); + void uploadFiles(files); + }; + + const handleDrop = (e: React.DragEvent) => { + if (disabled) return; + e.preventDefault(); + setDragOver(false); + const fromTransfer = filesFromDataTransfer(e.dataTransfer); + if (fromTransfer.length > 0) void uploadFiles(fromTransfer); + }; + + const visibleServerImages = useMemo( + () => images.filter(img => img.id != null && !pendingDeleteIds.includes(img.id)), + [images, pendingDeleteIds], + ); + + const totalCount = isDraft || saveOnSubmit + ? visibleServerImages.length + draftFiles.length + : images.length; + const showEmptyHint = !loading && totalCount === 0; + + return ( +
+
+

+ 첨부 이미지 {totalCount > 0 && `(${totalCount})`} +

+ + { + const files = e.target.files; + if (files?.length) void uploadFiles(pickImageFiles(files)); + e.target.value = ''; + }} + /> +
+ +
{ if (disabled) return; e.preventDefault(); setDragOver(true); }} + onDragOver={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }} + onDragLeave={e => { + if (zoneRef.current && !zoneRef.current.contains(e.relatedTarget as Node)) { + setDragOver(false); + } + }} + onDrop={handleDrop} + > + {loading && issueId != null && !isDraft && images.length === 0 ? ( +

이미지 불러오는 중…

+ ) : showEmptyHint ? ( +

+ {isDraft || saveOnSubmit + ? '저장 시 함께 반영됩니다. 드래그·우클릭 붙여넣기·파일 선택' + : '이미지를 드래그하거나, 우클릭 → 클립보드 붙여넣기, 또는 「파일 선택」'} +

+ ) : ( +
+ {(isDraft || saveOnSubmit) && visibleServerImages.map(img => { + if (img.id == null) return null; + const src = verificationIssueImageUrl(issueId!, img.id); + return ( +
+ +
+ {img.fileName} +
+ +
+ ); + })} + {(isDraft || saveOnSubmit) + ? draftPreviews.map(p => ( +
+ +
+ {p.fileName} +
+ +
+ )) + : !saveOnSubmit && images.map(img => { + if (img.id == null) return null; + const src = verificationIssueImageUrl(issueId!, img.id); + return ( +
+ +
+ {img.fileName} +
+ +
+ ); + })} +
+ )} + {uploading &&
업로드 중…
} +
+ + {ctxMenu && ( +
e.stopPropagation()} + > + + +
+ )} +
+ ); +}; + +export default VerificationIssueAttachmentsSection; diff --git a/frontend/src/components/verificationBoard/VerificationIssueCommentsSection.tsx b/frontend/src/components/verificationBoard/VerificationIssueCommentsSection.tsx new file mode 100644 index 0000000..72106df --- /dev/null +++ b/frontend/src/components/verificationBoard/VerificationIssueCommentsSection.tsx @@ -0,0 +1,231 @@ +/** + * 검증 이슈 댓글 — 목록·추가·수정·삭제 + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { getAuthSession } from '../../utils/auth'; +import { + createVerificationIssueComment, + deleteVerificationIssueComment, + loadVerificationIssueComments, + updateVerificationIssueComment, + type VerificationIssueCommentDto, +} from '../../utils/verificationBoardApi'; + +interface Props { + issueId: number; +} + +function formatDateTime(iso?: string): string { + if (!iso) return ''; + try { + return new Date(iso).toLocaleString('ko-KR', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }); + } catch { return ''; } +} + +function defaultAuthorName(): string { + const session = getAuthSession(); + if (session?.displayName) return session.displayName; + return '게스트'; +} + +const VerificationIssueCommentsSection: React.FC = ({ issueId }) => { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + const [newContent, setNewContent] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editContent, setEditContent] = useState(''); + + const reload = useCallback(async () => { + setLoading(true); + try { + const list = await loadVerificationIssueComments(issueId); + setComments(list); + } catch (e) { + console.warn('[VerificationBoard] comments load', e); + } finally { + setLoading(false); + } + }, [issueId]); + + useEffect(() => { + setEditingId(null); + setEditContent(''); + setNewContent(''); + void reload(); + }, [issueId, reload]); + + const handleAdd = async () => { + const content = newContent.trim(); + if (!content) { + window.alert('댓글 내용을 입력하세요.'); + return; + } + setSubmitting(true); + try { + await createVerificationIssueComment(issueId, { + content, + authorName: defaultAuthorName(), + }); + setNewContent(''); + await reload(); + } catch (e) { + console.warn('[VerificationBoard] comment add', e); + window.alert('댓글 등록에 실패했습니다.'); + } finally { + setSubmitting(false); + } + }; + + const startEdit = (c: VerificationIssueCommentDto) => { + if (c.id == null) return; + setEditingId(c.id); + setEditContent(c.content); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditContent(''); + }; + + const handleSaveEdit = async (commentId: number) => { + const content = editContent.trim(); + if (!content) { + window.alert('댓글 내용을 입력하세요.'); + return; + } + setSubmitting(true); + try { + await updateVerificationIssueComment(issueId, commentId, { content }); + cancelEdit(); + await reload(); + } catch (e) { + console.warn('[VerificationBoard] comment edit', e); + window.alert('댓글 수정에 실패했습니다.'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (commentId: number) => { + if (!window.confirm('이 댓글을 삭제할까요?')) return; + setSubmitting(true); + try { + await deleteVerificationIssueComment(issueId, commentId); + if (editingId === commentId) cancelEdit(); + await reload(); + } catch (e) { + console.warn('[VerificationBoard] comment delete', e); + window.alert('댓글 삭제에 실패했습니다.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

댓글 {comments.length > 0 && `(${comments.length})`}

+
+ +
+ {loading && comments.length === 0 ? ( +

댓글 불러오는 중…

+ ) : comments.length === 0 ? ( +

아직 댓글이 없습니다.

+ ) : comments.map(c => { + const isEditing = c.id === editingId; + return ( +
+
+ {c.authorName ?? '익명'} + + {formatDateTime(c.updatedAt ?? c.createdAt)} + {c.updatedAt && c.createdAt && c.updatedAt !== c.createdAt && ' (수정됨)'} + +
+ {!isEditing && ( + <> + + + + )} +
+
+ {isEditing ? ( +
+