Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
52 changes: 52 additions & 0 deletions .github/workflows/jacoco.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ jobs:
test:
runs-on: ubuntu-22.04

services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test_password_1234
MYSQL_DATABASE: runners
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3

steps:
- uses: actions/checkout@v4
with:
Expand All @@ -24,6 +38,44 @@ jobs:
- name: Create .env file for CI environment
run: echo "${{ secrets.ENV_FILE_CONTENT }}" > .env

- name: Create test application.yml
run: |
mkdir -p src/test/resources
cat > src/test/resources/application.yml << 'EOF'
spring:
datasource:
url: jdbc:mysql://localhost:3306/runners?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: test_password_1234
driver-class-name: com.mysql.cj.jdbc.Driver

jpa:
database: mysql
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
show-sql: true

jwt:
secret: dGVzdF9qd3Rfc2VjcmV0X2tleV9mb3JfY2lfdGVzdGluZ19vbmx5XzEyMzQ1Njc4OTA=
access-token-expire-time: 1800000
refresh-token-expire-time: 1209600000

file:
upload:
dir: ${java.io.tmpdir}/test-uploads

swagger:
id: runners
pwd: runners123
server:
url:
prod: http://localhost:8080
EOF

- name: Grant execute permission for gradlew
run: chmod +x gradlew

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package run.backend.domain.running.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import run.backend.domain.crew.entity.Crew;
import run.backend.domain.running.dto.request.Coordinate;
import run.backend.domain.running.service.RunningService;
import run.backend.global.annotation.member.MemberCrew;
import run.backend.global.common.response.CommonResponse;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/running")
@Tag(name = "러닝 API", description = "러닝 관련 API")
public class RunningController {

private final RunningService runningService;

@PostMapping("/route")
@Operation(summary = "러닝 좌표 저장", description = "크루의 러닝 좌표를 저장하는 API 입니다.")
public ResponseEntity<CommonResponse<Void>> uploadRoute(
@RequestBody List<Coordinate> coordinates,
@MemberCrew Crew crew) {

runningService.processRunningRoute(crew.getId(), coordinates);
return ResponseEntity.ok(new CommonResponse<>("크루 러닝 좌표 저장 완료"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package run.backend.domain.running.dto.request;

import java.time.LocalDateTime;

public record Coordinate(
double latitude,
double longitude,
LocalDateTime timestamp
) {
}
33 changes: 33 additions & 0 deletions src/main/java/run/backend/domain/running/entity/Pixel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package run.backend.domain.running.entity;

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Pixel {

@EmbeddedId
private PixelId id;

private Long crewId;

private LocalDateTime updatedAt;

public Pixel(PixelId id, Long crewId, LocalDateTime updatedAt) {
this.id = id;
this.crewId = crewId;
this.updatedAt = updatedAt;
}

public void updateCrew(Long crewId, LocalDateTime updatedAt) {
this.crewId = crewId;
this.updatedAt = updatedAt;
}
}
33 changes: 33 additions & 0 deletions src/main/java/run/backend/domain/running/entity/PixelId.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package run.backend.domain.running.entity;

import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class PixelId implements Serializable, Comparable<PixelId> {

private int x;
private int y;

public PixelId(int x, int y) {
this.x = x;
this.y = y;
}

@Override
public int compareTo(PixelId other) {
int xCompare = Integer.compare(this.x, other.x);
if (xCompare != 0) {
return xCompare;
}
return Integer.compare(this.y, other.y);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package run.backend.domain.running.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;
import run.backend.global.exception.ErrorCode;

@Getter
@AllArgsConstructor
public enum RunningErrorCode implements ErrorCode {

INVALID_COORDINATE(8001, "유효하지 않은 좌표 범위입니다.");

private final int errorCode;
private final String errorMessage;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package run.backend.domain.running.exception;

import run.backend.global.exception.CustomException;

public class RunningException extends CustomException {

public RunningException(final RunningErrorCode runningErrorCode) {
super(runningErrorCode);
}

public static class InvalidCoordinate extends CustomException {
public InvalidCoordinate() {
super(RunningErrorCode.INVALID_COORDINATE);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package run.backend.domain.running.repository;

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import run.backend.domain.running.entity.Pixel;
import run.backend.domain.running.entity.PixelId;

import java.util.Optional;

public interface PixelRepository extends JpaRepository<Pixel, PixelId> {

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Pixel p WHERE p.id = :id")
Optional<Pixel> findByIdWithLock(@Param("id") PixelId id);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,81 @@
package run.backend.domain.running.service;

import run.backend.domain.running.dto.response.CrewRunningSummaryResponse;
import run.backend.domain.running.dto.response.RunningRecordResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import run.backend.domain.running.dto.request.Coordinate;
import run.backend.domain.running.entity.Pixel;
import run.backend.domain.running.entity.PixelId;
import run.backend.domain.running.exception.RunningException;
import run.backend.domain.running.repository.PixelRepository;

public interface RunningService {
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

void startRunning(Long eventId);
@Service
@RequiredArgsConstructor
public class RunningService {

RunningRecordResponse stopRunning(Long eventId);
public static final double SEOUL_AREA_NORTH = 37.715133;
public static final double SEOUL_AREA_SOUTH = 37.413294;
public static final double SEOUL_AREA_WEST = 126.734086;
public static final double SEOUL_AREA_EAST = 127.269311;

void joinRunning(Long memberId, Long eventId);
public static final int GRID_WIDTH = 939;
public static final int GRID_HEIGHT = 671;

CrewRunningSummaryResponse getCrewRunningSummary(Long crewId);
private final PixelRepository pixelRepository;

@Transactional
public void processRunningRoute(Long crewId, List<Coordinate> coordinates) {
Map<PixelId, Coordinate> pixelToCoordMap = new LinkedHashMap<>();
for (Coordinate coord : coordinates) {
int[] pixelPos = toPixel(coord.latitude(), coord.longitude());
PixelId id = new PixelId(pixelPos[0], pixelPos[1]);

Coordinate existing = pixelToCoordMap.get(id);
if (existing == null || coord.timestamp().isAfter(existing.timestamp())) {
pixelToCoordMap.put(id, coord);
}
}

List<PixelId> sortedPixelIds = pixelToCoordMap.keySet().stream()
.sorted()
.toList();

List<Pixel> pixelsToSave = new ArrayList<>();
for (PixelId id : sortedPixelIds) {
Coordinate coord = pixelToCoordMap.get(id);

Pixel pixel = pixelRepository.findByIdWithLock(id)
.orElseGet(() -> new Pixel(id, crewId, coord.timestamp()));

if (pixel.getUpdatedAt() != null && pixel.getUpdatedAt().isAfter(coord.timestamp())) {
continue;
}

pixel.updateCrew(crewId, coord.timestamp());
pixelsToSave.add(pixel);
}

pixelRepository.saveAll(pixelsToSave);
}

public int[] toPixel(double latitude, double longitude) {

if (latitude < SEOUL_AREA_SOUTH || latitude > SEOUL_AREA_NORTH ||
longitude < SEOUL_AREA_WEST || longitude > SEOUL_AREA_EAST) {
throw new RunningException.InvalidCoordinate();
}

double latRatio = (latitude - SEOUL_AREA_SOUTH) / (SEOUL_AREA_NORTH - SEOUL_AREA_SOUTH);
double lonRatio = (longitude - SEOUL_AREA_WEST) / (SEOUL_AREA_EAST - SEOUL_AREA_WEST);

int x = (int) Math.round(lonRatio * GRID_WIDTH);
int y = (int) Math.round((1 - latRatio) * GRID_HEIGHT);

return new int[]{x, y};
}
}
22 changes: 22 additions & 0 deletions src/test/java/run/backend/config/TestSecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package run.backend.config;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@TestConfiguration
@EnableWebSecurity
public class TestSecurityConfig {

@Bean
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());

return http.build();
}
}
Loading