diff --git a/src/main/java/com/sb/web/board/controller/BoardImageController.java b/src/main/java/com/sb/web/board/controller/BoardImageController.java new file mode 100644 index 0000000..5fa45a1 --- /dev/null +++ b/src/main/java/com/sb/web/board/controller/BoardImageController.java @@ -0,0 +1,57 @@ +package com.sb.web.board.controller; + +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import com.sb.web.board.domain.BoardImage; +import com.sb.web.board.service.BoardImageService; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +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.Map; +import java.util.concurrent.TimeUnit; + +/** + * 게시글 인라인 이미지. + * POST /api/board/images 업로드(로그인 필요) → {url: "/api/images/{id}"} + * GET /api/images/{id} 이미지 바이트(공개 — img src 로 인증 없이 로드) + */ +@RestController +@RequiredArgsConstructor +public class BoardImageController { + + private final BoardImageService service; + + @PostMapping("/api/board/images") + public Map upload( + @RequestParam("image") MultipartFile file, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException { + if (file.isEmpty()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지가 비어 있습니다."); + } + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 파일만 업로드할 수 있습니다."); + } + Long id = service.save(current.getId(), contentType, file.getBytes()); + return Map.of("url", "/api/images/" + id); + } + + @GetMapping("/api/images/{id}") + public ResponseEntity get(@PathVariable Long id) { + BoardImage img = service.get(id); + if (img == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(img.getContentType())) + .cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()) + .body(img.getData()); + } +} diff --git a/src/main/java/com/sb/web/board/domain/BoardImage.java b/src/main/java/com/sb/web/board/domain/BoardImage.java new file mode 100644 index 0000000..fb36e9f --- /dev/null +++ b/src/main/java/com/sb/web/board/domain/BoardImage.java @@ -0,0 +1,26 @@ +package com.sb.web.board.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 게시글 인라인 이미지. board_image 테이블과 매핑(DB 저장). + * 본문에는 base64 대신 /api/images/{id} URL 만 들어간다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BoardImage { + + private Long id; + private Long memberId; + private String contentType; + private byte[] data; + private Integer byteSize; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/board/mapper/BoardImageMapper.java b/src/main/java/com/sb/web/board/mapper/BoardImageMapper.java new file mode 100644 index 0000000..6c769c0 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/BoardImageMapper.java @@ -0,0 +1,13 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.domain.BoardImage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface BoardImageMapper { + + int insert(BoardImage image); + + BoardImage findById(@Param("id") Long id); +} diff --git a/src/main/java/com/sb/web/board/service/BoardImageService.java b/src/main/java/com/sb/web/board/service/BoardImageService.java new file mode 100644 index 0000000..028d05f --- /dev/null +++ b/src/main/java/com/sb/web/board/service/BoardImageService.java @@ -0,0 +1,33 @@ +package com.sb.web.board.service; + +import com.sb.web.board.domain.BoardImage; +import com.sb.web.board.mapper.BoardImageMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 게시글 인라인 이미지 저장/조회 (DB 저장). + */ +@Service +@RequiredArgsConstructor +public class BoardImageService { + + private final BoardImageMapper mapper; + + @Transactional + public Long save(Long memberId, String contentType, byte[] data) { + BoardImage img = BoardImage.builder() + .memberId(memberId) + .contentType(contentType) + .data(data) + .byteSize(data.length) + .build(); + mapper.insert(img); + return img.getId(); + } + + public BoardImage get(Long id) { + return mapper.findById(id); + } +} diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index d91f512..022de7a 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -70,6 +70,18 @@ CREATE TABLE IF NOT EXISTS board_setting ( INSERT IGNORE INTO board_setting (id, tag_category_id) VALUES (1, NULL); +-- 게시글 인라인 이미지 (DB 저장). 본문에는 /api/images/{id} URL 만 포함(base64 미사용). +CREATE TABLE IF NOT EXISTS board_image ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + content_type VARCHAR(100) NOT NULL, + data LONGBLOB NOT NULL, + byte_size INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_board_image_member (member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- 댓글 CREATE TABLE IF NOT EXISTS comment ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/BoardImageMapper.xml b/src/main/resources/mapper/BoardImageMapper.xml new file mode 100644 index 0000000..01e7e15 --- /dev/null +++ b/src/main/resources/mapper/BoardImageMapper.xml @@ -0,0 +1,18 @@ + + + + + + INSERT INTO board_image (member_id, content_type, data, byte_size, created_at) + VALUES (#{memberId}, #{contentType}, #{data}, #{byteSize}, NOW()) + + + + +