Skip to content

Commit ccdc434

Browse files
authored
Merge pull request #265 from DevKor-github/main
Main to Deploy
2 parents 46347e6 + 7be5737 commit ccdc434

7 files changed

Lines changed: 169 additions & 18 deletions

File tree

ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
import devkor.ontime_back.response.ApiResponseForm;
55
import devkor.ontime_back.service.AlarmService;
66
import devkor.ontime_back.service.UserAuthService;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.Schema;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
712
import jakarta.servlet.http.HttpServletRequest;
813
import lombok.RequiredArgsConstructor;
914
import org.springframework.http.HttpStatus;
@@ -19,13 +24,39 @@ public class AlarmController {
1924
private final UserAuthService userAuthService;
2025
private final AlarmService alarmService;
2126

27+
@Operation(summary = "Get current user's native alarm settings")
28+
@ApiResponses(value = {
29+
@ApiResponse(responseCode = "200", description = "Alarm settings lookup succeeded", content = @Content(
30+
mediaType = "application/json",
31+
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"alarmsEnabled\": true,\n \"defaultAlarmOffsetMinutes\": 10,\n \"updatedAt\": \"2026-05-05T00:00:00Z\"\n }\n}")
32+
)),
33+
@ApiResponse(responseCode = "4XX", description = "Alarm settings lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
34+
})
2235
@GetMapping("/users/me/alarm-settings")
2336
public ResponseEntity<ApiResponseForm<AlarmSettingsResponseDto>> getAlarmSettings(HttpServletRequest request) {
2437
Long userId = userAuthService.getUserIdFromToken(request);
2538
return ResponseEntity.status(HttpStatus.OK)
2639
.body(ApiResponseForm.success(alarmService.getAlarmSettings(userId)));
2740
}
2841

42+
@Operation(
43+
summary = "Update current user's native alarm settings",
44+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
45+
description = "Partial alarm settings update. Omit fields that should not change.",
46+
required = true,
47+
content = @Content(schema = @Schema(
48+
type = "object",
49+
example = "{\"alarmsEnabled\": true, \"defaultAlarmOffsetMinutes\": 10}"
50+
))
51+
)
52+
)
53+
@ApiResponses(value = {
54+
@ApiResponse(responseCode = "200", description = "Alarm settings update succeeded", content = @Content(
55+
mediaType = "application/json",
56+
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"alarmsEnabled\": true,\n \"defaultAlarmOffsetMinutes\": 10,\n \"updatedAt\": \"2026-05-05T00:00:00Z\"\n }\n}")
57+
)),
58+
@ApiResponse(responseCode = "4XX", description = "Alarm settings update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
59+
})
2960
@PatchMapping("/users/me/alarm-settings")
3061
public ResponseEntity<ApiResponseForm<AlarmSettingsResponseDto>> patchAlarmSettings(
3162
HttpServletRequest request,
@@ -35,6 +66,24 @@ public ResponseEntity<ApiResponseForm<AlarmSettingsResponseDto>> patchAlarmSetti
3566
.body(ApiResponseForm.success(alarmService.patchAlarmSettings(userId, requestBody)));
3667
}
3768

69+
@Operation(
70+
summary = "Register current device for native alarm ownership",
71+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
72+
description = "Current device metadata used to bind the logged-in access token to native alarm status.",
73+
required = true,
74+
content = @Content(schema = @Schema(
75+
type = "object",
76+
example = "{\"deviceId\": \"ios-device-000001\", \"platform\": \"ios\", \"appVersion\": \"1.2.3\", \"osVersion\": \"iOS 18.0\", \"supportsNativeAlarm\": true, \"nativeAlarmProvider\": \"iosAlarmKit\", \"fallbackProvider\": \"localNotification\"}"
77+
))
78+
)
79+
)
80+
@ApiResponses(value = {
81+
@ApiResponse(responseCode = "200", description = "Current device registration succeeded", content = @Content(
82+
mediaType = "application/json",
83+
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"deviceId\": \"ios-device-000001\",\n \"active\": true,\n \"lastSeenAt\": \"2026-05-05T00:00:00Z\"\n }\n}")
84+
)),
85+
@ApiResponse(responseCode = "4XX", description = "Current device registration failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
86+
})
3887
@PutMapping("/users/me/devices/current")
3988
public ResponseEntity<ApiResponseForm<AlarmDeviceCurrentResponseDto>> registerCurrentDevice(
4089
HttpServletRequest request,
@@ -48,6 +97,23 @@ public ResponseEntity<ApiResponseForm<AlarmDeviceCurrentResponseDto>> registerCu
4897
userAuthService.getRefreshTokenFromRequest(request))));
4998
}
5099

