-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 기숙사 인증 사진 업로드 API #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
82d01f0
79349e0
f6e883e
05af875
aac60c1
d70d6c1
243ff4e
181f3cb
5ccea0f
98acd1a
263dc48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" } } | ||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||
| `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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Proposed correction- dorm_verified_until = ':dormVerifiedUntil', -- 1번에서 받은 값 그대로
+ dorm_verified_until = :dormVerifiedUntil, -- 1번에서 받은 값 그대로📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||
| `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 |
|---|---|---|
| @@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/javaRepository: 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 2Repository: Leets-Official/TDD-BE Length of output: 8885 Use a consistent business “now” for semester/expiry checks.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| return ResponseEntity.ok( | ||
| ApiResponse.success("계산되었습니다.", new DormSemesterEndResponse(semesterEnd))); | ||
| } | ||
| } | ||
| 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 인증 -> 그 해 8/31 23:59:59. | ||
| * 9/1~(다음 해) 2/28(29) 인증 -> 그 학기가 끝나는 해의 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); | ||
| } | ||
| } |
| 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 | ||
| ) { | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.