diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f29a873..4eed25d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -81,11 +81,12 @@ services: container_name: food-ai networks: [ my-network ] healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8000/healthz || curl -fsS http://127.0.0.1:8000/health || wget -qO- http://127.0.0.1:8000/healthz || wget -qO- http://127.0.0.1:8000/health || exit 1"] - interval: 10s + test: ["CMD", "python", "-c", "import urllib.request, sys; \ + sys.exit(0) if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else sys.exit(1)"] + interval: 15s timeout: 5s retries: 12 - start_period: 90s + start_period: 120s socket: image: dae9utang/babmukdang-socket:v1.0.0 diff --git a/src/main/java/com/example/timetoeat/domain/friend/controller/FriendMealQueryController.java b/src/main/java/com/example/timetoeat/domain/friend/controller/FriendMealQueryController.java new file mode 100644 index 0000000..fbe22c6 --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/friend/controller/FriendMealQueryController.java @@ -0,0 +1,33 @@ +package com.example.timetoeat.domain.friend.controller; + +import com.example.timetoeat.domain.friend.dto.response.FriendMealItemResponse; +import com.example.timetoeat.domain.friend.service.FriendMealQueryService; +import com.example.timetoeat.global.common.ApiResponse; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Validated +@RestController +@RequestMapping("/api/v1/friends") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +public class FriendMealQueryController { + + private final FriendMealQueryService service; + + // filter = ALL | HUNGRY(밥 안 먹음) | NOT_HUNGRY(밥 먹음) + @GetMapping("/me/meals") + public ApiResponse> getList( + @AuthenticationPrincipal(expression = "memberId") Long meId, + @RequestParam(defaultValue = "ALL") + @Pattern(regexp = "ALL|HUNGRY|NOT_HUNGRY") String filter + ) { + return ApiResponse.success(service.getDemoList(meId, filter)); + } +} diff --git a/src/main/java/com/example/timetoeat/domain/friend/dto/response/FriendMealItemResponse.java b/src/main/java/com/example/timetoeat/domain/friend/dto/response/FriendMealItemResponse.java new file mode 100644 index 0000000..ad3eca1 --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/friend/dto/response/FriendMealItemResponse.java @@ -0,0 +1,32 @@ +// src/main/java/com/example/timetoeat/domain/friend/dto/response/FriendMealItemResponse.java +package com.example.timetoeat.domain.friend.dto.response; + +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FriendMealItemResponse { + + private Long memberId; + private String userName; + private String profileImageUrl; + private boolean hungry; // true=배고파(OFF) + private String label; // "4시간 공복이에요" / "방금 먹었어요" + + @Builder + private FriendMealItemResponse(Long memberId, String userName, String profileImageUrl, + boolean hungry, String label) { + this.memberId = memberId; + this.userName = userName; + this.profileImageUrl = profileImageUrl; + this.hungry = hungry; + this.label = label; + } + + public static FriendMealItemResponse of(Long id, String name, String img, boolean hungry, String label) { + + return FriendMealItemResponse.builder() + .memberId(id).userName(name).profileImageUrl(img) + .hungry(hungry).label(label).build(); + } +} diff --git a/src/main/java/com/example/timetoeat/domain/friend/service/FriendMealQueryService.java b/src/main/java/com/example/timetoeat/domain/friend/service/FriendMealQueryService.java new file mode 100644 index 0000000..a1e064b --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/friend/service/FriendMealQueryService.java @@ -0,0 +1,110 @@ +package com.example.timetoeat.domain.friend.service; + +import com.example.timetoeat.domain.friend.dto.response.FriendMealItemResponse; +import com.example.timetoeat.domain.meal.entity.MemberMealStatus; +import com.example.timetoeat.domain.meal.repository.MemberMealStatusRepository; +import com.example.timetoeat.domain.member.entity.MemberEntity; +import com.example.timetoeat.domain.member.repository.MemberJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FriendMealQueryService { + + @Qualifier("kstClock") + private final Clock clock; + + private final MemberJpaRepository memberRepo; + private final MemberMealStatusRepository mealRepo; + + // 공개 API: 기존 컨트롤러가 호출 + public List getDemoList(Long meId, String filter) { + // 1) 실제 가입자 30명 (me 제외) + List members = memberRepo.findTop30ByIdNotOrderByCreatedAtDesc(meId); + + // 2) 벌크로 식사상태 조회 + Map statusMap = mealRepo.findByMember_IdIn( + members.stream().map(MemberEntity::getId).toList() + ).stream().collect(Collectors.toMap(s -> s.getMember().getId(), s -> s)); + + // 3) 실제 사용자 아이템 생성 + List items = new ArrayList<>(); + for (MemberEntity m : members) { + items.add(toItem(m, statusMap.get(m.getId()))); + } + + // 4) 30명 미만이면 데모로 채우기 + if (items.size() < 30) { + items.addAll(fakeItems(30 - items.size(), 1000L + items.size())); + } + + // 5) 필터 적용 + return items.stream().filter(it -> + switch (filter) { + case "HUNGRY" -> it.isHungry(); + case "NOT_HUNGRY" -> !it.isHungry(); + default -> true; + } + ).toList(); + } + + // 실제 사용자 → 응답 아이템 변환 (4시간 윈도우 계산) + private FriendMealItemResponse toItem(MemberEntity m, MemberMealStatus s) { + String img = (m.getProfileImageUrl() == null || m.getProfileImageUrl().isBlank()) + ? "https://picsum.photos/seed/avatar" + m.getId() + "/80/80" + : m.getProfileImageUrl(); + + LocalDateTime now = LocalDateTime.now(clock); + Duration window = Duration.ofHours(4); + + LocalDateTime last = (s == null) ? null : s.getLastMealAt(); + LocalDateTime manual = (s == null) ? null : s.getManualFastingSince(); + + // 수동 OFF가 더 최근이면 그 시각부터 공복 + LocalDateTime base = last; + if (manual != null && (base == null || manual.isAfter(base))) { + long hours = Duration.between(manual, now).toHours(); + return FriendMealItemResponse.of(m.getId(), m.getUsername(), img, true, hours + "시간 공복이에요"); + } + + if (last == null) { + return FriendMealItemResponse.of(m.getId(), m.getUsername(), img, true, "공복이에요"); + } + + LocalDateTime offAt = last.plus(window); + if (now.isBefore(offAt)) { + return FriendMealItemResponse.of(m.getId(), m.getUsername(), img, false, "방금 먹었어요"); + } else { + long hours = Duration.between(last, now).toHours(); + return FriendMealItemResponse.of(m.getId(), m.getUsername(), img, true, hours + "시간 공복이에요"); + } + } + + // 데모 아이템 생성(부족분 채우기) + private List fakeItems(int count, long startId) { + List names = List.of( + "박진아","권현욱","고지완","박진홍","임새연","이정훈","박지현","정민우","윤서현","한지우", + "전민경","박민재","김윤아","최서준","이다은","오지호","문지안","노시윤","하다온","백도윤", + "임지후","이하준","장수빈","신태윤","권서아","허도영","남유진","배승우","전해린","조시후" + ); + Random r = new Random(Objects.hash(LocalDate.now(clock))); + List list = new ArrayList<>(); + for (int i = 0; i < count; i++) { + boolean hungry = r.nextBoolean(); + int hours = hungry ? (1 + r.nextInt(10)) : 0; + String label = hungry ? (hours + "시간 공복이에요") : "방금 먹었어요"; + String img = "https://picsum.photos/seed/friend" + (i+1) + "/80/80"; + list.add(FriendMealItemResponse.of(startId + i, names.get(i % names.size()), img, hungry, label)); + } + return list; + } +} diff --git a/src/main/java/com/example/timetoeat/domain/meal/controller/MealStatusController.java b/src/main/java/com/example/timetoeat/domain/meal/controller/MealStatusController.java new file mode 100644 index 0000000..a143a1c --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/controller/MealStatusController.java @@ -0,0 +1,39 @@ +package com.example.timetoeat.domain.meal.controller; + +import com.example.timetoeat.domain.meal.dto.request.UpdateMealStatusRequest; +import com.example.timetoeat.domain.meal.dto.response.MealStatusResponse; +import com.example.timetoeat.domain.meal.service.MealStatusService; +import com.example.timetoeat.global.common.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Validated +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +public class MealStatusController { + + private final MealStatusService service; + + // 내 식사 상태 조회 + @GetMapping("/me/meal-status") + public ApiResponse getMyStatus( + @AuthenticationPrincipal(expression = "memberId") Long meId) { + return ApiResponse.success(service.getMyStatus(meId)); + } + + // 토글(ON=ATE_NOW / OFF=SET_OFF) + @PatchMapping("/me/meal-status") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update( + @AuthenticationPrincipal(expression = "memberId") Long meId, + @Valid @RequestBody UpdateMealStatusRequest req) { + service.update(meId, req.getAction()); + } +} diff --git a/src/main/java/com/example/timetoeat/domain/meal/dto/request/UpdateMealStatusRequest.java b/src/main/java/com/example/timetoeat/domain/meal/dto/request/UpdateMealStatusRequest.java new file mode 100644 index 0000000..9ddfd50 --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/dto/request/UpdateMealStatusRequest.java @@ -0,0 +1,14 @@ +package com.example.timetoeat.domain.meal.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UpdateMealStatusRequest { + + public enum Action { ATE_NOW, SET_OFF } // ON(밥 먹음) / OFF(공복 시작) + + @NotNull + private Action action; +} \ No newline at end of file diff --git a/src/main/java/com/example/timetoeat/domain/meal/dto/response/MealStatusResponse.java b/src/main/java/com/example/timetoeat/domain/meal/dto/response/MealStatusResponse.java new file mode 100644 index 0000000..20a65d3 --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/dto/response/MealStatusResponse.java @@ -0,0 +1,44 @@ +package com.example.timetoeat.domain.meal.dto.response; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MealStatusResponse { + + public enum Status { FED, FASTING } + + private Status status; // ON(FED) / OFF(FASTING) + private LocalDateTime lastMealAt; + private long fastingMinutes; // FASTING(공복상태)일 때 공복 분 + private long fastingHours; // FASTING일 때 공복 시간(내림) + private long secondsToAutoOff; // FED(음식 섭취)일 때 자동 OFF까지 남은 초(<= 4h) + + @Builder + private MealStatusResponse(Status status, LocalDateTime lastMealAt, + long fastingMinutes, long fastingHours, long secondsToAutoOff) { + this.status = status; + this.lastMealAt = lastMealAt; + this.fastingMinutes = fastingMinutes; + this.fastingHours = fastingHours; + this.secondsToAutoOff = secondsToAutoOff; + } + + public static MealStatusResponse fed(LocalDateTime lastMealAt, long secondsToAutoOff) { + return MealStatusResponse.builder() + .status(Status.FED).lastMealAt(lastMealAt) + .fastingMinutes(0).fastingHours(0) + .secondsToAutoOff(secondsToAutoOff) + .build(); + } + + public static MealStatusResponse fasting(LocalDateTime lastMealAt, long fastingMin, long fastingHr) { + return MealStatusResponse.builder() + .status(Status.FASTING).lastMealAt(lastMealAt) + .fastingMinutes(fastingMin).fastingHours(fastingHr) + .secondsToAutoOff(0) + .build(); + } +} diff --git a/src/main/java/com/example/timetoeat/domain/meal/entity/MemberMealStatus.java b/src/main/java/com/example/timetoeat/domain/meal/entity/MemberMealStatus.java new file mode 100644 index 0000000..43aa33f --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/entity/MemberMealStatus.java @@ -0,0 +1,53 @@ +package com.example.timetoeat.domain.meal.entity; + +import com.example.timetoeat.domain.member.entity.MemberEntity; +import com.example.timetoeat.global.util.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +import static jakarta.persistence.FetchType.LAZY; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member_meal_status", + uniqueConstraints = @UniqueConstraint(name = "uk_meal_member", columnNames = "member_id")) +@Entity +public class MemberMealStatus extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + private MemberEntity member; + + // 마지막 식사 시각(수동 ON/게시글/태그 시 갱신) + @Column(name = "last_meal_at") + private LocalDateTime lastMealAt; + + // 수동 OFF 시각(있으면 이때부터 공복으로 간주) + @Column(name = "manual_fasting_since") + private LocalDateTime manualFastingSince; + + @Builder + private MemberMealStatus(MemberEntity member, LocalDateTime lastMealAt, LocalDateTime manualFastingSince) { + this.member = member; + this.lastMealAt = lastMealAt; + this.manualFastingSince = manualFastingSince; + } + + public static MemberMealStatus of(MemberEntity member) { + return MemberMealStatus.builder().member(member).build(); + } + + public void ate(LocalDateTime when) { + this.lastMealAt = when; + this.manualFastingSince = null; // 수동 OFF 해제 + } + + public void setManualFasting(LocalDateTime since) { + this.manualFastingSince = since; + } +} diff --git a/src/main/java/com/example/timetoeat/domain/meal/listener/MealStatusOnArticleCreatedListener.java b/src/main/java/com/example/timetoeat/domain/meal/listener/MealStatusOnArticleCreatedListener.java new file mode 100644 index 0000000..da67bfa --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/listener/MealStatusOnArticleCreatedListener.java @@ -0,0 +1,30 @@ +package com.example.timetoeat.domain.meal.listener; + +import com.example.timetoeat.domain.article.event.ArticleCreatedEvent; +import com.example.timetoeat.domain.article.repository.ArticleTagRepository; +import com.example.timetoeat.domain.meal.service.MealStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MealStatusOnArticleCreatedListener { + + private final MealStatusService mealStatusService; + private final ArticleTagRepository tagRepository; + + // ArticleCommandService#createArticle() 에서 발행되는 이벤트를 AFTER_COMMIT에 처리 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ArticleCreatedEvent e) { + // 1) 글쓴이 ON + mealStatusService.markAte(e.getAuthorId(), e.getMealAtKst()); + + // 2) 태그된 멤버도 ON + List taggedIds = tagRepository.findTaggedMemberIdsByArticleId(e.getArticleId()); + taggedIds.forEach(id -> mealStatusService.markAte(id, e.getMealAtKst())); + } +} diff --git a/src/main/java/com/example/timetoeat/domain/meal/repository/MemberMealStatusRepository.java b/src/main/java/com/example/timetoeat/domain/meal/repository/MemberMealStatusRepository.java new file mode 100644 index 0000000..30352fb --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/repository/MemberMealStatusRepository.java @@ -0,0 +1,14 @@ +package com.example.timetoeat.domain.meal.repository; + +import com.example.timetoeat.domain.meal.entity.MemberMealStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MemberMealStatusRepository extends JpaRepository { + + Optional findByMember_Id(Long memberId); + + List findByMember_IdIn(java.util.Collection ids); +} \ No newline at end of file diff --git a/src/main/java/com/example/timetoeat/domain/meal/service/MealStatusService.java b/src/main/java/com/example/timetoeat/domain/meal/service/MealStatusService.java new file mode 100644 index 0000000..4e09aa4 --- /dev/null +++ b/src/main/java/com/example/timetoeat/domain/meal/service/MealStatusService.java @@ -0,0 +1,90 @@ +// src/main/java/com/example/timetoeat/domain/meal/service/MealStatusService.java +package com.example.timetoeat.domain.meal.service; + +import com.example.timetoeat.domain.meal.dto.request.UpdateMealStatusRequest.Action; +import com.example.timetoeat.domain.meal.dto.response.MealStatusResponse; +import com.example.timetoeat.domain.meal.entity.MemberMealStatus; +import com.example.timetoeat.domain.meal.repository.MemberMealStatusRepository; +import com.example.timetoeat.domain.member.exception.MemberErrorCode; +import com.example.timetoeat.domain.member.repository.MemberJpaRepository; +import com.example.timetoeat.global.error.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; + +@Service +@RequiredArgsConstructor +@Transactional +public class MealStatusService { + + private static final Duration WINDOW = Duration.ofHours(4); + + private final MemberMealStatusRepository repo; + private final MemberJpaRepository memberRepo; + @Qualifier("kstClock") + private final Clock clock; + + // 게시글 작성/태그에 의해 "먹음" 처리 + public void markAte(Long memberId) { + var now = LocalDateTime.now(clock); + var status = getOrCreate(memberId); + status.ate(now); + } + + // 토글 API: ATE_NOW / SET_OFF + public void update(Long memberId, Action action) { + var status = getOrCreate(memberId); + var now = LocalDateTime.now(clock); + switch (action) { + case ATE_NOW -> status.ate(now); + case SET_OFF -> status.setManualFasting(now); + } + } + + @Transactional(readOnly = true) + public MealStatusResponse getMyStatus(Long memberId) { + var now = LocalDateTime.now(clock); + var sOpt = repo.findByMember_Id(memberId); + if (sOpt.isEmpty()) { + // 기록이 없으면 공복 상태로 간주(0시간 공복) + return MealStatusResponse.fasting(null, 0, 0); + } + var s = sOpt.get(); + LocalDateTime base = s.getLastMealAt(); + // 수동 OFF가 더 최근이면 그 시점부터 공복으로 간주 + if (s.getManualFastingSince() != null + && (base == null || s.getManualFastingSince().isAfter(base))) { + base = s.getManualFastingSince(); + long mins = Duration.between(base, now).toMinutes(); + return MealStatusResponse.fasting(s.getLastMealAt(), mins, mins / 60); + } + + if (base == null) { + long mins = 0; + return MealStatusResponse.fasting(null, mins, mins / 60); + } + + LocalDateTime offAt = base.plus(WINDOW); + if (now.isBefore(offAt)) { + long secLeft = Duration.between(now, offAt).toSeconds(); + return MealStatusResponse.fed(s.getLastMealAt(), secLeft); + } else { + long mins = Duration.between(base, now).toMinutes(); + return MealStatusResponse.fasting(s.getLastMealAt(), mins, mins / 60); + } + } + + private MemberMealStatus getOrCreate(Long memberId) { + var m = memberRepo.findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + return repo.findByMember_Id(memberId).orElseGet(() -> repo.save(MemberMealStatus.of(m))); + } + + public void markAte(Long memberId, LocalDateTime when) { + var status = getOrCreate(memberId); + status.ate(when); // lastMealAt = when, manualFastingSince = null + } +} diff --git a/src/main/java/com/example/timetoeat/domain/member/repository/MemberJpaRepository.java b/src/main/java/com/example/timetoeat/domain/member/repository/MemberJpaRepository.java index 3a1fda9..c0ad5f9 100644 --- a/src/main/java/com/example/timetoeat/domain/member/repository/MemberJpaRepository.java +++ b/src/main/java/com/example/timetoeat/domain/member/repository/MemberJpaRepository.java @@ -3,8 +3,12 @@ import com.example.timetoeat.domain.member.entity.MemberEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface MemberJpaRepository extends JpaRepository { + Optional findByEmail(String email); + + List findTop30ByIdNotOrderByCreatedAtDesc(Long id); }