100+
@Operation(
101+
summary = "Unregister current device from native alarm ownership",
102+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
103+
description = "Optional device ID. If omitted, the device bound to the access token is unregistered.",
104+
content = @Content(schema = @Schema(
105+
type = "object",
106+
example = "{\"deviceId\": \"ios-device-000001\"}"
107+
))
108+
)
109+
)
110+
@ApiResponses(value = {
111+
@ApiResponse(responseCode = "200", description = "Current device unregister succeeded", content = @Content(
112+
mediaType = "application/json",
113+
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"active\": false\n }\n}")
114+
)),
115+
@ApiResponse(responseCode = "4XX", description = "Current device unregister failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
116+
})
51117
@DeleteMapping("/users/me/devices/current")
52118
public ResponseEntity<ApiResponseForm<AlarmDeviceUnregisterResponseDto>> unregisterCurrentDevice(
53119
HttpServletRequest request,
@@ -60,6 +126,24 @@ public ResponseEntity<ApiResponseForm<AlarmDeviceUnregisterResponseDto>> unregis
60126
userAuthService.getAccessTokenFromRequest(request))));
61127
}
62128

129+
@Operation(
130+
summary = "Report current native alarm reconciliation status",
131+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
132+
description = "Native alarm reconciliation status for the current device and schedule window.",
133+
required = true,
134+
content = @Content(schema = @Schema(
135+
type = "object",
136+
example = "{\"deviceId\": \"ios-device-000001\", \"reconciledAt\": \"2026-05-05T09:00:00+09:00\", \"scheduleWindowStart\": \"2026-05-05T00:00:00\", \"scheduleWindowEnd\": \"2026-05-06T00:00:00\", \"alarmCoverageStart\": \"2026-05-05T00:00:00\", \"alarmCoverageEnd\": \"2026-05-06T00:00:00\", \"status\": \"armed\", \"permissionIssue\": null, \"nativeAlarmProvider\": \"iosAlarmKit\", \"fallbackProvider\": \"localNotification\", \"armedScheduleCount\": 1, \"armedScheduleIds\": [\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"], \"skippedScheduleCount\": 0, \"failures\": []}"
137+
))
138+
)
139+
)
140+
@ApiResponses(value = {
141+
@ApiResponse(responseCode = "200", description = "Alarm status report succeeded", content = @Content(
142+
mediaType = "application/json",
143+
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"received\": true\n }\n}")
144+
)),
145+
@ApiResponse(responseCode = "4XX", description = "Alarm status report failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
146+
})
63147
@PostMapping("/users/me/alarm-status")
64148
public ResponseEntity<ApiResponseForm<AlarmStatusReportResponseDto>> reportAlarmStatus(
65149
HttpServletRequest request,
@@ -72,6 +156,14 @@ public ResponseEntity<ApiResponseForm<AlarmStatusReportResponseDto>> reportAlarm
72156
userAuthService.getAccessTokenFromRequest(request))));
73157
}
74158

