Skip to content

Commit a78abf3

Browse files
committed
feat: use presigned upload flow in recruit pages
1 parent a97fa0d commit a78abf3

2 files changed

Lines changed: 82 additions & 24 deletions

File tree

src/app/recruit/core/page.tsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ type PrefillPayload = {
5151
phone?: string
5252
}
5353

54-
type S3UploadResponse = {
55-
s3Key?: string
54+
type PresignedUploadResponse = {
55+
key?: string
56+
uploadUrl?: string
5657
}
5758

5859
const STEPS = ['기본정보', '내용작성', '일정안내', '약관동의'] as const
@@ -394,19 +395,31 @@ export default function RecruitCore() {
394395
if (uploadTargets.length === 0) return []
395396

396397
const uploads = uploadTargets.map(async (target) => {
397-
const formData = new FormData()
398-
formData.append('file', target.file)
398+
const presignedResponse = await apiClient.post('/resource/presigned-upload', {
399+
fileName: target.file.name,
400+
contentType: target.file.type || 'application/octet-stream',
401+
fileSize: target.file.size,
402+
s3key: 'recruitCore'
403+
})
404+
405+
const payload = unwrapApiResponse<PresignedUploadResponse>(presignedResponse.data)
406+
if (!payload?.key || !payload.uploadUrl) {
407+
throw new Error(`${target.name} 업로드 URL 발급 응답이 올바르지 않습니다.`)
408+
}
399409

400-
const response = await apiClient.post('/resource/image', formData, {
401-
params: { s3key: 'study' }
410+
const uploadResponse = await fetch(payload.uploadUrl, {
411+
method: 'PUT',
412+
headers: {
413+
'Content-Type': target.file.type || 'application/octet-stream'
414+
},
415+
body: target.file
402416
})
403417

404-
const payload = unwrapApiResponse<S3UploadResponse>(response.data)
405-
if (!payload?.s3Key) {
406-
throw new Error(`${target.name} 업로드 응답에 s3Key가 없습니다.`)
418+
if (!uploadResponse.ok) {
419+
throw new Error(`${target.name} S3 업로드에 실패했습니다.`)
407420
}
408421

409-
return payload.s3Key
422+
return payload.key
410423
})
411424

412425
return Promise.all(uploads)

src/app/recruit/member/page.tsx

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ type RecruitFormState = {
4444

4545
type DuplicateCheckStatus = 'idle' | 'checking' | 'available' | 'duplicate' | 'unverified' | 'error'
4646

47+
type PresignedUploadResponse = {
48+
key?: string
49+
uploadUrl?: string
50+
}
51+
4752
interface DuplicateCheckState {
4853
status: DuplicateCheckStatus
4954
message?: string
@@ -350,21 +355,17 @@ export default function Recruit() {
350355
}
351356
const payload = Object.fromEntries(buildRecruitMap())
352357
if (formData.enrolledClassification === '군휴학' && formData.proofFile) {
353-
const fd = new FormData()
354-
fd.append(
355-
'request',
356-
new Blob([JSON.stringify(payload)], { type: 'application/json' })
357-
)
358-
fd.append('file', formData.proofFile)
359-
await axios.post(`${process.env.NEXT_PUBLIC_BASE_API_URL}/recruit/member/apply`, fd, {
360-
headers: { 'Content-Type': 'multipart/form-data' }
361-
})
362-
} else {
363-
await axios.post(
364-
`${process.env.NEXT_PUBLIC_BASE_API_URL}/recruit/member/apply`,
365-
payload
366-
)
358+
const proofFileKey = await uploadProofFile(formData.proofFile)
359+
const step6 = payload[6] as Record<string, unknown>
360+
payload[6] = {
361+
...step6,
362+
proofFileUrl: proofFileKey
363+
}
367364
}
365+
await axios.post(
366+
`${process.env.NEXT_PUBLIC_BASE_API_URL}/recruit/member/apply`,
367+
payload
368+
)
368369
router.push('/recruit/member/completed?from=recruit')
369370
} catch (error: any) {
370371
setGlobalError(error.response?.data?.message || '지원서 제출 중 오류가 발생했습니다.')
@@ -373,6 +374,50 @@ export default function Recruit() {
373374
}
374375
}
375376

377+
const uploadProofFile = async (file: File) => {
378+
const presignedResponse = await axios.post(
379+
`${process.env.NEXT_PUBLIC_BASE_API_URL}/recruit/member/apply/proof-file/presigned-upload`,
380+
{
381+
fileName: file.name,
382+
contentType: file.type || 'application/octet-stream',
383+
fileSize: file.size
384+
}
385+
)
386+
387+
const payload = unwrapPresignedUploadResponse(presignedResponse.data)
388+
if (!payload?.key || !payload.uploadUrl) {
389+
throw new Error('증빙 파일 업로드 URL 발급 응답이 올바르지 않습니다.')
390+
}
391+
392+
const uploadResponse = await fetch(payload.uploadUrl, {
393+
method: 'PUT',
394+
headers: {
395+
'Content-Type': file.type || 'application/octet-stream'
396+
},
397+
body: file
398+
})
399+
400+
if (!uploadResponse.ok) {
401+
throw new Error('증빙 파일 S3 업로드에 실패했습니다.')
402+
}
403+
404+
return payload.key
405+
}
406+
407+
const unwrapPresignedUploadResponse = (raw: unknown): PresignedUploadResponse | null => {
408+
if (!raw || typeof raw !== 'object') return null
409+
410+
const record = raw as Record<string, unknown>
411+
if ('data' in record && record.data && typeof record.data === 'object') {
412+
return unwrapPresignedUploadResponse(record.data)
413+
}
414+
415+
return {
416+
key: typeof record.key === 'string' ? record.key : undefined,
417+
uploadUrl: typeof record.uploadUrl === 'string' ? record.uploadUrl : undefined
418+
}
419+
}
420+
376421
const studentStatus: GdgFieldStatus | undefined =
377422
formData.studentId.trim() === studentCheckState.verifiedValue
378423
? 'success'

0 commit comments

Comments
 (0)