Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@ terraform/secrets.tf
### Claude AI ###
CLAUDE.md
.claude/

## dataSet ##
cocktails_ver00.csv
cocktails_ver01.csv
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies {
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
implementation("org.springframework.boot:spring-boot-starter-batch")
testImplementation("org.springframework.batch:spring-batch-test")

runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
compileOnly("org.projectlombok:lombok")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
@Repository
public interface CocktailCommentRepository extends JpaRepository<CocktailComment, Long> {

List<CocktailComment> findTop10ByCocktailIdOrderByIdDesc(Long cocktailId);
List<CocktailComment> findTop10ByCocktailIdAndStatusInOrderByIdDesc(
Long cocktailId, List<CommentStatus> statuses
);

List<CocktailComment> findTop10ByCocktailIdAndIdLessThanOrderByIdDesc(Long cocktailId, Long lastId);
List<CocktailComment> findTop10ByCocktailIdAndStatusInAndIdLessThanOrderByIdDesc(
Long cocktailId, List<CommentStatus> statuses, Long lastId
);

boolean existsByCocktailIdAndUserIdAndStatusNot(Long cocktailId, Long id, CommentStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.back.domain.cocktail.repository.CocktailRepository;
import com.back.domain.post.comment.enums.CommentStatus;
import com.back.domain.user.entity.User;
import com.back.global.exception.UnauthorizedException;
import com.back.global.rq.Rq;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -46,6 +47,7 @@ public CocktailCommentResponseDto createCocktailComment(Long cocktailId, Cocktai
.cocktail(cocktail)
.user(user)
.content(reqBody.content())
.status(reqBody.status())
.build();

return new CocktailCommentResponseDto(cocktailCommentRepository.save(cocktailComment));
Expand All @@ -54,17 +56,34 @@ public CocktailCommentResponseDto createCocktailComment(Long cocktailId, Cocktai
// 칵테일 댓글 다건 조회 로직 (무한스크롤)
@Transactional(readOnly = true)
public List<CocktailCommentResponseDto> getCocktailComments(Long cocktailId, Long lastId) {
User actor = rq.getActor(); // 서비스에서 호출 가능

if (actor == null) {
throw new UnauthorizedException("로그인이 필요합니다.");
}
Long currentUserId = actor.getId();
List<CocktailComment> comments;

if (lastId == null) {
return cocktailCommentRepository.findTop10ByCocktailIdOrderByIdDesc(cocktailId)
.stream()
.map(CocktailCommentResponseDto::new)
.toList();
comments = cocktailCommentRepository
.findTop10ByCocktailIdAndStatusInOrderByIdDesc(cocktailId, List.of(CommentStatus.PUBLIC, CommentStatus.PRIVATE)
);
} else {
return cocktailCommentRepository.findTop10ByCocktailIdAndIdLessThanOrderByIdDesc(cocktailId, lastId)
.stream()
.map(CocktailCommentResponseDto::new)
.toList();
comments = cocktailCommentRepository
.findTop10ByCocktailIdAndStatusInAndIdLessThanOrderByIdDesc(cocktailId, List.of(CommentStatus.PUBLIC, CommentStatus.PRIVATE),
lastId);
}

return comments.stream()
.filter(comment ->{
if(comment.getStatus() == CommentStatus.PUBLIC) return true;
if(comment.getStatus() == CommentStatus.PRIVATE) {
return comment.getUser().getId().equals(currentUserId);
}
return false;
})
.map(CocktailCommentResponseDto::new)
.toList();
}

// 칵테일 댓글 단건 조회 로직
Expand Down
25 changes: 14 additions & 11 deletions src/main/java/com/back/domain/cocktail/service/CocktailService.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,19 @@ private String convertFractions(String ingredient) {
if (ingredient == null) return null;

// 치환 테이블 생성
Map<String, String> fractionMap = Map.of(
"1/2", "½",
"1/3", "⅓",
"2/3", "⅔",
"1/4", "¼",
"3/4", "¾",
"1/8", "⅛",
"3/8", "⅜",
"5/8", "⅝",
"7/8", "⅞"
Map<String, String> fractionMap = Map.ofEntries(
Map.entry("1/2", "½"),
Map.entry("1/3", "⅓"),
Map.entry("2/3", "⅔"),
Map.entry("1/4", "¼"),
Map.entry("3/4", "¾"),
Map.entry("1/8", "⅛"),
Map.entry("3/8", "⅜"),
Map.entry("5/8", "⅝"),
Map.entry("7/8", "⅞"),
Map.entry("1/5", "⅕"),
Map.entry("2/5", "⅖"),
Map.entry("1/6", "⅙")
);

// 테이블 기반 치환
Expand Down Expand Up @@ -176,7 +179,7 @@ private List<IngredientDto> parseIngredients(String ingredientStr) {

// (숫자 + 선택적 분수) + (공백) + (단위)
Pattern pattern = Pattern.compile(
"^([0-9]*\\s*[½⅓⅔¼¾⅛⅜⅝⅞]?)\\s*(.*)$",
"^([0-9]*\\s*[½⅓⅔¼¾⅛⅜⅝⅞⅕⅖⅙]?)\\s*(.*)$",
Pattern.UNICODE_CHARACTER_CLASS
);
Matcher matcher = pattern.matcher(amountUnit);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
Expand All @@ -20,8 +21,6 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.time.LocalDateTime;

@RestController
@RequestMapping("/me")
@RequiredArgsConstructor
Expand Down Expand Up @@ -120,7 +119,17 @@ public RsData<NotificationGoResponseDto> goPostLink(
@PathVariable("id") Long notificationId
) {
Long userId = principal.getId();
var body = notificationService.markAsReadAndGetPostLink(userId, notificationId);
NotificationGoResponseDto body = notificationService.markAsReadAndGetPostLink(userId, notificationId);
return RsData.successOf(body);
}

@DeleteMapping("/notifications")
@Operation(summary = "Delete all notifications", description = "Remove every notification belonging to the authenticated user")
public RsData<Void> deleteNotifications(
@AuthenticationPrincipal SecurityUser principal
) {
Long userId = principal.getId();
notificationService.deleteAll(userId);
return RsData.of(200, "cleared");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.back.domain.notification.entity.Notification;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
Expand Down Expand Up @@ -37,4 +38,8 @@ List<Notification> findMyNotificationsAfter(@Param("userId") Long userId,
where n.id = :id and n.user.id = :userId
""")
Notification findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
long deleteByUser_Id(Long userId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,9 @@ public void sendNotification(User user, Post post, NotificationType type, String
}
}
}

@Transactional
public void deleteAll(Long userId) {
notificationRepository.deleteByUser_Id(userId);
}
}
96 changes: 96 additions & 0 deletions src/main/java/com/back/global/appConfig/CocktailBatchConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.back.global.appConfig;

import com.back.global.standard.util.DecimalToFractionConverter;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.FlatFileItemWriter;
import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.item.file.builder.FlatFileItemWriterBuilder;
import org.springframework.batch.item.file.mapping.PassThroughLineMapper;
import org.springframework.batch.item.file.transform.PassThroughLineAggregator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.transaction.PlatformTransactionManager;

// DecimalToFractionConverter가 있다고 가정합니다.
// import com.example.DecimalToFractionConverter;

@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class CocktailBatchConfig {

private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;

private static final String INPUT_PATH = "src/main/resources/cocktails.csv";
private static final String OUTPUT_PATH = "src/main/resources/cocktails_clean.csv";

// 1.Reader: CSV 파일 한 줄씩 읽기
@Bean
public FlatFileItemReader<String> reader() {
return new FlatFileItemReaderBuilder<String>()
.name("cocktailReader")
.resource(new FileSystemResource(INPUT_PATH))
.lineMapper(new PassThroughLineMapper())
.build();
}

// 2️⃣ Processor: ingredient 컬럼(6번째)만 DecimalToFractionConverter로 변환
@Bean
public ItemProcessor<String, String> processor() {
return line -> {
// CSV 한 줄을 안전하게 split
String[] columns = line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1);

// Header인지 확인: "ingredient"라는 컬럼 이름으로 간단 체크
if (columns.length > 6 && !columns[6].equalsIgnoreCase("ingredient")) {
// DecimalToFractionConverter는 별도로 구현되어 있어야 합니다.
columns[6] = DecimalToFractionConverter.convert(columns[6]);
}

// 다시 CSV 문자열로 합치기
return String.join(",", columns);
};
}

// 3️⃣ Writer: 변환된 CSV 출력 (변화 없음)
@Bean
public FlatFileItemWriter<String> writer() {
return new FlatFileItemWriterBuilder<String>()
.name("cocktailWriter")
.resource(new FileSystemResource(OUTPUT_PATH))
.lineAggregator(new PassThroughLineAggregator<>())
.build();
}

// 4️⃣ Step: Chunk 단위 처리 (StepBuilderFactory 대체)
@Bean
public Step convertStep() {
// StepBuilder를 직접 생성하고, jobRepository와 transactionManager를 주입합니다.
return new StepBuilder("convertStep", jobRepository)
// chunk 메서드에 transactionManager를 인수로 전달합니다.
.<String, String>chunk(5, transactionManager)
.reader(reader())
.processor(processor())
.writer(writer())
.build();
}

// 5️⃣ Job 정의 (JobBuilderFactory 대체)
@Bean
public Job convertJob() {
// JobBuilder를 직접 생성하고, jobRepository를 주입합니다.
return new JobBuilder("convertJob", jobRepository)
.start(convertStep())
.build();
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/back/global/exception/UnauthorizedException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.back.global.exception;

public class UnauthorizedException extends ServiceException {

private static final int UNAUTHORIZED_CODE = 401;
private static final String UNAUTHORIZED_MSG = "로그인이 필요합니다.";

public UnauthorizedException() {
super(UNAUTHORIZED_CODE, UNAUTHORIZED_MSG);
}

public UnauthorizedException(String msg) {
super(UNAUTHORIZED_CODE, msg);
}
}
1 change: 0 additions & 1 deletion src/main/java/com/back/global/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 회원 or 인증된 사용자만 가능
.requestMatchers("/admin/**").hasRole("ADMIN")
// 나머지 모든 API는 인증 필요
.anyRequest().authenticated()
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.back.global.standard.util;

import java.io.*;

public class CocktailDataCleaner {
public static void main(String[] args) {
String inputPath = "src/main/resources/cocktails.csv"; // 원본 파일 경로
String outputPath = "src/main/resources/cocktails_clean.csv"; // 정제 후 파일 경로

try (
BufferedReader br = new BufferedReader(new FileReader(inputPath));
BufferedWriter bw = new BufferedWriter(new FileWriter(outputPath))
) {
String line;
boolean isHeader = true;

while ((line = br.readLine()) != null) {
// CSV 안의 쉼표와 큰따옴표를 안전하게 처리
String[] columns = splitCsvLine(line);

if (isHeader) {
bw.write(String.join(",", columns));
bw.newLine();
isHeader = false;
continue;
}

// ✅ 6번째 인덱스(ingredient) 컬럼만 변환
if (columns.length > 6) {
columns[6] = DecimalToFractionConverter.convert(columns[6]);
}

bw.write(String.join(",", columns));
bw.newLine();
}

System.out.println("✅ 칵테일 데이터 정제가 완료되었습니다: " + outputPath);

} catch (IOException e) {
System.err.println("❌ 파일 처리 중 오류 발생: " + e.getMessage());
}
}

/**
* CSV 한 줄을 안전하게 split하는 메서드
* (문장 안에 쉼표가 포함된 경우 대응)
*/
private static String[] splitCsvLine(String line) {
// 따옴표를 기준으로 나누되, 따옴표 안의 콤마는 무시
return line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1);
}
}
Loading