Skip to content
Merged

Dev #101

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
7 changes: 4 additions & 3 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<FriendMealItemResponse>> getList(
@AuthenticationPrincipal(expression = "memberId") Long meId,
@RequestParam(defaultValue = "ALL")
@Pattern(regexp = "ALL|HUNGRY|NOT_HUNGRY") String filter
) {
return ApiResponse.success(service.getDemoList(meId, filter));
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<FriendMealItemResponse> getDemoList(Long meId, String filter) {
// 1) 실제 가입자 30명 (me 제외)
List<MemberEntity> members = memberRepo.findTop30ByIdNotOrderByCreatedAtDesc(meId);

// 2) 벌크로 식사상태 조회
Map<Long, MemberMealStatus> statusMap = mealRepo.findByMember_IdIn(
members.stream().map(MemberEntity::getId).toList()
).stream().collect(Collectors.toMap(s -> s.getMember().getId(), s -> s));

// 3) 실제 사용자 아이템 생성
List<FriendMealItemResponse> 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<FriendMealItemResponse> fakeItems(int count, long startId) {
List<String> names = List.of(
"박진아","권현욱","고지완","박진홍","임새연","이정훈","박지현","정민우","윤서현","한지우",
"전민경","박민재","김윤아","최서준","이다은","오지호","문지안","노시윤","하다온","백도윤",
"임지후","이하준","장수빈","신태윤","권서아","허도영","남유진","배승우","전해린","조시후"
);
Random r = new Random(Objects.hash(LocalDate.now(clock)));
List<FriendMealItemResponse> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<MealStatusResponse> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> taggedIds = tagRepository.findTaggedMemberIdsByArticleId(e.getArticleId());
taggedIds.forEach(id -> mealStatusService.markAte(id, e.getMealAtKst()));
}
}
Original file line number Diff line number Diff line change
@@ -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<MemberMealStatus, Long> {

Optional<MemberMealStatus> findByMember_Id(Long memberId);

List<MemberMealStatus> findByMember_IdIn(java.util.Collection<Long> ids);
}
Loading