feat: 스팟/체크인/한줄평 API 및 로컬(H2) 실행 프로파일

- GET /api/spots, POST /{id}/checkins, GET /{id}/mates, GET·POST /{id}/reviews
- Spot/CheckIn/SpotReview 엔티티·서비스 (체크인 2시간 TTL, 만료 비노출)
- CORS 허용(Vite dev, Capacitor iOS/AOS)
- local 프로파일: Postgres 없이 H2로 기동 + 시드 데이터 (연동 검증용)
- V2 마이그레이션: spots 위경도 컬럼 추가, geom NOT NULL 완화

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 08:07:43 +09:00
parent 1befad06d2
commit 115bc13ea1
15 changed files with 550 additions and 1 deletions
+3 -1
View File
@@ -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') {
@@ -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<SpotResponse> list() {
return spotService.listSpots().stream().map(SpotResponse::from).toList();
}
/** 스팟 체크인 ("나 지금 여기 도착!") */
@PostMapping("/{id}/checkins")
public ResponseEntity<CheckInResponse> 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<MateResponse> mates(@PathVariable Long id) {
return spotService.activeMates(id).stream().map(MateResponse::from).toList();
}
/** 스팟 한줄평 목록 */
@GetMapping("/{id}/reviews")
public List<ReviewResponse> reviews(@PathVariable Long id) {
return spotService.reviews(id).stream().map(ReviewResponse::from).toList();
}
/** 스팟 한줄평 작성 */
@PostMapping("/{id}/reviews")
public ResponseEntity<ReviewResponse> 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);
}
}
@@ -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) {
}
}
@@ -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);
}
}
@@ -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("*");
}
}
@@ -60,4 +60,8 @@ public class Dog {
public String getName() {
return name;
}
public String getBreed() {
return breed;
}
}
@@ -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;
}
}
@@ -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<CheckIn, Long> {
/** 스팟에 현재 유효한(만료 전) 체크인 목록. */
List<CheckIn> findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(
Long spotId, OffsetDateTime now);
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.dog.dognation.domain.spot;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpotRepository extends JpaRepository<Spot, Long> {
}
@@ -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;
}
}
@@ -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<SpotReview, Long> {
List<SpotReview> findBySpotIdOrderByCreatedAtDesc(Long spotId);
}
@@ -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<Spot> 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<Dog> activeMates(Long spotId) {
List<CheckIn> active = checkInRepository
.findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(spotId, OffsetDateTime.now());
List<Long> dogIds = active.stream().map(CheckIn::getDogId).distinct().toList();
return dogRepository.findAllById(dogIds);
}
@Transactional(readOnly = true)
public List<SpotReview> 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));
}
}
+23
View File
@@ -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
@@ -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;