159+
@Operation(summary = "Get current native alarm reconciliation status")
160+
@ApiResponses(value = {
161+
@ApiResponse(responseCode = "200", description = "Current alarm status lookup succeeded", content = @Content(
162+
mediaType = "application/json",
163+
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"deviceId\": \"ios-device-000001\",\n \"active\": true,\n \"platform\": \"ios\",\n \"appVersion\": \"1.2.3\",\n \"osVersion\": \"iOS 18.0\",\n \"supportsNativeAlarm\": true,\n \"nativeAlarmProvider\": \"iosAlarmKit\",\n \"fallbackProvider\": \"localNotification\",\n \"lastSeenAt\": \"2026-05-05T00:00:00Z\",\n \"reconciledAt\": \"2026-05-05T00:00:00Z\",\n \"scheduleWindowStart\": \"2026-05-05T00:00:00\",\n \"scheduleWindowEnd\": \"2026-05-06T00:00:00\",\n \"alarmCoverageStart\": \"2026-05-05T00:00:00\",\n \"alarmCoverageEnd\": \"2026-05-06T00:00:00\",\n \"status\": \"armed\",\n \"permissionIssue\": null,\n \"armedScheduleCount\": 1,\n \"armedScheduleIds\": [\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"],\n \"skippedScheduleCount\": 0,\n \"failures\": [],\n \"updatedAt\": \"2026-05-05T00:00:00Z\"\n }\n}")
164+
)),
165+
@ApiResponse(responseCode = "4XX", description = "Current alarm status lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
166+
})
75167
@GetMapping("/users/me/alarm-status")
76168
public ResponseEntity<ApiResponseForm<AlarmStatusCurrentResponseDto>> getCurrentAlarmStatus(HttpServletRequest request) {
77169
Long userId = userAuthService.getUserIdFromToken(request);

ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class FirebaseTokenController {
3232
content = @Content(
3333
schema = @Schema(
3434
type = "object",
35-
example = "{\"firebaseToken\": \"token1234abcd(실제로는 firebase에서 받은 토큰을 기입해야 함)\"}"
35+
example = "{\"firebaseToken\": \"token1234abcd(실제로는 firebase에서 받은 토큰을 기입해야 함)\", \"deviceId\": \"ios-device-000001\"}"
3636
)
3737
)
3838
)

ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,36 @@ public ResponseEntity<ApiResponseForm<List<ScheduleDto>>> getPeriodSchedule(Http
7070
return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(schedules));
7171
}
7272

73+
@Operation(summary = "Native alarm schedule window lookup",
74+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
75+
description = "No JSON body is required. Send access token in the header and use startDate/endDate query parameters."
76+
)
77+
)
78+
@ApiResponses(value = {
79+
@ApiResponse(responseCode = "200", description = "Alarm schedule window lookup succeeded",
80+
content = @Content(
81+
mediaType = "application/json",
82+
schema = @Schema(
83+
example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": [\n {\n \"scheduleId\": \"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\n \"scheduleName\": \"Morning meeting\",\n \"place\": {\n \"placeId\": \"3fa85f64-5717-4562-b3fc-2c963f66afe6\",\n \"placeName\": \"Office\"\n },\n \"scheduleTime\": \"2026-05-05T09:30:00\",\n \"moveTime\": 20,\n \"scheduleSpareTime\": 10,\n \"doneStatus\": \"NOT_ENDED\",\n \"preparationStartTime\": \"2026-05-05T08:40:00\",\n \"defaultAlarmTime\": \"2026-05-05T08:30:00\",\n \"preparations\": [],\n \"alarmSettings\": null\n }\n ]\n}"
84+
)
85+
)),
86+
@ApiResponse(responseCode = "4XX", description = "Alarm schedule window lookup failed",
87+
content = @Content(
88+
mediaType = "application/json",
89+
schema = @Schema(example = "Failure message")
90+
)
91+
)
92+
})
7393
@GetMapping("/alarm-window")
7494
public ResponseEntity<ApiResponseForm<List<AlarmWindowScheduleDto>>> getAlarmWindowSchedules(
7595
HttpServletRequest request,
96+
@Parameter(description = "Alarm window start date-time. Supports ISO local date-time or ISO offset date-time.",
97+
required = true,
98+
example = "2026-05-05T00:00:00")
7699
@RequestParam String startDate,
100+
@Parameter(description = "Alarm window end date-time. Supports ISO local date-time or ISO offset date-time.",
101+
required = true,
102+
example = "2026-05-06T00:00:00")
77103
@RequestParam String endDate) {
78104
Long userId = userAuthService.getUserIdFromToken(request);
79105
List<AlarmWindowScheduleDto> schedules = scheduleService.getAlarmWindowSchedules(

ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,12 @@ public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpSe
107107
FilterChain filterChain) throws ServletException, IOException {
108108
log.info("checkAccessTokenAndAuthentication() 호출");
109109
jwtTokenProvider.extractAccessToken(request)
110-
.ifPresent(accessToken -> {
111-
jwtTokenProvider.extractEmail(accessToken)
112-
.ifPresent(email -> userRepository.findByEmail(email)
113-
.ifPresent(this::saveAuthentication)
114-
);
115-
116-
jwtTokenProvider.extractUserId(accessToken)
117-
.ifPresent(userId -> log.info("추출된 userId: {}", userId));
118-
});
110+
.ifPresent(accessToken -> jwtTokenProvider.extractUserId(accessToken)
111+
.ifPresent(userId -> {
112+
log.info("추출된 userId: {}", userId);
113+
userRepository.findById(userId)
114+
.ifPresent(this::saveAuthentication);
115+
}));
119116

120117
filterChain.doFilter(request, response);
121118
}
@@ -192,4 +189,4 @@ private void handleInvalidTokenException(HttpServletResponse response, InvalidTo
192189
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
193190
}
194191
}
195-
}
192+
}

ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public String createRefreshToken() {
7171
return JWT.create()
7272
.withSubject(REFRESH_TOKEN_SUBJECT)
7373
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
74+
.withJWTId(UUID.randomUUID().toString())
7475
.sign(Algorithm.HMAC512(secretKey));
7576
}
7677

