diff --git a/backend/src/main/java/com/worlabel/domain/alarm/controller/AlarmController.java b/backend/src/main/java/com/worlabel/domain/alarm/controller/AlarmController.java new file mode 100644 index 0000000..68f1d67 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/alarm/controller/AlarmController.java @@ -0,0 +1,76 @@ +package com.worlabel.domain.alarm.controller; + +import com.worlabel.domain.alarm.entity.Alarm; +import com.worlabel.domain.alarm.service.AlarmService; +import com.worlabel.domain.auth.entity.dto.AccessTokenResponse; +import com.worlabel.domain.auth.entity.dto.JwtToken; +import com.worlabel.global.annotation.CurrentUser; +import com.worlabel.global.config.swagger.SwaggerApiError; +import com.worlabel.global.config.swagger.SwaggerApiSuccess; +import com.worlabel.global.exception.CustomException; +import com.worlabel.global.exception.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "알람 관련 API") +@RequestMapping("/api/alarm") +public class AlarmController { + + private final AlarmService alarmService; + + @Operation(summary = "알림 리스트 조회", description = "현재 사용자의 알림 목록을 조회합니다.") + @SwaggerApiSuccess(description = "알림 목록이 조회됨") + @SwaggerApiError({ErrorCode.INVALID_TOKEN}) + @GetMapping("") + public List getAlarmList(@CurrentUser final Integer memberId) { + return alarmService.getAlarmList(memberId); + } + + @Operation(summary = "알림 읽음 처리", description = "해당 알림을 읽음처리합니다") + @SwaggerApiSuccess(description = "알림 읽음 처리") + @SwaggerApiError({ErrorCode.INVALID_TOKEN}) + @PutMapping("/{alarm_id}") + public void readAlarm( + @CurrentUser final Integer memberId, + @PathVariable("alarm_id") final Long alarmId + ) { + alarmService.readAlarm(memberId, alarmId); + } + + @Operation(summary = "알림 삭제", description = "해당 알림을 삭제합니다.") + @SwaggerApiSuccess(description = "알림 삭제") + @SwaggerApiError({ErrorCode.INVALID_TOKEN}) + @DeleteMapping("/{alarm_id}") + public void deleteAlarm( + @CurrentUser final Integer memberId, + @PathVariable("alarm_id") final Long alarmId ) { + alarmService.deleteAlarm(memberId, alarmId); + } + + @Operation(summary = "알람 전체 삭제", description = "알람을 전체 삭제합니다.") + @SwaggerApiSuccess(description = "알람 전체 삭제") + @SwaggerApiError({ErrorCode.INVALID_TOKEN}) + @DeleteMapping("") + public void deleteAllAlarm(@CurrentUser final Integer memberId) { + alarmService.deleteAllAlarm(memberId); + } + + // TODO: 연동 후 삭제 + @Operation(summary = "알람 테스트 전용", description = "테스트 알람을 10개 생성. 추후 삭제 예정") + @SwaggerApiSuccess(description = "알람 테스트 생성") + @SwaggerApiError({ErrorCode.INVALID_TOKEN}) + @PostMapping("/test") + public void test(@CurrentUser final Integer memberId) { + alarmService.test(memberId); + } +} diff --git a/backend/src/main/java/com/worlabel/domain/alarm/entity/Alarm.java b/backend/src/main/java/com/worlabel/domain/alarm/entity/Alarm.java new file mode 100644 index 0000000..1b34f7d --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/alarm/entity/Alarm.java @@ -0,0 +1,39 @@ +package com.worlabel.domain.alarm.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Alarm { + + private long id; + + private Boolean isRead; + + private String createdAt; + + private AlarmType type; + + public enum AlarmType{ + PREDICT, + TRAIN, + IMAGE, + COMMENT, + REVIEW_RESULT, + REVIEW_REQUEST + } + + public static Alarm create(long id, AlarmType type) { + return new Alarm(id, false, LocalDateTime.now().toString(), type); + } + + public void read(){ + isRead = true; + } +} diff --git a/backend/src/main/java/com/worlabel/domain/alarm/repository/AlarmCacheRepository.java b/backend/src/main/java/com/worlabel/domain/alarm/repository/AlarmCacheRepository.java new file mode 100644 index 0000000..03291c1 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/alarm/repository/AlarmCacheRepository.java @@ -0,0 +1,94 @@ +package com.worlabel.domain.alarm.repository; + +import com.google.gson.Gson; +import com.worlabel.domain.alarm.entity.Alarm; +import com.worlabel.domain.alarm.entity.Alarm.AlarmType; +import com.worlabel.global.cache.CacheKey; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class AlarmCacheRepository { + + private final RedisTemplate redisTemplate; + private final Gson gson; + + private final long ttlInSeconds = 10000L; + + public void save(int memberId, AlarmType type) { + Long alarmId = redisTemplate.opsForValue().increment(CacheKey.alarmIdKey()); + + // 알람 생성 + Alarm alarm = Alarm.create(alarmId, type); + String jsonAlarm = gson.toJson(alarm); + + // Redis에 저장 (개별 키에 TTL 설정) + String key = CacheKey.alarmMemberKey(memberId, alarmId); + redisTemplate.opsForValue().set(key, jsonAlarm, ttlInSeconds, TimeUnit.SECONDS); + } + + // 알람 리스트 조회 (ID나 타임스탬프 기준으로 정렬) + public List getAlarmList(int memberId) { + // 멤버에 해당하는 모든 알람 키 가져오기 + String key = CacheKey.alarmMemberAllKey(memberId); + Set keys = redisTemplate.keys(key); + + if(keys == null || keys.isEmpty()){ + return List.of(); + } + + return keys.stream() + .map(alarmKey -> redisTemplate.opsForValue().get(alarmKey)) + .map(this::converter) + .sorted(Comparator.comparing(Alarm::getId)) + .toList(); + } + + // 특정 알람 삭제 + public void deleteAlarm(int memberId, long alarmId) { + String key = CacheKey.alarmMemberKey(memberId, alarmId); + redisTemplate.delete(key); + } + + public void deleteAllAlarm(int memberId) { + String key = CacheKey.alarmMemberAllKey(memberId); + Set keys = redisTemplate.keys(key); // 해당 패턴으로 키 조회 + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); // 모든 키 삭제 + } + } + + // 특정 알람의 상태 변경 (읽음 처리) + public void readAlarm(int memberId, long alarmId) { + String key = CacheKey.alarmMemberKey(memberId, alarmId); + Alarm alarm = getAlarm(memberId, alarmId); + + if (alarm != null) { + // 읽음 상태로 변경 후 다시 저장 + alarm.read(); + String jsonAlarm = gson.toJson(alarm); + redisTemplate.opsForValue().set(key, jsonAlarm); + } + } + + // 특정 알람 조회 + private Alarm getAlarm(int memberId, long alarmId) { + String key = CacheKey.alarmMemberKey(memberId, alarmId); + String jsonAlarm = redisTemplate.opsForValue().get(key); + return converter(jsonAlarm); + } + + // JSON을 Alarm 객체로 변환하는 메서드 + private Alarm converter(String jsonAlarm) { + return gson.fromJson(jsonAlarm, Alarm.class); + } + +} diff --git a/backend/src/main/java/com/worlabel/domain/alarm/service/AlarmService.java b/backend/src/main/java/com/worlabel/domain/alarm/service/AlarmService.java new file mode 100644 index 0000000..b84dd23 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/alarm/service/AlarmService.java @@ -0,0 +1,51 @@ +package com.worlabel.domain.alarm.service; + +import com.worlabel.domain.alarm.entity.Alarm; +import com.worlabel.domain.alarm.entity.Alarm.AlarmType; +import com.worlabel.domain.alarm.repository.AlarmCacheRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AlarmService { + + private final AlarmCacheRepository alarmCacheRepository; + + public void save(int memberId, AlarmType type) { + alarmCacheRepository.save(memberId, type); + } + + public List getAlarmList(int memberId){ + return alarmCacheRepository.getAlarmList(memberId); + } + + public void deleteAlarm(int memberId, long alarmId){ + alarmCacheRepository.deleteAlarm(memberId, alarmId); + } + + public void readAlarm(int memberId, long alarmId){ + alarmCacheRepository.readAlarm(memberId, alarmId); + } + + public void deleteAllAlarm(int memberId){ + alarmCacheRepository.deleteAllAlarm(memberId); + } + + public void test(int memberId) { + // 3가지 알람 타입 배열을 정의 + AlarmType[] alarmTypes = {AlarmType.PREDICT, AlarmType.TRAIN, AlarmType.IMAGE}; + + // 10개의 알람 생성 + for(int i = 0; i < 10; i++) { + // i % 3을 사용하여 순차적으로 3개의 AlarmType을 선택 + AlarmType selectedType = alarmTypes[i % alarmTypes.length]; + save(memberId, selectedType); + } + } + +} diff --git a/backend/src/main/java/com/worlabel/global/cache/CacheKey.java b/backend/src/main/java/com/worlabel/global/cache/CacheKey.java index 9452929..cd9e910 100644 --- a/backend/src/main/java/com/worlabel/global/cache/CacheKey.java +++ b/backend/src/main/java/com/worlabel/global/cache/CacheKey.java @@ -24,4 +24,16 @@ public class CacheKey { public static String trainKey(int projectId, int modelId) { return "train:" + projectId + ":" + modelId; } + + public static String alarmIdKey(){ + return "alarm:id"; + } + + public static String alarmMemberKey(int memberId, long alarmId) { + return "member:" + memberId + ":alarm:" + alarmId; + } + + public static String alarmMemberAllKey(int memberId) { + return "member:" + memberId + ":alarm:*"; + } } diff --git a/backend/src/main/resources/sql/dml/init.sql b/backend/src/main/resources/sql/dml/init.sql index 40664b7..e4a7a3e 100644 --- a/backend/src/main/resources/sql/dml/init.sql +++ b/backend/src/main/resources/sql/dml/init.sql @@ -1,6 +1,6 @@ # YOLO8 DEFAULT 모델 삽입 -INSERT INTO ai_model(model_id, version, name) -VALUES (1, 0, "yolo8"); +INSERT INTO ai_model(model_id, version, name,model_key) +VALUES (1, 0, "yolo8", "yolo8"); # 80개의 라벨 카테고리 삽입 INSERT INTO label_category(model_id, label_category_name, ai_category_id)