diff --git a/build.gradle b/build.gradle index 3009579..2169fd7 100644 --- a/build.gradle +++ b/build.gradle @@ -31,9 +31,11 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' + // H2: 로컬 실행 프로파일(local) 및 테스트용 인메모리 DB + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testRuntimeOnly 'com.h2database:h2' } tasks.named('test') { diff --git a/src/main/java/com/dog/dognation/api/SpotController.java b/src/main/java/com/dog/dognation/api/SpotController.java new file mode 100644 index 0000000..00dc9eb --- /dev/null +++ b/src/main/java/com/dog/dognation/api/SpotController.java @@ -0,0 +1,67 @@ +package com.dog.dognation.api; + +import com.dog.dognation.api.dto.SpotDtos.CheckInRequest; +import com.dog.dognation.api.dto.SpotDtos.CheckInResponse; +import com.dog.dognation.api.dto.SpotDtos.MateResponse; +import com.dog.dognation.api.dto.SpotDtos.ReviewCreateRequest; +import com.dog.dognation.api.dto.SpotDtos.ReviewResponse; +import com.dog.dognation.api.dto.SpotDtos.SpotResponse; +import com.dog.dognation.domain.spot.SpotService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/spots") +public class SpotController { + + private final SpotService spotService; + + public SpotController(SpotService spotService) { + this.spotService = spotService; + } + + /** 스팟 목록 */ + @GetMapping + public List list() { + return spotService.listSpots().stream().map(SpotResponse::from).toList(); + } + + /** 스팟 체크인 ("나 지금 여기 도착!") */ + @PostMapping("/{id}/checkins") + public ResponseEntity checkIn(@PathVariable Long id, + @Valid @RequestBody CheckInRequest req) { + CheckInResponse body = CheckInResponse.from( + spotService.checkIn(id, req.userId(), req.dogId())); + return ResponseEntity.status(HttpStatus.CREATED).body(body); + } + + /** 스팟에 현재 체크인 중인 메이트 목록 */ + @GetMapping("/{id}/mates") + public List mates(@PathVariable Long id) { + return spotService.activeMates(id).stream().map(MateResponse::from).toList(); + } + + /** 스팟 한줄평 목록 */ + @GetMapping("/{id}/reviews") + public List reviews(@PathVariable Long id) { + return spotService.reviews(id).stream().map(ReviewResponse::from).toList(); + } + + /** 스팟 한줄평 작성 */ + @PostMapping("/{id}/reviews") + public ResponseEntity addReview(@PathVariable Long id, + @Valid @RequestBody ReviewCreateRequest req) { + ReviewResponse body = ReviewResponse.from( + spotService.addReview(id, req.userId(), req.tag(), req.content())); + return ResponseEntity.status(HttpStatus.CREATED).body(body); + } +} diff --git a/src/main/java/com/dog/dognation/api/dto/SpotDtos.java b/src/main/java/com/dog/dognation/api/dto/SpotDtos.java new file mode 100644 index 0000000..8f5b0ea --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/SpotDtos.java @@ -0,0 +1,51 @@ +package com.dog.dognation.api.dto; + +import com.dog.dognation.domain.dog.Dog; +import com.dog.dognation.domain.spot.CheckIn; +import com.dog.dognation.domain.spot.Spot; +import com.dog.dognation.domain.spot.SpotReview; +import jakarta.validation.constraints.NotNull; + +import java.time.OffsetDateTime; + +/** 스팟 관련 요청/응답 DTO 모음. */ +public final class SpotDtos { + + private SpotDtos() { + } + + public record SpotResponse(Long id, String name, String type, Double latitude, Double longitude) { + public static SpotResponse from(Spot s) { + return new SpotResponse(s.getId(), s.getName(), s.getType(), s.getLatitude(), s.getLongitude()); + } + } + + public record MateResponse(Long dogId, String name, String breed) { + public static MateResponse from(Dog d) { + return new MateResponse(d.getId(), d.getName(), d.getBreed()); + } + } + + public record ReviewResponse(Long id, String tag, String content, OffsetDateTime createdAt) { + public static ReviewResponse from(SpotReview r) { + return new ReviewResponse(r.getId(), r.getTag(), r.getContent(), r.getCreatedAt()); + } + } + + public record CheckInResponse(Long id, Long spotId, Long dogId, OffsetDateTime expiresAt) { + public static CheckInResponse from(CheckIn c) { + return new CheckInResponse(c.getId(), c.getSpotId(), c.getDogId(), c.getExpiresAt()); + } + } + + public record CheckInRequest( + @NotNull(message = "userId 는 필수입니다.") Long userId, + @NotNull(message = "dogId 는 필수입니다.") Long dogId) { + } + + public record ReviewCreateRequest( + @NotNull(message = "userId 는 필수입니다.") Long userId, + String tag, + String content) { + } +} diff --git a/src/main/java/com/dog/dognation/config/LocalSeedData.java b/src/main/java/com/dog/dognation/config/LocalSeedData.java new file mode 100644 index 0000000..5f11128 --- /dev/null +++ b/src/main/java/com/dog/dognation/config/LocalSeedData.java @@ -0,0 +1,76 @@ +package com.dog.dognation.config; + +import com.dog.dognation.domain.spot.SpotService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * local 프로파일 전용 시드 데이터. + * 프론트-백 연동을 곧바로 확인할 수 있도록 유저/강아지/스팟/체크인/한줄평을 채운다. + */ +@Component +@Profile("local") +public class LocalSeedData implements CommandLineRunner { + + private final JdbcTemplate jdbc; + private final SpotService spotService; + + public LocalSeedData(JdbcTemplate jdbc, SpotService spotService) { + this.jdbc = jdbc; + this.spotService = spotService; + } + + @Override + public void run(String... args) { + // 유저 (FREE / PREMIUM) + long me = insertUser("me@dog.com", "나", "FREE"); + long other = insertUser("mate@dog.com", "이웃", "PREMIUM"); + + // 강아지 + long myDog = insertDog(me, "몽이", "푸들"); + long mate1 = insertDog(other, "콩이", "말티즈"); + long mate2 = insertDog(other, "보리", "리트리버"); + + // 스팟 + long hangang = insertSpot("한강공원 산책로", "TRAIL", 37.5283, 126.9327); + insertSpot("서울숲 반려견 놀이터", "PLAYGROUND", 37.5445, 127.0374); + + // 이웃 강아지들이 한강공원에 체크인 중 + spotService.checkIn(hangang, other, mate1); + spotService.checkIn(hangang, other, mate2); + + // 한줄평 + spotService.addReview(hangang, other, "진드기 많아요", null); + spotService.addReview(hangang, other, "그늘 시원해요", null); + spotService.addReview(hangang, me, "물그릇 있음", null); + + System.out.printf("[local seed] me=%d, myDog=%d, spot(한강)=%d 준비 완료%n", me, myDog, hangang); + } + + private long insertUser(String email, String nickname, String tier) { + jdbc.update(""" + INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at) + VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, email, nickname, tier); + return jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); + } + + private long insertDog(long userId, String name, String breed) { + jdbc.update(""" + INSERT INTO dogs(user_id, name, breed, neutered, created_at) + VALUES (?, ?, ?, false, CURRENT_TIMESTAMP) + """, userId, name, breed); + return jdbc.queryForObject( + "SELECT id FROM dogs WHERE user_id = ? AND name = ?", Long.class, userId, name); + } + + private long insertSpot(String name, String type, double lat, double lng) { + jdbc.update(""" + INSERT INTO spots(name, type, latitude, longitude, created_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + """, name, type, lat, lng); + return jdbc.queryForObject("SELECT id FROM spots WHERE name = ?", Long.class, name); + } +} diff --git a/src/main/java/com/dog/dognation/config/WebConfig.java b/src/main/java/com/dog/dognation/config/WebConfig.java new file mode 100644 index 0000000..c34b356 --- /dev/null +++ b/src/main/java/com/dog/dognation/config/WebConfig.java @@ -0,0 +1,23 @@ +package com.dog.dognation.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins( + "http://localhost:9000", // Vite dev server + "http://localhost", // Capacitor(Android) + "capacitor://localhost", // Capacitor(iOS) + "ionic://localhost" + ) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } +} diff --git a/src/main/java/com/dog/dognation/domain/dog/Dog.java b/src/main/java/com/dog/dognation/domain/dog/Dog.java index 1214031..1ce9019 100644 --- a/src/main/java/com/dog/dognation/domain/dog/Dog.java +++ b/src/main/java/com/dog/dognation/domain/dog/Dog.java @@ -60,4 +60,8 @@ public class Dog { public String getName() { return name; } + + public String getBreed() { + return breed; + } } diff --git a/src/main/java/com/dog/dognation/domain/spot/CheckIn.java b/src/main/java/com/dog/dognation/domain/spot/CheckIn.java new file mode 100644 index 0000000..0af27a7 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/CheckIn.java @@ -0,0 +1,62 @@ +package com.dog.dognation.domain.spot; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** 스팟 체크인. 휘발성: expiresAt 이 지나면 만료(비노출). */ +@Entity +@Table(name = "check_ins") +public class CheckIn { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "dog_id", nullable = false) + private Long dogId; + + @Column(name = "checked_in_at", nullable = false) + private OffsetDateTime checkedInAt = OffsetDateTime.now(); + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + protected CheckIn() { + } + + public CheckIn(Long spotId, Long userId, Long dogId, OffsetDateTime expiresAt) { + this.spotId = spotId; + this.userId = userId; + this.dogId = dogId; + this.checkedInAt = OffsetDateTime.now(); + this.expiresAt = expiresAt; + } + + public Long getId() { + return id; + } + + public Long getSpotId() { + return spotId; + } + + public Long getDogId() { + return dogId; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } +} diff --git a/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java b/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java new file mode 100644 index 0000000..39af36e --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java @@ -0,0 +1,13 @@ +package com.dog.dognation.domain.spot; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.OffsetDateTime; +import java.util.List; + +public interface CheckInRepository extends JpaRepository { + + /** 스팟에 현재 유효한(만료 전) 체크인 목록. */ + List findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc( + Long spotId, OffsetDateTime now); +} diff --git a/src/main/java/com/dog/dognation/domain/spot/Spot.java b/src/main/java/com/dog/dognation/domain/spot/Spot.java new file mode 100644 index 0000000..be47a79 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/Spot.java @@ -0,0 +1,67 @@ +package com.dog.dognation.domain.spot; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** + * 가상 스팟(Anchor). 실시간 GPS 동선 대신 고정 스팟 좌표만 저장한다. + * (PostGIS geom 컬럼은 공간쿼리용으로 스키마에 유지하되, 엔티티는 lat/lng 만 매핑.) + */ +@Entity +@Table(name = "spots") +public class Spot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 150) + private String name; + + @Column(nullable = false, length = 30) + private String type = "PARK"; + + private Double latitude; + + private Double longitude; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected Spot() { + } + + public Spot(String name, String type, Double latitude, Double longitude) { + this.name = name; + this.type = type; + this.latitude = latitude; + this.longitude = longitude; + this.createdAt = OffsetDateTime.now(); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public Double getLatitude() { + return latitude; + } + + public Double getLongitude() { + return longitude; + } +} diff --git a/src/main/java/com/dog/dognation/domain/spot/SpotRepository.java b/src/main/java/com/dog/dognation/domain/spot/SpotRepository.java new file mode 100644 index 0000000..762cd46 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/SpotRepository.java @@ -0,0 +1,6 @@ +package com.dog.dognation.domain.spot; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpotRepository extends JpaRepository { +} diff --git a/src/main/java/com/dog/dognation/domain/spot/SpotReview.java b/src/main/java/com/dog/dognation/domain/spot/SpotReview.java new file mode 100644 index 0000000..b9c952d --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/SpotReview.java @@ -0,0 +1,62 @@ +package com.dog.dognation.domain.spot; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** 스팟 태그형 한줄평 (예: "진드기 많아요", "풀숲 무성함"). */ +@Entity +@Table(name = "spot_reviews") +public class SpotReview { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(length = 50) + private String tag; + + @Column(length = 200) + private String content; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected SpotReview() { + } + + public SpotReview(Long spotId, Long userId, String tag, String content) { + this.spotId = spotId; + this.userId = userId; + this.tag = tag; + this.content = content; + this.createdAt = OffsetDateTime.now(); + } + + public Long getId() { + return id; + } + + public String getTag() { + return tag; + } + + public String getContent() { + return content; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/dog/dognation/domain/spot/SpotReviewRepository.java b/src/main/java/com/dog/dognation/domain/spot/SpotReviewRepository.java new file mode 100644 index 0000000..85e1d33 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/SpotReviewRepository.java @@ -0,0 +1,10 @@ +package com.dog.dognation.domain.spot; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SpotReviewRepository extends JpaRepository { + + List findBySpotIdOrderByCreatedAtDesc(Long spotId); +} diff --git a/src/main/java/com/dog/dognation/domain/spot/SpotService.java b/src/main/java/com/dog/dognation/domain/spot/SpotService.java new file mode 100644 index 0000000..36fa161 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/SpotService.java @@ -0,0 +1,74 @@ +package com.dog.dognation.domain.spot; + +import com.dog.dognation.domain.dog.Dog; +import com.dog.dognation.domain.dog.DogRepository; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; + +@Service +public class SpotService { + + /** 체크인 유효시간(분). 이 시간이 지나면 스팟에서 자동 비노출. */ + private static final int CHECKIN_TTL_MINUTES = 120; + + private final SpotRepository spotRepository; + private final CheckInRepository checkInRepository; + private final SpotReviewRepository reviewRepository; + private final DogRepository dogRepository; + + public SpotService(SpotRepository spotRepository, + CheckInRepository checkInRepository, + SpotReviewRepository reviewRepository, + DogRepository dogRepository) { + this.spotRepository = spotRepository; + this.checkInRepository = checkInRepository; + this.reviewRepository = reviewRepository; + this.dogRepository = dogRepository; + } + + @Transactional(readOnly = true) + public List listSpots() { + return spotRepository.findAll(); + } + + @Transactional + public CheckIn checkIn(Long spotId, Long userId, Long dogId) { + if (!spotRepository.existsById(spotId)) { + throw new EntityNotFoundException("스팟을 찾을 수 없습니다: " + spotId); + } + if (!dogRepository.existsById(dogId)) { + throw new EntityNotFoundException("강아지를 찾을 수 없습니다: " + dogId); + } + OffsetDateTime expiresAt = OffsetDateTime.now().plusMinutes(CHECKIN_TTL_MINUTES); + return checkInRepository.save(new CheckIn(spotId, userId, dogId, expiresAt)); + } + + /** 스팟에 현재 체크인 중인 강아지(메이트) 목록. */ + @Transactional(readOnly = true) + public List activeMates(Long spotId) { + List active = checkInRepository + .findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(spotId, OffsetDateTime.now()); + List dogIds = active.stream().map(CheckIn::getDogId).distinct().toList(); + return dogRepository.findAllById(dogIds); + } + + @Transactional(readOnly = true) + public List reviews(Long spotId) { + return reviewRepository.findBySpotIdOrderByCreatedAtDesc(spotId); + } + + @Transactional + public SpotReview addReview(Long spotId, Long userId, String tag, String content) { + if (!spotRepository.existsById(spotId)) { + throw new EntityNotFoundException("스팟을 찾을 수 없습니다: " + spotId); + } + if ((tag == null || tag.isBlank()) && (content == null || content.isBlank())) { + throw new IllegalArgumentException("태그 또는 내용 중 하나는 입력해야 합니다."); + } + return reviewRepository.save(new SpotReview(spotId, userId, tag, content)); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..a096c5e --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,23 @@ +# 로컬 실행 프로파일 — Postgres/PostGIS 없이 인메모리 H2 로 앱을 띄운다. +# 프론트-백 연동을 로컬에서 검증하기 위한 용도. (PostGIS 공간쿼리는 미포함) +# 실행: SPRING_PROFILES_ACTIVE=local ./gradlew bootRun +spring: + datasource: + url: jdbc:h2:mem:dognation;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + + flyway: + enabled: false + + h2: + console: + enabled: true # http://localhost:8080/h2-console diff --git a/src/main/resources/db/migration/V2__add_spot_latlng.sql b/src/main/resources/db/migration/V2__add_spot_latlng.sql new file mode 100644 index 0000000..ddae6c5 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_spot_latlng.sql @@ -0,0 +1,9 @@ +-- 앱 레벨에서 다루기 쉬운 위경도 컬럼 추가. +-- 공간 인덱스/반경검색은 기존 geom(geography) 을 계속 사용하고, +-- geom 은 애플리케이션에서 lat/lng 로부터 채운다(또는 트리거로 동기화 예정). +ALTER TABLE spots ADD COLUMN latitude DOUBLE PRECISION; +ALTER TABLE spots ADD COLUMN longitude DOUBLE PRECISION; + +-- 엔티티가 geom 을 매핑하지 않으므로 JPA 저장 시 null 이 되도록 NOT NULL 완화. +-- (lat/lng 로부터 geom 백필은 추후 트리거/배치로 처리) +ALTER TABLE spots ALTER COLUMN geom DROP NOT NULL;