검증게시판 기능 추가
This commit is contained in:
@@ -7,7 +7,7 @@ public final class MenuIds {
|
||||
private MenuIds() {}
|
||||
|
||||
public static final List<String> 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"
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
|
||||
*
|
||||
* 구독 채널 (명세서 5.2):
|
||||
* /sub/charts/{market}/{type} — 실시간 캔들 배포
|
||||
* /sub/verification-issues/events — 검증 이슈 등록·단계 변경
|
||||
*
|
||||
* 발행 채널:
|
||||
* /pub/... — 클라이언트 → 서버 (향후 확장용)
|
||||
|
||||
+60
@@ -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<VerificationIssueCommentDto>> list(@PathVariable Long issueId) {
|
||||
return ResponseEntity.ok(service.listByIssue(issueId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<VerificationIssueCommentDto> 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<VerificationIssueCommentDto> 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<Void> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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<VerificationIssueDto>> list(
|
||||
@RequestParam(value = "stage", required = false) String stage) {
|
||||
VerificationIssueStage parsed = parseStage(stage);
|
||||
return ResponseEntity.ok(service.list(parsed));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<VerificationIssueDto> get(@PathVariable Long id) {
|
||||
return service.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<VerificationIssueDto> 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<Void> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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<VerificationIssueImageDto>> 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<MultipartFile> 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<Resource> 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<Void> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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<Map<String, String>> 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<Map<String, String>> handleMultipart(MultipartException e) {
|
||||
log.warn("[VerificationUpload] multipart error: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "이미지 파일 형식을 확인할 수 없습니다."));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.goldenchart.entity;
|
||||
|
||||
/** 검증 이슈 처리 단계 */
|
||||
public enum VerificationIssueStage {
|
||||
REGISTERED,
|
||||
IN_FIX,
|
||||
FIX_COMPLETE,
|
||||
RE_REGISTERED,
|
||||
COMPLETE
|
||||
}
|
||||
+13
@@ -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<GcVerificationIssueComment, Long> {
|
||||
|
||||
List<GcVerificationIssueComment> findByIssueIdOrderByCreatedAtAsc(Long issueId);
|
||||
|
||||
void deleteByIssueId(Long issueId);
|
||||
}
|
||||
+13
@@ -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<GcVerificationIssueImage, Long> {
|
||||
|
||||
List<GcVerificationIssueImage> findByIssueIdOrderBySortOrderAscCreatedAtAsc(Long issueId);
|
||||
|
||||
void deleteByIssueId(Long issueId);
|
||||
}
|
||||
@@ -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<GcVerificationIssue, Long> {
|
||||
|
||||
List<GcVerificationIssue> findAllByOrderByUpdatedAtDesc();
|
||||
|
||||
List<GcVerificationIssue> findByStageOrderByUpdatedAtDesc(VerificationIssueStage stage);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE gc_verification_issue
|
||||
ADD COLUMN reproduction_path VARCHAR(500) NULL AFTER content;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 검증 이슈 등록·단계 변경 알림 팝업 ON/OFF
|
||||
ALTER TABLE gc_app_settings
|
||||
ADD COLUMN verification_issue_notify TINYINT(1) NOT NULL DEFAULT 1
|
||||
COMMENT '검증 이슈 등록·단계 변경 알림 팝업';
|
||||
Reference in New Issue
Block a user