Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
84 changes: 84 additions & 0 deletions docs/operations/dormitory-verification-admin-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 기숙사 인증 승인/반려 운영 가이드 (MVP: 관리자 DB 직접 UPDATE)

FR-AUTH-02 확정 사항: 기숙사 인증 승인(PENDING → APPROVED/REJECTED)은 MVP 범위에서
API를 만들지 않고, DB 접근 권한이 있는 관리자가 `dormitory` 테이블을 직접 `UPDATE`해서
처리한다(별도 관리자 인증/권한 체계가 아직 없어서다). 이 문서는 그 수동 작업을 실수 없이
하기 위한 SQL 템플릿과 절차를 정리한다.

> **주의**: 아래 `:userId`, `:dormVerifiedUntil` 등은 MyBatis/JDBC 같은 바인딩 도구의
> named parameter가 아니다 - MySQL Workbench/DBeaver 같은 일반 SQL 클라이언트에 사람이
> 직접 값을 채워 넣는 자리표시자다. 숫자(`:userId`)는 따옴표 없이, 문자열/날짜(`:dormVerifiedUntil`)는
> 값 자체를 작은따옴표로 감싸서 치환한다(플레이스홀더를 감싸고 있는 따옴표는 그대로 두고
> 안의 텍스트만 바꾸는 것과 동일). 예시는 각 섹션 하단 참고.

## 사전 확인

1. 승인/반려 대상 유저의 `dormitory` row가 `dorm_status='PENDING'`인지 먼저 확인한다.
```sql
SELECT id, user_id, dormitory, dorm_status, dorm_verification_image_key
FROM dormitory
WHERE user_id = :userId;
```
2. `dorm_verification_image_key` 값으로 S3(비공개 버킷)에서 실제 인증 사진을 확인하고 승인/반려를 판단한다.

## 승인 처리

1. `dorm_verified_until`에 넣을 값을 아래 API로 구한다(인증 불필요, 데이터 조회/변경 없는 순수 계산기).
```
GET /api/v1/internal/dorm-semester-end?date=2026-07-25
→ { "success": true, "data": { "dorm_verified_until": "2026-08-31T23:59:59" } }
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`date`를 생략하면 오늘 날짜 기준으로 계산한다. 승인 처리 "오늘" 날짜 기준으로 계산하면 된다
(인증 사진이 언제 제출됐는지가 아니라 관리자가 승인하는 시점 기준).
2. 아래 SQL의 `:userId`, `:dormVerifiedUntil`을 채워 실행한다.
```sql
UPDATE dormitory
SET dorm_status = 'APPROVED',
dorm_verified_at = NOW(),
dorm_verified_until = ':dormVerifiedUntil', -- 1번에서 받은 값 그대로
reject_reason = NULL
WHERE user_id = :userId
AND dorm_status = 'PENDING';
Comment on lines +35 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove the quotes around the named SQL parameter.

':dormVerifiedUntil' is interpreted as a literal string by named-parameter SQL clients, not as a bind parameter. Use dorm_verified_until = :dormVerifiedUntil; if literal substitution is intended, document replacing it with a properly quoted timestamp value.

Proposed correction
-       dorm_verified_until = ':dormVerifiedUntil',  -- 1번에서 받은 값 그대로
+       dorm_verified_until = :dormVerifiedUntil,    -- 1번에서 받은 값 그대로
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
UPDATE dormitory
SET dorm_status = 'APPROVED',
dorm_verified_at = NOW(),
dorm_verified_until = ':dormVerifiedUntil', -- 1번에서 받은 값 그대로
reject_reason = NULL
WHERE user_id = :userId
AND dorm_status = 'PENDING';
UPDATE dormitory
SET dorm_status = 'APPROVED',
dorm_verified_at = NOW(),
dorm_verified_until = :dormVerifiedUntil, -- 1번에서 받은 값 그대로
reject_reason = NULL
WHERE user_id = :userId
AND dorm_status = 'PENDING';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/operations/dormitory-verification-admin-guide.md` around lines 29 - 35,
Update the dormitory approval SQL so the named parameter in dorm_verified_until
is unquoted, using :dormVerifiedUntil directly as the bind value; preserve the
existing update behavior and other assignments.

```
`WHERE`에 `dorm_status = 'PENDING'`을 같이 걸어서, 이미 처리된 건을 실수로 다시 승인하는 걸 막는다.
영향받은 row 수가 0이면 이미 PENDING이 아니라는 뜻이니 1번부터 다시 확인한다.

