Skip to content

Commit b7ce91a

Browse files
authored
Merge pull request #23 from dev-DDIP/Feat/issue-#21
Feat: S2 기반 위치 관리 기능 구현
2 parents f1e0d13 + 7ee71d3 commit b7ce91a

33 files changed

Lines changed: 1383 additions & 2 deletions

build.gradle

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ configurations {
2222

2323
repositories {
2424
mavenCentral()
25+
maven { url 'https://jitpack.io' }
2526
}
2627

2728
dependencies {
@@ -41,6 +42,7 @@ dependencies {
4142
runtimeOnly 'com.mysql:mysql-connector-j'
4243
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
4344
implementation 'org.springframework.session:spring-session-data-redis'
45+
implementation 'org.redisson:redisson-spring-boot-starter:3.23.1'
4446

4547
// Lombok
4648
compileOnly 'org.projectlombok:lombok'
@@ -60,10 +62,13 @@ dependencies {
6062
implementation 'dev.langchain4j:langchain4j:1.0.0-beta1'
6163
implementation 'dev.langchain4j:langchain4j-google-ai-gemini:1.0.0-beta1'
6264

65+
// S2
66+
implementation 'com.github.google:s2-geometry-library-java:2.0.0'
67+
6368
// etc
6469
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.7.0'
6570
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.1.0'
66-
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.1.0'
71+
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.1.0'
6772
}
6873

6974
tasks.named('test') {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.knu.ddip.common.config;
2+
3+
import org.redisson.Redisson;
4+
import org.redisson.api.RedissonClient;
5+
import org.redisson.config.Config;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.context.annotation.Profile;
10+
11+
@Profile("!test")
12+
@Configuration
13+
public class RedissonConfig {
14+
15+
@Value("${REDIS_HOST}")
16+
private String host;
17+
18+
@Value("${REDIS_PORT}")
19+
private int port;
20+
21+
@Value("${REDIS_PASSWORD}")
22+
private String password;
23+
24+
@Bean
25+
public RedissonClient redissonClient() {
26+
Config config = new Config();
27+
28+
config.useSingleServer()
29+
.setAddress("redis://" + host + ":" + port)
30+
.setPassword(password)
31+
.setConnectionPoolSize(10)
32+
.setConnectionMinimumIdleSize(1);
33+
34+
return Redisson.create(config);
35+
}
36+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.knu.ddip.common.config;
2+
3+
import net.javacrumbs.shedlock.core.LockProvider;
4+
import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
5+
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.data.redis.connection.RedisConnectionFactory;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
10+
11+
@EnableScheduling
12+
@Configuration
13+
@EnableSchedulerLock(defaultLockAtLeastFor = "30s", defaultLockAtMostFor = "1m")
14+
public class ScheduleConfig {
15+
@Bean
16+
public LockProvider lockProvider(RedisConnectionFactory redisConnectionFactory) {
17+
return new RedisLockProvider(redisConnectionFactory);
18+
}
19+
}

src/main/java/com/knu/ddip/common/exception/GlobalExceptionHandler.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.knu.ddip.common.exception;
22

33
import com.knu.ddip.auth.exception.*;
4+
import com.knu.ddip.location.exception.LocationNotFoundException;
45
import org.springframework.http.HttpStatus;
56
import org.springframework.http.HttpStatusCode;
67
import org.springframework.http.ProblemDetail;
@@ -67,4 +68,12 @@ public ResponseEntity<ProblemDetail> handleTokenStolenException(TokenStolenExcep
6768
return ResponseEntity.status(HttpStatusCode.valueOf(462)).body(problemDetail);
6869
}
6970

71+
@ExceptionHandler(LocationNotFoundException.class)
72+
public ResponseEntity<ProblemDetail> handleLocationNotFoundException(LocationNotFoundException e) {
73+
74+
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
75+
problemDetail.setTitle("Location Not Found");
76+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail);
77+
}
78+
7079
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.knu.ddip.location.application.dto;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record UpdateMyLocationRequest(
7+
double lat,
8+
double lng
9+
) {
10+
public static UpdateMyLocationRequest of(double lat, double lng) {
11+
return new UpdateMyLocationRequest(lat, lng);
12+
}
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.knu.ddip.location.application.init;
2+
3+
import com.knu.ddip.location.application.service.LocationService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.boot.ApplicationArguments;
6+
import org.springframework.boot.ApplicationRunner;
7+
import org.springframework.stereotype.Component;
8+
9+
@Component
10+
@RequiredArgsConstructor
11+
public class GeoJsonInitializer implements ApplicationRunner {
12+
13+
public static final String GEOJSON_INIT_LOCK_KEY = "lock:geojson:init";
14+
15+
private final OneTimeRunner oneTimeRunner;
16+
private final LocationService locationService;
17+
18+
@Override
19+
public void run(ApplicationArguments args) {
20+
oneTimeRunner.runOnce(
21+
GEOJSON_INIT_LOCK_KEY,
22+
() -> locationService.loadAndSaveGeoJsonFeatures()
23+
);
24+
}
25+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.knu.ddip.location.application.init;
2+
3+
public interface OneTimeRunner {
4+
void runOnce(String lockName, Runnable task);
5+
}
6+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.knu.ddip.location.application.scheduler;
2+
3+
import com.knu.ddip.location.application.service.LocationWriter;
4+
import lombok.RequiredArgsConstructor;
5+
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
6+
import org.springframework.scheduling.annotation.Scheduled;
7+
import org.springframework.stereotype.Component;
8+
9+
@Component
10+
@RequiredArgsConstructor
11+
public class LocationScheduler {
12+
13+
private final LocationWriter locationWriter;
14+
15+
@SchedulerLock(
16+
name = "cleanup_locations_lock",
17+
lockAtLeastFor = "30s",
18+
lockAtMostFor = "5m"
19+
)
20+
@Scheduled(cron = "0 0 * * * *") // 매 정시
21+
public void cleanupExpiredUserLocations() {
22+
long now = System.currentTimeMillis();
23+
locationWriter.cleanupExpiredUserLocations(now);
24+
}
25+
26+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.knu.ddip.location.application.service;
2+
3+
import java.util.List;
4+
5+
public interface LocationReader {
6+
void validateLocationByCellId(String cellId);
7+
8+
List<String> findAllLocationsByCellIdIn(List<String> cellIds);
9+
10+
List<String> findUserIdsByCellIds(List<String> targetCellIds);
11+
12+
boolean isCellIdNotInTargetArea(String cellId);
13+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.knu.ddip.location.application.service;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.google.common.geometry.S2CellId;
6+
import com.knu.ddip.location.application.dto.UpdateMyLocationRequest;
7+
import com.knu.ddip.location.application.util.S2Converter;
8+
import com.knu.ddip.location.application.util.UuidBase64Utils;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.core.io.ClassPathResource;
12+
import org.springframework.stereotype.Service;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
import java.io.IOException;
16+
import java.io.InputStream;
17+
import java.nio.charset.StandardCharsets;
18+
import java.util.ArrayList;
19+
import java.util.List;
20+
import java.util.UUID;
21+
import java.util.stream.Collectors;
22+
import java.util.stream.StreamSupport;
23+
24+
@Slf4j
25+
@Service
26+
@Transactional(readOnly = true)
27+
@RequiredArgsConstructor
28+
public class LocationService {
29+
30+
private final LocationReader locationReader;
31+
private final LocationWriter locationWriter;
32+
33+
private final ObjectMapper objectMapper;
34+
35+
public static final int LEVEL = 17;
36+
public static final String KNU_GEOJSON_FEATURE_FILENAME = "geojson/cells.geojson";
37+
38+
// KNU GeoJSON 파일을 읽어서 각 Feature를 DB에 저장
39+
@Transactional
40+
public void loadAndSaveGeoJsonFeatures() {
41+
// GeoJson 파일 읽기
42+
String geoJsonContent = getGeoJsonContent();
43+
44+
try {
45+
// 기존 데이터 삭제
46+
locationWriter.deleteAll();
47+
48+
// JSON 파싱
49+
JsonNode rootNode = objectMapper.readTree(geoJsonContent);
50+
JsonNode featuresNode = rootNode.get("features");
51+
List<String> cellIds = StreamSupport.stream(featuresNode.spliterator(), false)
52+
.map(featureNode -> featureNode.get("properties").get("id").asText())
53+
.collect(Collectors.toList());
54+
55+
locationWriter.saveAll(cellIds);
56+
57+
log.info("총 {}개의 S2Cell Feature가 저장되었습니다.", cellIds.size());
58+
} catch (IOException e) {
59+
e.printStackTrace();
60+
log.error(e.getMessage());
61+
}
62+
}
63+
64+
public void saveUserLocationAtomic(UUID userId, UpdateMyLocationRequest request) {
65+
S2CellId cellIdObj = S2Converter.toCellId(request.lat(), request.lng(), LEVEL);
66+
String cellId = cellIdObj.toToken();
67+
68+
// 경북대 내부에 위치하는지 확인
69+
boolean cellIdNotInTargetArea = locationReader.isCellIdNotInTargetArea(cellId);
70+
71+
String encodedUserId = UuidBase64Utils.uuidToBase64String(userId);
72+
73+
locationWriter.saveUserIdByCellIdAtomic(cellId, cellIdNotInTargetArea, encodedUserId);
74+
}
75+
76+
// 요청 전송 시 이웃 userIds 조회
77+
public List<UUID> getNeighborRecipientUserIds(UUID myUserId, double lat, double lng) {
78+
S2CellId cellIdObj = S2Converter.toCellId(lat, lng, LEVEL);
79+
String cellId = cellIdObj.toToken();
80+
81+
// 경북대 내부에 위치하는지 확인
82+
locationReader.validateLocationByCellId(cellId);
83+
84+
// 이웃 cellIds 가져오기
85+
List<S2CellId> neighbors = new ArrayList<>();
86+
cellIdObj.getAllNeighbors(LEVEL, neighbors);
87+
List<String> neighborCellIds = neighbors.stream()
88+
.map(S2CellId::toToken)
89+
.collect(Collectors.toList());
90+
91+
// 경북대 내부에 위치하는 이웃 cellIds만 가져오기
92+
List<String> targetCellIds = locationReader.findAllLocationsByCellIdIn(neighborCellIds);
93+
targetCellIds.add(cellId);
94+
95+
// targetCellId의 userIds만 가져오기
96+
97+
List<UUID> userIds = locationReader.findUserIdsByCellIds(targetCellIds).stream()
98+
.map(UuidBase64Utils::base64StringToUuid)
99+
.filter(userId -> !userId.equals(myUserId))
100+
.collect(Collectors.toList());
101+
102+
return userIds;
103+
}
104+
105+
// 초기 화면에서 인접한 요청 가져오기 (현재는 인접한 cellId 가져오는 것만 구현)
106+
public List<String> getNeighborCellIds(double lat, double lng) {
107+
S2CellId cellIdObj = S2Converter.toCellId(lat, lng, LEVEL);
108+
String cellId = cellIdObj.toToken();
109+
110+
// 경북대 내부에 위치하는지 확인
111+
locationReader.validateLocationByCellId(cellId);
112+
113+
// 이웃 cellIds 가져오기
114+
List<S2CellId> neighbors = new ArrayList<>();
115+
cellIdObj.getAllNeighbors(LEVEL, neighbors);
116+
List<String> neighborCellIds = neighbors.stream()
117+
.map(S2CellId::toToken)
118+
.collect(Collectors.toList());
119+
120+
// 경북대 내부에 위치하는 이웃 cellIds만 가져오기
121+
List<String> targetCellIds = locationReader.findAllLocationsByCellIdIn(neighborCellIds);
122+
targetCellIds.add(cellId);
123+
124+
return targetCellIds;
125+
}
126+
127+
private String getGeoJsonContent() {
128+
ClassPathResource resource = new ClassPathResource(KNU_GEOJSON_FEATURE_FILENAME);
129+
try (InputStream is = resource.getInputStream()) {
130+
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
131+
} catch (IOException e) {
132+
throw new RuntimeException(e);
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)