@@ -199,4 +200,4 @@ public String createExpiredAccessToken(String email) {
199200
.sign(Algorithm.HMAC512(secretKey));
200201
}
201202

202-
}
203+
}

ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@
1818
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
1919

2020
import java.io.IOException;
21+
import java.util.Map;
2122
import java.util.Optional;
23+
import java.util.concurrent.ConcurrentHashMap;
2224

2325
@Slf4j
2426
public class GoogleLoginFilter extends AbstractAuthenticationProcessingFilter {
27+
private static final Map<String, Object> LOGIN_LOCKS = new ConcurrentHashMap<>();
28+
2529
private final UserRepository userRepository;
2630
private final GoogleLoginService googleLoginService;
2731

@@ -42,13 +46,16 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
4246
GoogleIdToken.Payload googlePayload = googleLoginService.verifyIdentityToken(oAuthGoogleRequestDto.getIdToken());
4347
String googleUserId = googlePayload.getSubject();
4448

45-
Optional<User> existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId);
49+
Object loginLock = LOGIN_LOCKS.computeIfAbsent(googleUserId, key -> new Object());
50+
synchronized (loginLock) {
51+
Optional<User> existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId);
4652

47-
if (existingUser.isPresent()) {
48-
return googleLoginService.handleLogin(oAuthGoogleRequestDto, existingUser.get(), response);
49-
} else {
50-
OAuthGoogleUserDto oAuthGoogleUserDto = new OAuthGoogleUserDto(googleUserId, (String) googlePayload.get("name"), (String) googlePayload.get("picture"), googlePayload.getEmail());
51-
return googleLoginService.handleRegister(oAuthGoogleRequestDto, oAuthGoogleUserDto, response);
53+
if (existingUser.isPresent()) {
54+
return googleLoginService.handleLogin(oAuthGoogleRequestDto, existingUser.get(), response);
55+
} else {
56+
OAuthGoogleUserDto oAuthGoogleUserDto = new OAuthGoogleUserDto(googleUserId, (String) googlePayload.get("name"), (String) googlePayload.get("picture"), googlePayload.getEmail());
57+
return googleLoginService.handleRegister(oAuthGoogleRequestDto, oAuthGoogleUserDto, response);
58+
}
5259
}
5360

5461
} catch (Exception e) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package devkor.ontime_back.global.jwt;
2+
3+
import devkor.ontime_back.repository.UserRepository;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.test.util.ReflectionTestUtils;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
import static org.mockito.Mockito.mock;
10+
11+
class JwtTokenProviderTest {
12+
13+
@DisplayName("동일한 시각에 발급한 리프레시 토큰도 서로 다르다")
14+
@Test
15+
void createRefreshTokenGeneratesUniqueTokens() {
16+
// given
17+
JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(mock(UserRepository.class));
18+
ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", "test-secret-key-that-is-long-enough");
19+
ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpirationPeriod", 3600000L);
20+
21+
// when
22+
String refreshToken1 = jwtTokenProvider.createRefreshToken();
23+
String refreshToken2 = jwtTokenProvider.createRefreshToken();
24+
25+
// then
26+
assertThat(refreshToken1).isNotEqualTo(refreshToken2);
27+
}
28+
}

0 commit comments

Comments
 (0)