실제 값을 채운 예시(userId=42, 1번 응답의 dorm_verified_until="2026-08-31T23:59:59"):
```sql
UPDATE dormitory
SET dorm_status = 'APPROVED',
dorm_verified_at = NOW(),
dorm_verified_until = '2026-08-31 23:59:59',
reject_reason = NULL
WHERE user_id = 42
AND dorm_status = 'PENDING';
```

## 반려 처리

```sql
UPDATE dormitory
SET dorm_status = 'REJECTED',
reject_reason = ':rejectReason' -- 실제 사유 문자열로 교체(따옴표는 유지)
WHERE user_id = :userId
AND dorm_status = 'PENDING';
```

실제 값을 채운 예시(userId=42):
```sql
UPDATE dormitory
SET dorm_status = 'REJECTED',
reject_reason = '사진이 흐릿해서 동/호수를 확인할 수 없습니다'
WHERE user_id = 42
AND dorm_status = 'PENDING';
```

반려 건은 재제출을 유도하기 위해 인증 사진을 즉시 삭제하지 않는다(별도 배치나 수동 삭제 불필요).

## 참고

- `dorm_verified_at`은 반려 시 건드리지 않는다(승인된 적 있으면 그 기록을 유지, 없으면 계속 NULL).
- 학기 만료(`dorm_verified_until` 경과) 처리는 수동 작업이 아니라 매일 00:00 배치
(`DormVerificationExpiryScheduler`)가 자동으로 `EXPIRED` 전환 + S3 이미지 삭제까지 처리한다.
- 이 수동 UPDATE 방식은 승인/반려 물량이 늘어나거나 관리자 권한 체계(role)가 생기면
정식 관리자 승인 API로 교체를 검토한다.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ public class SecurityConfig {
"/swagger-ui/**",
"/v3/api-docs/**",
"/api/v1/delivery-parties/**",
"/ws/**"
"/ws/**",
// 데이터를 조회/변경하지 않는 순수 계산기(운영자용 보조 도구)라 인증 없이 연다.
"/api/v1/internal/**"
};

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.leets.tdd.user.controller;

import com.leets.tdd.global.common.ApiResponse;
import com.leets.tdd.user.domain.DormSemesterCalculator;
import com.leets.tdd.user.dto.DormSemesterEndResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.time.LocalDateTime;

/**
* 운영/관리자 보조용 내부 도구. 기숙사 인증(FR-AUTH-02) 승인은 MVP 범위상 API 없이 관리자가
* DB를 직접 UPDATE하는 방식으로 확정되어 있다({@code docs/operations/dormitory-verification-admin-guide.md}
* 참고). 그 수동 작업 중 dorm_verified_until 값을 손으로 계산하다 실수하는 걸 막기 위한 계산기다.
* 데이터를 조회/변경하지 않는 순수 계산이라 인증 없이 열어둔다(SecurityConfig의 PUBLIC_ENDPOINTS 참고).
*/
@Tag(name = "Internal", description = "운영/관리자 보조용 내부 도구 API(인증 불필요, 데이터 조회·변경 없음)")
@RestController
@RequestMapping("/api/v1/internal")
public class InternalToolController {

@Operation(
summary = "기숙사 인증 학기 만료일 계산",
description = "date(YYYY-MM-DD, 인증 시각 기준)를 넣으면 그 인증이 속한 학기가 끝나는 시각을 "
+ "돌려준다. date를 생략하면 오늘 날짜 기준으로 계산한다. 관리자가 dormitory 테이블을 "
+ "직접 UPDATE할 때 dorm_verified_until 값을 참고하는 용도다."
)
@GetMapping("/dorm-semester-end")
public ResponseEntity<ApiResponse<DormSemesterEndResponse>> calcDormSemesterEnd(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
) {
LocalDateTime verifiedAt = (date != null ? date : LocalDate.now()).atStartOfDay();
LocalDateTime semesterEnd = DormSemesterCalculator.calcSemesterEnd(verifiedAt);
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n 'spring\.jpa.*timezone|user\.timezone|TimeZone|ZoneId|TZ|`@Scheduled`' \
  src/main/resources/application.yaml src/main/java

Repository: Leets-Official/TDD-BE

Length of output: 392


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant files =="
git ls-files 'src/main/java/com/leets/tdd/user/controller/InternalToolController.java' \
  'src/main/java/com/leets/tdd/user/scheduler/DormVerificationExpiryScheduler.java' \
  'src/main/java/com/leets/tdd/user/**' \
  'src/main/java/com/leets/tdd/global/**' \
  'src/main/resources/**' \
  'build.gradle' 'build.gradle.kts' 'pom.xml'

echo
echo "== application configs =="
for f in $(git ls-files 'src/main/resources/**'); do
  echo "--- $f"
  sed -n '1,220p' "$f"
done

echo
echo "== controller excerpt =="
sed -n '1,90p' src/main/java/com/leets/tdd/user/controller/InternalToolController.java | cat -n

echo
echo "== scheduler excerpts =="
sed -n '1,90p' src/main/java/com/leets/tdd/user/scheduler/DormVerificationExpiryScheduler.java | cat -n
sed -n '1,90p' src/main/java/com/leets/tdd/global/jwt/RefreshTokenCleanupScheduler.java | cat -n

echo
echo "== dorm semester calculator excerpt =="
rg -n "class DormSemesterCalculator|calcSemesterEnd|verifiedAt" src/main/java
sed -n '1,160p' src/main/java/com/leets/tdd/user/calculator/DormSemesterCalculator.java | cat -n

echo
echo "== repository query =="
rg -n "findByDormStatusAndDormVerifiedUntilBefore|verifyUntil|DormStatus" src/main/java/com/leets/tdd/user src/main/java/com/leets/tdd/global -g '*.java'

Repository: Leets-Official/TDD-BE

Length of output: 28830


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== DormSemesterCalculator =="
cat -n src/main/java/com/leets/tdd/user/domain/DormSemesterCalculator.java

echo
echo "== scheduler configuration search =="
rg -n "EnableScheduling|spring\.task\s|\btask\s" src/main/java src/test/java build.gradle pom.xml || true

echo
echo "== repository query implementation =="
sed -n '1,180p' src/main/java/com/leets/tdd/user/repository/DormitoryRepository.java | cat -n

echo
echo "== Dormitory entity date fields =="
rg -n "dormVerifiedUntil|verified_at|dorm_verified_until|LocalDateTime|LocalDate" src/main/java/com/leets/tdd/user/domain/Dormitory.java src/main/java/com/leets/tdd/user/repository/DormitoryRepository.java src/main/java/com/leets/tdd/user/scheduler/DormVerificationExpiryScheduler.java -C 2

Repository: Leets-Official/TDD-BE

Length of output: 8885


Use a consistent business “now” for semester/expiry checks.

InternalToolController defaults omitted dates with LocalDate.now() (JVM default timezone) before calculating semester end, and DormVerificationExpiryScheduler compares dormVerifiedUntil with LocalDateTime.now(). Since the app sets jackson.time-zone: Asia/Seoul and the cron is wall-clock midnight, use an explicit Asia/Seoul clock/@Scheduled(zone = "Asia/Seoul") so semester calculation and daily expiry don’t drift with the JVM default timezone.

📍 Affects 2 files
  • src/main/java/com/leets/tdd/user/controller/InternalToolController.java#L39-L40 (this comment)
  • src/main/java/com/leets/tdd/user/scheduler/DormVerificationExpiryScheduler.java#L32-L36
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/leets/tdd/user/controller/InternalToolController.java`
around lines 39 - 40, The business-time handling is inconsistent because
semester calculation and expiry checks use the JVM default timezone. In
InternalToolController, replace the omitted-date fallback with an explicit
Asia/Seoul clock/time source; in DormVerificationExpiryScheduler, use the same
Asia/Seoul time source for dormVerifiedUntil comparisons and configure the
scheduled job with zone = "Asia/Seoul" so its midnight execution uses Seoul
wall-clock time.

return ResponseEntity.ok(
ApiResponse.success("계산되었습니다.", new DormSemesterEndResponse(semesterEnd)));
}
}
87 changes: 84 additions & 3 deletions src/main/java/com/leets/tdd/user/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

import com.leets.tdd.global.jwt.UserPrincipal;
import com.leets.tdd.global.common.ApiResponse;
import com.leets.tdd.user.dto.DormVerificationConfirmRequest;
import com.leets.tdd.user.dto.DormVerificationPresignRequest;
import com.leets.tdd.user.dto.DormVerificationPresignResponse;
import com.leets.tdd.user.dto.DormVerificationUploadResponse;
import com.leets.tdd.user.dto.MyPageResponse;
import com.leets.tdd.user.dto.ProfileImageConfirmRequest;
import com.leets.tdd.user.dto.ProfileImagePresignRequest;
import com.leets.tdd.user.dto.ProfileImagePresignResponse;
import com.leets.tdd.user.dto.ProfileImageUploadResponse;
import com.leets.tdd.user.dto.ProfileRegistrationRequest;
import com.leets.tdd.user.dto.ProfileRegistrationResponse;
import com.leets.tdd.user.dto.ChangePasswordRequest;
Expand Down Expand Up @@ -65,9 +73,10 @@ public ResponseEntity<ApiResponse<ProfileRegistrationResponse>> completeSignup(

@Operation(
summary = "프로필 수정",
description = "닉네임/기숙사 동/프로필 사진을 수정한다. 닉네임과 기숙사 동은 필수이고, "
+ "profileImageUrl을 null로 보내면 프로필 사진을 해제한다. "
+ "Authorization 헤더에 access token(Bearer)이 필요하다."
description = "닉네임/기숙사 동을 수정한다. 닉네임과 기숙사 동은 필수이고, "
+ "profileImageUrl은 빈 값(삭제)만 허용한다 - 프로필 사진을 새로 설정하는 건 "
+ "이 API가 아니라 /me/profile-image/presign, /me/profile-image/confirm(업로드 "
+ "전용 API)으로만 가능하다. Authorization 헤더에 access token(Bearer)이 필요하다."
)
@SecurityRequirement(name = "bearerAuth")
@PatchMapping("/me/profile")
Expand All @@ -79,6 +88,78 @@ public ResponseEntity<ApiResponse<ProfileUpdateResponse>> updateProfile(
return ResponseEntity.ok(ApiResponse.success("계정 수정에 성공하였습니다.", response));
}

@Operation(
summary = "프로필 사진 업로드 1단계(업로드 URL 발급)",
description = "프로필 사진을 올릴 Presigned PUT URL과 key를 발급한다(공개 버킷). 응답으로 받은 "
+ "uploadUrl로 브라우저가 S3에 직접 PUT한 뒤, 그 key로 확정(confirm) API를 호출해야 "
+ "프로필 사진에 반영된다. 허용 형식은 JPEG/PNG/WEBP다. "
+ "Authorization 헤더에 access token(Bearer)이 필요하다."
)
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/me/profile-image/presign")
public ResponseEntity<ApiResponse<ProfileImagePresignResponse>> presignProfileImageUpload(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody ProfileImagePresignRequest request
) {
ProfileImagePresignResponse response =
userService.presignProfileImageUpload(userPrincipal.userId(), request);
return ResponseEntity.ok(ApiResponse.success("업로드 URL이 발급되었습니다.", response));
}

@Operation(
summary = "프로필 사진 업로드 2단계(업로드 확정)",
description = "브라우저가 S3에 직접 업로드를 마친 뒤 호출한다. 서버가 실제로 올라간 객체의 "
+ "용량/형식을 확인(HeadObject)하고, 기준을 벗어나면 객체를 지우고 실패 처리한다. "
+ "통과하면 그때 프로필 사진에 반영되고, 응답에는 만료 없는 완성된 공개 URL이 담긴다. "
+ "Authorization 헤더에 access token(Bearer)이 필요하다."
)
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/me/profile-image/confirm")
public ResponseEntity<ApiResponse<ProfileImageUploadResponse>> confirmProfileImageUpload(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody ProfileImageConfirmRequest request
) {
ProfileImageUploadResponse response =
userService.confirmProfileImageUpload(userPrincipal.userId(), request);
return ResponseEntity.ok(ApiResponse.success("프로필 사진이 변경되었습니다.", response));
}

@Operation(
summary = "기숙사 인증하기 1단계(업로드 URL 발급)",
description = "인증 사진을 올릴 Presigned PUT URL과 key를 발급한다. 응답으로 받은 uploadUrl로 "
+ "브라우저가 S3에 직접 PUT한 뒤, 그 key로 확정(confirm) API를 호출해야 인증 신청이 "
+ "완료된다. 허용 형식은 JPEG/PNG/WEBP이고, 이미 심사 중이거나 승인된 상태면 발급 자체가 "
+ "거부된다. Authorization 헤더에 access token(Bearer)이 필요하다."
)
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/me/dormitory-verification/presign")
public ResponseEntity<ApiResponse<DormVerificationPresignResponse>> presignDormVerificationUpload(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody DormVerificationPresignRequest request
) {
DormVerificationPresignResponse response =
userService.presignDormVerificationUpload(userPrincipal.userId(), request);
return ResponseEntity.ok(ApiResponse.success("업로드 URL이 발급되었습니다.", response));
}

@Operation(
summary = "기숙사 인증하기 2단계(업로드 확정)",
description = "브라우저가 S3에 직접 업로드를 마친 뒤 호출한다. 서버가 실제로 올라간 객체의 "
+ "용량/형식을 확인(HeadObject)하고, 기준을 벗어나면 객체를 지우고 실패 처리한다. "
+ "통과하면 그때 심사 대기(PENDING) 상태로 반영된다. "
+ "Authorization 헤더에 access token(Bearer)이 필요하다."
)
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/me/dormitory-verification/confirm")
public ResponseEntity<ApiResponse<DormVerificationUploadResponse>> confirmDormVerificationUpload(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody DormVerificationConfirmRequest request
) {
DormVerificationUploadResponse response =
userService.confirmDormVerificationUpload(userPrincipal.userId(), request);
return ResponseEntity.ok(ApiResponse.success("기숙사 인증 신청이 완료되었습니다.", response));
}

@Operation(
summary = "알림 설정 변경",
description = "전체 알림 on/off 통합 토글 하나만 바꾼다(MVP 범위, 카테고리별 세분화 없음). "
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.leets.tdd.user.domain;

import java.time.LocalDate;
import java.time.LocalDateTime;

/**
* 기숙사 인증은 학기 단위로 유지된다(FR-AUTH-02). 인증 시점이 속한 학기의 마지막 순간을
* 고정 날짜로 계산한다 - 승인일로부터 며칠 뒤가 아니라, 그 학기가 끝나는 날짜에 맞춰 만료된다.
* <p>
* 3/1~8/31 인증 -&gt; 그 해 8/31 23:59:59.
* 9/1~(다음 해) 2/28(29) 인증 -&gt; 그 학기가 끝나는 해의 2월 마지막날 23:59:59
* (9~12월에 인증했으면 다음 해 2월, 1~2월에 인증했으면 그 해 2월 - 둘 다 같은 학기에 속하기 때문).
*/
public final class DormSemesterCalculator {

private DormSemesterCalculator() {
}

public static LocalDateTime calcSemesterEnd(LocalDateTime verifiedAt) {
int month = verifiedAt.getMonthValue();
int year = verifiedAt.getYear();

if (month >= 3 && month <= 8) {
return LocalDateTime.of(year, 8, 31, 23, 59, 59);
}

// 9~12월에 인증했으면 학기가 다음 해 2월에 끝나고, 1~2월에 인증했으면 이미 그 학기
// 안에 있는 것이므로 같은 해 2월에 끝난다.
int febYear = (month >= 9) ? year + 1 : year;
int lastDayOfFeb = LocalDate.of(febYear, 2, 1).lengthOfMonth();
return LocalDateTime.of(febYear, 2, lastDayOfFeb, 23, 59, 59);
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/leets/tdd/user/domain/Dormitory.java
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,10 @@ public void resubmit(String dormitory, String dormVerificationImageKey) {
public void changeDormitory(String dormitory) {
this.dormitory = dormitory;
}

// 인증 사진 업로드(재)신청 가능 여부. 이미 심사 중(PENDING)이거나 이미 승인(APPROVED)된
// 상태에서 다시 신청하는 것은 막는다 - REJECTED/EXPIRED/NOT_SUBMITTED는 재신청 가능하다.
public boolean isVerificationInProgress() {
return dormStatus == DormStatus.PENDING || dormStatus == DormStatus.APPROVED;
}
}
7 changes: 7 additions & 0 deletions src/main/java/com/leets/tdd/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ public void updateProfile(String nickname, String profileImageUrl) {
this.profileImageUrl = profileImageUrl;
}

// 프로필 이미지 업로드 확정(confirm) 시 이미지만 갱신한다. 컬럼/필드 이름은 profileImageUrl이지만
// 실제로 저장하는 값은 S3 객체 key다(공개 버킷 + base URL로 조립해서 응답한다 - 컬럼명 정리는
// MVP 이후 별도 PR로 예정돼 있어 여기서는 안 건드린다). 닉네임은 이 메서드에서 건드리지 않는다.
public void updateProfileImageKey(String profileImageKey) {
this.profileImageUrl = profileImageKey;
}

// 로그인 시도 제한 확인용(5분 내 3회 실패 시 15분 차단).
// lastFailedLoginAt 하나로 "실패 3회 미만일 때의 5분 리셋 판단"과
// "실패 3회 이상일 때의 15분 차단 판단"을 둘 다 계산한다(별도 만료시각 컬럼 없이).
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/leets/tdd/user/dto/DormSemesterEndResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.leets.tdd.user.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.LocalDateTime;

/**
* 기숙사 인증 학기 만료일 계산 결과. 관리자가 DB로 직접 승인 처리할 때
* dorm_verified_until 컬럼에 넣을 값을 손으로 계산하지 않도록 돕는 용도다.
*/
public record DormSemesterEndResponse(
@JsonProperty("dorm_verified_until") LocalDateTime dormVerifiedUntil
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.leets.tdd.user.dto;

import jakarta.validation.constraints.NotBlank;

/**
* 기숙사 인증 이미지 업로드 3단계(확정) 요청. presign에서 받은 key를 그대로 보낸다.
* 서버는 이 key를 그대로 신뢰하지 않고, 호출자 본인 몫인지 + 실제로 S3에 업로드됐는지를
* 다시 검증한 뒤에만 DB에 반영한다.
*/
public record DormVerificationConfirmRequest(

@NotBlank(message = "key를 입력해주세요.")
String key
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.leets.tdd.user.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

/**
* 기숙사 인증 이미지 업로드 1단계(발급) 요청. 브라우저가 S3에 직접 올릴 파일의 MIME 타입을
* 미리 알려주면, 서버가 이 타입으로 서명된 Presigned PUT URL을 만들어 돌려준다.
*/
public record DormVerificationPresignRequest(

@NotBlank(message = "이미지 형식(contentType)을 입력해주세요.")
@Pattern(regexp = "^(image/jpeg|image/png|image/webp)$",
message = "JPEG, PNG, WEBP 형식만 업로드할 수 있습니다.")
String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.leets.tdd.user.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
* 기숙사 인증 이미지 업로드 1단계(발급) 응답. key는 3단계(확정) 요청에 그대로 다시 실어 보내야 한다.
* uploadUrl로는 반드시 presign 요청에 보낸 것과 같은 Content-Type 헤더를 실어 PUT해야 한다
* (서명에 포함돼 있어 다르면 S3가 서명 불일치로 거부한다).
*/
public record DormVerificationPresignResponse(
@JsonProperty("key") String key,
@JsonProperty("upload_url") String uploadUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.leets.tdd.user.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.LocalDateTime;

/**
* 기숙사 인증 신청(사진 업로드) 응답. 다른 user 도메인 DTO는 camelCase 그대로 내려주지만,
* 이 API는 명세가 snake_case 필드명을 명시하고 있어 @JsonProperty로 고정한다.
* dormVerifiedImageUrl은 DB에 저장된 S3 key가 아니라, 응답 시점에 새로 발급한 짧은 만료
* 시간의 Presigned GET URL이다(ImageStorageService.generatePresignedGetUrl).
*/
public record DormVerificationUploadResponse(
@JsonProperty("dorm_status") String dormStatus,
@JsonProperty("dorm_verified_at") LocalDateTime dormVerifiedAt,
@JsonProperty("dorm_verified_until") LocalDateTime dormVerifiedUntil,
@JsonProperty("dorm_verified_image_url") String dormVerifiedImageUrl
) {
}
Loading
Loading