feat(frontend): 대용량 첨부 — 쓰기 · 읽기 · 설정 · 공개 내려받기 화면

- 쓰기: 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). 고르자마자 서버에 만들고
  16MiB 조각(Content-Digest)을 파일 3개까지 함께, 끊기면 2 · 5 · 15 · 30초로 다시 · 1분 넘으면 멈춤 + [다시 시도],
  새로고침 뒤 같은 파일을 다시 고르면 잇는다. 다 올릴 때까지 [보내기]는 aria-disabled + 까닭, 위험 판정은 빨간 줄 + 실시간 알림.
  대용량 파일이 있으면 첫 원문 인용 앞을 data-ara-quote-start · bodyQuoteStart 로 알린다(서버가 그 자리에 덩이를 넣는다).
- 읽기: 첨부 칸의 대용량 항목(서버의 largeFiles), 본문 덩이 접기, 보낸 사람의 받은 수 · 링크 끊기(끊은 날 — revokedAt).
- 설정 › 메일 › 대용량 첨부: 보관 사용량 · 보내지 않은 파일 · 보낸 링크(끊기).
- 공개 화면 /s/:token: 세션 복원 없이(meta.public) · credentials omit · no-referrer, 라우트는 대소문자를 가린다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:55 +09:00
parent 93154ad9a5
commit 3461c361f8
84 changed files with 10084 additions and 137 deletions
+6
View File
@@ -76,6 +76,12 @@ describe('에러 코드 사전', () => {
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
// 웹메일 8단계 (주소록)
'CONTACT_001', // 같은 이름의 그룹 — 새 그룹 · 이름 바꾸기 창이 알림 없이 칸 아래에 보인다
// 웹메일 11단계 (대용량 첨부) — 조각 올리기 · 보내기가 이 코드로 분기한다 (FILE_008 은 분기하지 않는다 — 받기는 <a href> 이동이다)
'FILE_003', // 올리기 상태가 맞지 않음 — 상태를 다시 묻고 잇는다(받았다는 조각은 한 번 더 보내 보고, 또 409 면 멈춘다)
'FILE_004', // 조각 손상 — 그 조각만 다시 보낸다
'FILE_005', // 보관 한도 — 그 파일 줄에 사전 문구 + [대용량 첨부 보기]
'FILE_006', // 서버 저장 공간 — 그 파일 줄에 사전 문구, 멈추고 [다시 시도]
'MAIL_016', // 대용량 파일을 쓸 수 없음 — 쓰기 화면이 파일마다 상태를 다시 묻는다
]
for (const code of usedByClient) {
@@ -34,6 +34,11 @@ export interface UploadItem {
* 올린 파일은 빼고 다시 고르면 되지만, 전달 원본은 다시 올릴 수 없어 빼는 것만 할 수 있다 (계약 2절).
*/
missing: boolean
/**
* 이 쓰기 화면에서 고른 파일 — 「링크로 보내기」(11단계)가 대용량 첨부로 옮길 때 다시 올린다.
* 전달 원본 · 저장본의 첨부는 null (처음 판에서 링크로 바꾸지 않는다)
*/
file: File | null
}
export interface AttachmentUploadOptions {
@@ -153,6 +158,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachment: null,
errorMessage: rejected ? getErrorMessage(rejected) : null,
missing: false,
file,
},
]
if (!rejected) {
@@ -187,6 +193,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachment: rejected ? null : attachment,
errorMessage: rejected ? getErrorMessage(rejected) : null,
missing: false,
file: null,
}
})
items.value = [...items.value, ...added]
@@ -0,0 +1,267 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { MailEditorContent, MailEditorReserve } from '@/components/mail/editor/mail-editor-handle'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import type { Clock } from '@/composables/useClock'
import { useLargeFileUploads } from '@/composables/useLargeFileUploads'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_QUOTA_WARNING_RATIO, LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { MAIL_MAX_ATTACHMENTS } from '@/shared/constants/mail'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { MessageLargeFile } from '@/shared/types/large-file'
import type { SendMessagePayload } from '@/shared/types/mail'
import { formatCount, formatFileSize, formatMegabytes, formatQuotaUsage } from '@/shared/utils/format'
import { formatKstMonthDayWeekday, kstRetentionEnd } from '@/shared/utils/kst-date'
import {
composeAttachmentHint,
largeFileBlockTextReserve,
routeAttachment,
sendBlockOf,
} from '@/shared/utils/large-file'
import { withoutQuoteStartMark } from '@/shared/utils/outgoing-html-rules'
import { filesByShareUrl, quotedLinkNotices, shareUrlsIn, type QuotedLinkNotice } from '@/shared/utils/share-links'
import { useLargeFilesStore } from '@/stores/large-files.store'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면의 대용량 첨부를 잇는 곳 (11단계 — 계약 4절 "대용량 첨부" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
// 쓰기 화면(ComposePage)에서 떼어 냈다 — 화면은 이것이 돌려주는 값 · 함수만 쓴다. 조각 올리기 자체는 useLargeFileUploads.
// - 고른 파일을 메일에 담거나 링크로 보낸다 — 20MB 넘는 파일 · 합계를 25MB 넘게 만드는 파일 · 메일에 담지 못하는 형식은 링크로
// (routeAttachment — 고른 차례대로. 합계 때문에 바꿨으면 칸 아래 한 줄). 이 화면에서 고른 일반 첨부는 「링크로 보내기」로 옮길 수 있다.
// - 첨부 칸의 글: 안내문 · 묶음 머리(메일 합계 · 링크 기간) · 보관 사용량 경고 · 합계 때문에 바꾼 안내 · 인용 속 원래 링크의 상태.
// - [보내기]를 막는 까닭(sendBlockOf) — 올리는 중 · 다 올리지 못한 파일 · 빼야 하는 파일 · 한 통의 대용량 파일 수 (일반 첨부의 올리는 중 · 찾지 못함 포함).
// - 보내는 · 저장하는 본문 필드 — 대용량 파일이 있으면 그 id 와 덩이 자리(서식 본문의 인용 경계 표시 · bodyQuoteStart), 없으면 표시를 지운다.
// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 그만큼 텍스트 한도를 남긴다(sendReserve).
// - 실시간 알림의 위험 판정은 그 파일을 "위험 — 보낼 수 없음"으로 바꾼다 (useLargeFileAlerts 가 스토어에 신호를 남긴다)
/** 쓰기 화면의 일반 첨부 — useAttachmentUploads 에서 이것만 쓴다 */
export interface ComposeNormalAttachments {
items: Ref<UploadItem[]>
/** 메일에 담은(올리는 중 포함) 파일의 합계 */
attachedBytes: Readonly<Ref<number>>
/** 서버가 찾지 못한 첨부(MAIL_004)가 남았다 */
hasMissing: Readonly<Ref<boolean>>
addFiles: (files: Iterable<File>) => File[]
remove: (key: number) => void
}
export interface ComposeLargeFilesOptions {
clock: Clock
normal: ComposeNormalAttachments
}
/** 본문 필드 — 발송 · 임시 저장 같다 */
export type ComposeBodyFields = Pick<SendMessagePayload, 'body' | 'bodyHtml' | 'largeFileIds' | 'bodyQuoteStart'>
export function useComposeLargeFiles(options: ComposeLargeFilesOptions) {
const { clock, normal } = options
const mailStore = useMailStore()
const largeFilesStore = useLargeFilesStore()
const { lookupShareLinks } = useLargeFiles()
const toast = useToast()
/**
* 대용량 첨부(링크) — 한도는 GET /file-quota (쓰기 화면을 새로 채울 때마다 받는다, 못 받으면 서버 기본값).
* 사용량은 받은 값에 이 화면에서 새로 만든 올리기를 더해 센다
*/
const uploads = useLargeFileUploads({
clock,
limits: () => ({
maxFileBytes: largeFilesStore.limits.maxFileBytes,
maxFilesPerMessage: largeFilesStore.limits.maxFilesPerMessage,
limitBytes: largeFilesStore.limits.limitBytes,
baseUsedBytes: largeFilesStore.limits.usedBytes,
}),
})
/** 합계 때문에 저절로 링크로 바꾼 파일 — 칸 아래 한 줄 (그 줄을 빼면 사라진다) */
const switchedByTotal = ref<{ key: number; name: string; size: number } | null>(null)
/** 인용 속 원래 대용량 첨부 링크의 상태 — 칸 아래 글자로만 (답장 · 전달) */
const quotedNotices = ref<QuotedLinkNotice[]>([])
/** 양식을 새로 채운 횟수 — 늦게 온 인용 링크 상태를 버린다 */
let formSeq = 0
/** 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)" */
const hint = computed(() =>
composeAttachmentHint(
{
maxBytes: mailStore.attachmentMaxBytes,
totalMaxBytes: mailStore.attachmentTotalMaxBytes,
maxCount: MAIL_MAX_ATTACHMENTS,
},
largeFilesStore.limits,
),
)
/** 메일 묶음 머리의 "합계 18.1 MB / 25 MB" */
const normalTotalText = computed(() =>
LARGE_FILE_TEXT.normalTotal(
formatFileSize(normal.attachedBytes.value),
formatFileSize(mailStore.attachmentTotalMaxBytes),
),
)
/** 링크 묶음 머리 — "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지" */
const linkGroupHint = computed(() => {
const days = largeFilesStore.limits.retentionDays
return LARGE_FILE_TEXT.linkGroupHint(formatCount(days), formatKstMonthDayWeekday(kstRetentionEnd(clock.now(), days)))
})
/** 보관 사용량이 80%를 넘으면 링크 묶음 머리 오른쪽에 "보관 8.4 / 10 GB" */
const quotaWarning = computed(() => {
const { limitBytes } = largeFilesStore.limits
const used = uploads.usedBytes.value
return limitBytes > 0 && used / limitBytes > LARGE_FILE_QUOTA_WARNING_RATIO
? LARGE_FILE_TEXT.quotaWarning(formatQuotaUsage(used, limitBytes))
: null
})
/** 합계 때문에 저절로 링크로 바꾼 안내 — 그 파일이 링크 묶음에 있는 동안 */
const switchNotice = computed(() => {
const switched = switchedByTotal.value
if (!switched || !uploads.items.value.some((item) => item.key === switched.key)) return null
return LARGE_FILE_TEXT.switchedByTotal(
switched.name,
formatFileSize(switched.size),
formatMegabytes(mailStore.attachmentTotalMaxBytes),
)
})
/**
* 첨부 때문에 보내기를 막는 까닭 — 올리는 중 · 다 올리지 못한 대용량 파일 · 빼야 하는 파일(서버가 찾지 못한 첨부 MAIL_004 ·
* 지운 · 기간이 지난 · 위험 판정 대용량 파일) · 한 통의 대용량 파일 수. 모르는 사이 첨부 없이 나가지 않게 (계약 4절 "보내기를 막는 까닭")
*/
const sendBlock = computed(() =>
sendBlockOf({
normalUploading: normal.items.value.filter((item) => item.status === 'uploading').length,
normalMissing: normal.hasMissing.value,
largeActive: uploads.activeCount.value,
largeIncomplete: uploads.hasIncomplete.value,
largeMissing: uploads.hasMissing.value,
largeInfected: uploads.hasInfected.value,
largeCount: uploads.carried.value.length,
maxLargeFiles: largeFilesStore.limits.maxFilesPerMessage,
}),
)
/**
* 본문 필드 — 대용량 파일이 있으면 그 id 와 덩이 자리(서식 본문의 인용 경계 표시 · 텍스트의 bodyQuoteStart)를 싣는다.
* 없으면 표시를 지우고 둘 다 보내지 않는다 — 지금까지와 같은 본문이다 (계약 4절 "발송 · 임시 저장과 함께")
*/
function bodyFields(content: MailEditorContent): ComposeBodyFields {
const ids = uploads.uploadIds.value
const withLarge = ids.length > 0
const html = content.html !== null && !withLarge ? withoutQuoteStartMark(content.html) : content.html
return {
body: content.text,
...(html ? { bodyHtml: html } : {}),
...(withLarge ? { largeFileIds: [...ids] } : {}),
...(withLarge && content.quoteStart !== null ? { bodyQuoteStart: content.quoteStart } : {}),
}
}
/** 보내기 직전에 남길 텍스트 몫 — 서버가 덩이를 넣은 뒤 다시 잰다 (대용량 파일이 없으면 남기지 않는다) */
function sendReserve(): MailEditorReserve | undefined {
const carried = uploads.carried.value
return carried.length > 0 ? { text: largeFileBlockTextReserve(carried) } : undefined
}
/**
* 고른 파일을 메일에 담거나 링크로 보낸다 — 고른 차례대로 (routeAttachment, 계약 4절 "링크로 바뀌는 때").
* 20MB 넘는 파일 · 합계를 25MB 넘게 만드는 파일 · 메일에 담지 못하는 형식은 링크로. 합계 때문에 바꿨으면 칸 아래에 한 줄.
* @returns 올리기 시작한 파일 (사전 검사에 걸린 것은 빼고)
*/
function attachFiles(files: readonly File[]): File[] {
const limits = { maxBytes: mailStore.attachmentMaxBytes, totalMaxBytes: mailStore.attachmentTotalMaxBytes }
const accepted: File[] = []
for (const file of files) {
const route = routeAttachment(file, limits, normal.attachedBytes.value)
if (route === 'normal') {
accepted.push(...normal.addFiles([file]))
continue
}
const [item] = uploads.addFiles([file])
if (!item || item.phase === 'rejected') continue
accepted.push(file)
if (route === 'large-total') switchedByTotal.value = { key: item.key, name: file.name, size: file.size }
}
return accepted
}
/** 「링크로 보내기」를 보일 일반 첨부 — 이 화면에서 고른 파일(올리는 중 · 올림)만. 전달 원본 · 저장본의 첨부는 처음 판에서 바꾸지 않는다 */
function isLinkable(item: UploadItem): boolean {
return item.origin === 'upload' && item.file !== null && item.status !== 'failed' && !item.missing
}
/**
* 「링크로 보내기」 — 링크로 올리기 전 검사(한 통의 대용량 파일 수 · 보관량)를 먼저 본다. 통과할 때만 메일에 담던 파일을 빼고
* 대용량 첨부로 다시 올린다. 걸리면 일반 첨부는 그대로 두고 까닭을 알린다(보관량이면 [대용량 첨부 보기])
*/
function sendAsLink(key: number) {
const item = normal.items.value.find((candidate) => candidate.key === key)
if (!item?.file || !isLinkable(item)) return
const { file } = item
const problem = uploads.precheck(file)
if (problem) {
toast.error(
problem.text,
problem.linksSettings ? { label: LARGE_FILE_TEXT.quotaLink, to: { name: SETTINGS_ROUTES.largeFiles } } : undefined,
)
return
}
normal.remove(key)
uploads.addFiles([file])
}
/**
* 인용 속 원래 대용량 첨부 링크의 상태를 묻는다 — 알림 없이, 실패하면 글자를 두지 않는다 (계약 4절 "인용 속 링크의 상태").
* 그사이 양식이 바뀌었으면 버린다
*/
async function lookupQuotedLinks(
html: string | null,
text: string | null,
files: readonly MessageLargeFile[],
): Promise<void> {
const urls = shareUrlsIn(html, text)
if (urls.length === 0) return
const mark = formSeq
try {
const result = await lookupShareLinks(urls, { silent: true })
if (mark !== formSeq) return
quotedNotices.value = quotedLinkNotices(result.links, filesByShareUrl(files))
} catch {
// 알리지 않는다 — 원래 링크는 인용 속 글로 그대로 간다
}
}
/** 양식을 새로 채웠다 — 올리던 것을 멈추고(서버의 올리기는 저장본이 이어 쓴다) 칸 아래 글을 지운다. 한도 · 사용량은 새로 받는다 */
function reset() {
uploads.reset()
switchedByTotal.value = null
quotedNotices.value = []
formSeq += 1
void largeFilesStore.loadQuota()
}
// 실시간 알림의 위험 판정 — 그 파일이 이 메일에 있으면 "위험 — 보낼 수 없음"으로 (useLargeFileAlerts 가 신호를 남긴다)
watch(
() => largeFilesStore.infectedSignal,
(signal) => {
if (signal) uploads.markInfected(signal.fileId)
},
)
return {
uploads,
quotedNotices,
hint,
normalTotalText,
linkGroupHint,
quotaWarning,
switchNotice,
sendBlock,
bodyFields,
sendReserve,
attachFiles,
isLinkable,
sendAsLink,
lookupQuotedLinks,
reset,
}
}
@@ -0,0 +1,26 @@
import { useToast } from './useToast'
import { LARGE_FILE_ALERT_TEXT, LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { LargeFileSignal } from '@/shared/utils/mail-events'
import { useLargeFilesStore } from '@/stores/large-files.store'
// 올린 대용량 파일의 위험 판정 — 실시간 알림 large-file 을 받은 탭마다 (계약 4절 "악성 검사" · 디자인 README "11단계 › 보낸 뒤 위험이 나오면")
// 빨간 알림 "올린 대용량 파일 ‘…’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기](10초 — 단추가 있는 알림, 마우스 · 초점이 있으면 멈춤).
// 그 파일이 쓰기 화면에 있으면 "위험 — 보낼 수 없음"으로 바뀌고(스토어의 신호), 설정 › 대용량 첨부의 목록도 바뀐다.
// 연결이 없던 동안의 판정은 목록 상태로만 안다 (계약 4절 — 열린 질문)
export function useLargeFileAlerts() {
const toast = useToast()
const largeFilesStore = useLargeFilesStore()
/** useMailRealtime 의 onLargeFile — 위험 판정만 알린다 (그 밖의 판정은 아직 오지 않는다) */
function notify(signal: LargeFileSignal): void {
if (signal.scanStatus !== 'infected') return
largeFilesStore.noteInfected(signal.fileId, signal.name)
toast.error(LARGE_FILE_ALERT_TEXT.infected(signal.name || LARGE_FILE_TEXT.unnamed), {
label: LARGE_FILE_ALERT_TEXT.action,
to: { name: SETTINGS_ROUTES.largeFiles },
})
}
return { notify }
}
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, nextTick, ref, type EffectScope } from 'vue'
import { LARGE_FILE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises } from '@/test-utils/mock-api'
import { useLargeFileScanRefresh } from './useLargeFileScanRefresh'
// 읽기 화면 — 보안 검사 중인 대용량 첨부가 있는 동안 상태를 다시 묻는다 (11단계 리뷰 L5)
// 쓰기 · 공개 화면과 같은 간격(15 · 30 · 60초) · 상한, 막힌 [다운로드]는 한 번 바로, 메일을 바꾸면 처음부터, 세션이 바뀌거나 닫으면 멈춘다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
describe('useLargeFileScanRefresh', () => {
let clock: ManualClock
let scope: EffectScope
const messageId = ref('m1')
const scanning = ref(true)
let refresh: ReturnType<typeof vi.fn<() => Promise<void>>>
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
clock = createManualClock(Date.UTC(2026, 9, 30, 5, 0, 0))
scope = effectScope()
messageId.value = 'm1'
scanning.value = true
refresh = vi.fn<() => Promise<void>>(() => Promise.resolve())
})
afterEach(() => {
scope.stop()
})
function start() {
const refresher = scope.run(() =>
useLargeFileScanRefresh({
messageId: () => messageId.value,
scanning: () => scanning.value,
refresh,
clock,
}),
)
if (!refresher) throw new Error('스코프를 만들지 못함')
return refresher
}
async function advance(ms: number) {
clock.advance(ms)
await flushPromises()
}
it('검사 중인 동안 15 · 30 · 60초 간격으로 다시 묻는다 — 끝나면 멈춘다', async () => {
start()
await advance(14_999)
expect(refresh).not.toHaveBeenCalled()
await advance(1)
expect(refresh).toHaveBeenCalledTimes(1)
await advance(30_000)
expect(refresh).toHaveBeenCalledTimes(2)
await advance(60_000)
expect(refresh).toHaveBeenCalledTimes(3)
scanning.value = false
await nextTick()
await advance(600_000)
expect(refresh).toHaveBeenCalledTimes(3)
expect(clock.pendingCount()).toBe(0)
})
it('검사 중인 파일이 없으면 묻지 않고, 묻는 수에는 상한이 있다', async () => {
scanning.value = false
start()
await advance(600_000)
expect(refresh).not.toHaveBeenCalled()
scanning.value = true
await nextTick()
for (let index = 0; index < LARGE_FILE_SCAN_POLL_LIMIT + 5; index += 1) await advance(60_000)
expect(refresh).toHaveBeenCalledTimes(LARGE_FILE_SCAN_POLL_LIMIT)
})
it('막힌 [다운로드]를 누르면 한 번 바로 묻는다 — 묻는 중이면 겹쳐 묻지 않는다', async () => {
let resolve: () => void = () => undefined
refresh.mockImplementation(
() =>
new Promise<void>((done) => {
resolve = done
}),
)
const refresher = start()
refresher.refreshNow()
refresher.refreshNow()
expect(refresh).toHaveBeenCalledTimes(1)
resolve()
await flushPromises()
// 바로 물은 뒤에도 간격은 이어서 센다
await advance(15_000)
expect(refresh).toHaveBeenCalledTimes(2)
})
it('메일을 바꾸면 처음 간격부터 다시 센다', async () => {
start()
await advance(15_000)
await advance(30_000)
expect(refresh).toHaveBeenCalledTimes(2)
messageId.value = 'm2'
await nextTick()
await advance(15_000)
expect(refresh).toHaveBeenCalledTimes(3)
})
it('세션이 바뀌거나(로그아웃 · 다른 계정) 화면을 닫으면 멈춘다', async () => {
const refresher = start()
useAuthStore().startSession({ email: 'junho.park@arasystem.it.kr', name: '박준호' })
await nextTick()
await advance(600_000)
refresher.refreshNow()
expect(refresh).not.toHaveBeenCalled()
scope.stop()
scope = effectScope()
useAuthStore().markAuthenticated(USER)
start()
scope.stop()
await advance(600_000)
expect(refresh).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from '@/composables/useClock'
import { LARGE_FILE_SCAN_POLL_DELAYS_MS, LARGE_FILE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
import { useAuthStore } from '@/stores/auth.store'
// 읽기 화면의 대용량 첨부 — 보안 검사 중인 파일이 있는 동안 상태를 다시 묻는다 (11단계 리뷰 L5)
// 검사가 끝나도 메일 상태(새 메일 확인 · 실시간 알림의 state)는 바뀌지 않아, 묻지 않으면 "검사 중"이 그대로 남는다.
// - 쓰기 · 공개 화면과 같은 간격(15 · 30 · 60초, 그 뒤 60초마다)으로 LARGE_FILE_SCAN_POLL_LIMIT 번까지.
// - 막힌 [다운로드](검사 중)를 누르면 한 번 바로 묻는다 — 기다리지 않고 끝났는지 보게.
// - 한 번에 하나. 메일을 바꾸거나 닫으면(스코프가 사라짐) · 세션이 바뀌면 멈춘다. 늦게 온 응답은 묻는 쪽(refresh)이 버린다
export interface LargeFileScanRefreshOptions {
/** 지금 보고 있는 메일 — 바뀌면 처음부터 다시 센다 */
messageId: () => string
/** 검사 중인 파일이 있다 */
scanning: () => boolean
/** 다시 묻는다 — 열린 메일의 대용량 첨부만 바꾼다 (실패는 알리지 않는다) */
refresh: () => Promise<void>
clock?: Clock
}
export function useLargeFileScanRefresh(options: LargeFileScanRefreshOptions) {
const clock = options.clock ?? systemClock
const authStore = useAuthStore()
let timer: ClockTimer | null = null
let polls = 0
let inFlight = false
/** 멈췄다 — 세션이 바뀌었다 (다시 시작하지 않는다) */
let stopped = false
function clear() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
function schedule() {
clear()
if (stopped || inFlight || !options.scanning() || polls >= LARGE_FILE_SCAN_POLL_LIMIT) return
const delays = LARGE_FILE_SCAN_POLL_DELAYS_MS
const delay = delays[Math.min(polls, delays.length - 1)] ?? 0
timer = clock.setTimeout(() => {
timer = null
polls += 1
void run()
}, delay)
}
async function run() {
if (stopped || inFlight) return
inFlight = true
const messageId = options.messageId()
try {
await options.refresh()
} finally {
inFlight = false
}
// 그사이 다른 메일로 바뀌었으면 그 메일의 셈이 이미 새로 시작했다
if (options.messageId() === messageId) schedule()
}
/** 막힌 [다운로드]를 눌렀다 — 기다리던 묻기를 앞당긴다 (셈은 늘리지 않는다) */
function refreshNow() {
if (stopped || inFlight || !options.scanning()) return
clear()
void run()
}
function stop() {
stopped = true
clear()
}
// 메일이 바뀌면 처음부터 — 검사 중인 파일이 없으면 묻지 않는다
watch(
options.messageId,
() => {
polls = 0
schedule()
},
{ immediate: true },
)
// 검사가 끝났으면(응답 · 실시간 소식) 멈추고, 다시 검사 중이 되면(다른 덩이) 이어서 센다
watch(options.scanning, (scanning) => {
if (scanning) {
if (timer === null) schedule()
} else {
clear()
}
})
// 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 멈춘다 — 앞 사용자의 메일을 묻지 않게
watch(
() => authStore.generation,
() => stop(),
)
onScopeDispose(stop)
return { refreshNow }
}
@@ -0,0 +1,664 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { LargeFile } from '@/shared/types/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useLargeFileUploads } from './useLargeFileUploads'
// 쓰기 화면의 대용량 첨부 — 조각 올리기 (계약 4절 "올리기" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
// 서버는 가짜다: 조각 크기를 4바이트로 알려 작은 파일로 여러 조각을 본다 (클라이언트는 응답의 chunkSize 를 따른다)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CHUNK = 4
const GB = 1024 * 1024 * 1024
const LIMITS = { maxFileBytes: 2047 * 1024 * 1024, maxFilesPerMessage: 10, limitBytes: 10 * GB, baseUsedBytes: 0 }
/** 조각마다 다른 "지문" — 실제 SHA-256 대신 크기 · 처음 글자로 (테스트는 같은지만 본다) */
async function fakeDigest(chunk: Blob): Promise<string> {
return `sha-${chunk.size}-${(await chunk.text()).slice(0, 1)}`
}
function makeFile(name: string, content: string, lastModified = 1_700_000_000_000): File {
return new File([content], name, { lastModified })
}
interface FakeUpload extends LargeFile {
/** 조각 번호 → 받은 내용 */
chunks: Map<number, string>
}
/** 서버가 내보내는 모양 — 받은 조각의 내용(chunks)은 빼고 번호만 */
function largeFileOf(upload: FakeUpload): LargeFile {
const copy: Partial<FakeUpload> = { ...upload }
delete copy.chunks
return { ...(copy as LargeFile), receivedChunks: [...upload.chunks.keys()].sort((a, b) => a - b) }
}
/** 붙잡힌 조각 요청 — 풀리거나(released) 요청이 취소되면(aborted) 끝난다 */
function waitGate(gate: Promise<void>, request: MockRequest): Promise<'released' | 'aborted'> {
const signal = request.config.signal as AbortSignal | undefined
const aborted = new Promise<'aborted'>((resolve) => {
if (signal?.aborted) resolve('aborted')
signal?.addEventListener('abort', () => resolve('aborted'), { once: true })
})
return Promise.race([gate.then(() => 'released' as const), aborted])
}
/** 가짜 서버 — 만들기 · 조각 · 끝내기 · 상태 · 지우기. fault 로 조각마다 실패를 끼운다 */
function createFakeServer() {
const uploads = new Map<string, FakeUpload>()
let nextId = 1
let fault: ((request: MockRequest, index: number) => MockResult | null) | null = null
let createFault: (() => MockResult | null) | null = null
let completionFault: (() => MockResult | null) | null = null
/** 조각 요청을 붙잡아 둔다 — 풀 때까지 응답하지 않는다 (동시에 몇 개가 나갔는지 본다) */
let chunkGate: Promise<void> | null = null
function view(upload: FakeUpload): LargeFile {
return largeFileOf(upload)
}
async function handle(request: MockRequest): Promise<MockResult> {
if (request.method === 'POST' && request.url === '/file-uploads') {
const blocked = createFault?.()
if (blocked) return blocked
const body = request.data as { name: string; size: number; lastModified: number }
const id = `u${nextId}`
nextId += 1
const upload: FakeUpload = {
id,
name: body.name,
size: body.size,
status: 'uploading',
chunkSize: CHUNK,
chunkCount: Math.ceil(body.size / CHUNK),
receivedChunks: [],
firstChunkSha256: null,
scanStatus: null,
createdAt: '2026-10-30T05:00:00Z',
expiresAt: '2026-10-31T05:00:00Z',
lastModified: body.lastModified,
chunks: new Map(),
}
uploads.set(id, upload)
return ok(view(upload), 201)
}
const chunk = /^\/file-uploads\/([^/]+)\/chunks\/(\d+)$/.exec(request.url)
if (request.method === 'PUT' && chunk) {
const upload = uploads.get(chunk[1] ?? '')
if (!upload) return fail(404, 'RES_001')
const index = Number(chunk[2])
// 붙잡힌 동안 멈춤 · 빼기(요청 취소)가 오면 브라우저처럼 바로 취소로 끝난다
if (chunkGate && (await waitGate(chunkGate, request)) === 'aborted') return 'cancel'
const injected = fault?.(request, index)
if (injected) return injected
const content = await (request.data as Blob).text()
upload.chunks.set(index, content)
if (index === 0) upload.firstChunkSha256 = `sha-${content.length}-${content.slice(0, 1)}`
return ok(view(upload))
}
const completion = /^\/file-uploads\/([^/]+)\/completion$/.exec(request.url)
if (request.method === 'POST' && completion) {
const upload = uploads.get(completion[1] ?? '')
if (!upload) return fail(404, 'RES_001')
if (upload.chunks.size < upload.chunkCount) return fail(409, 'FILE_003')
const busy = completionFault?.()
if (busy) return busy
upload.status = 'ready'
upload.scanStatus = 'pending'
return ok(view(upload))
}
const single = /^\/file-uploads\/([^/]+)$/.exec(request.url)
if (single && request.method === 'GET') {
const upload = uploads.get(single[1] ?? '')
return upload ? ok(view(upload)) : fail(404, 'RES_001')
}
if (single && request.method === 'DELETE') {
return uploads.delete(single[1] ?? '') ? { status: 204 } : fail(404, 'RES_001')
}
return fail(500, 'SYS_001')
}
return {
uploads,
handle,
setFault(next: typeof fault) {
fault = next
},
setCreateFault(next: typeof createFault) {
createFault = next
},
setCompletionFault(next: typeof completionFault) {
completionFault = next
},
/** 조각 요청을 붙잡는다 — 돌려준 함수로 푼다 */
holdChunks(): () => void {
let release: () => void = () => undefined
chunkGate = new Promise<void>((resolve) => {
release = () => {
chunkGate = null
resolve()
}
})
return release
},
}
}
describe('useLargeFileUploads', () => {
let clock: ManualClock
let scope: EffectScope
let server: ReturnType<typeof createFakeServer>
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
clock = createManualClock(Date.UTC(2026, 9, 30, 5, 0, 0))
server = createFakeServer()
requests = installMockApi((request) => server.handle(request))
scope = effectScope()
})
afterEach(() => {
scope.stop()
useToastStore().clear()
restoreApiAdapter()
})
function start(limits: Partial<typeof LIMITS> = {}) {
const uploads = scope.run(() =>
useLargeFileUploads({ clock, limits: () => ({ ...LIMITS, ...limits }), digest: fakeDigest }),
)
if (!uploads) throw new Error('스코프를 만들지 못함')
return uploads
}
const lines = () => requests.map((request) => `${request.method} ${request.url}`)
it('만들기 → 조각마다(파일마다 한 번에 하나) Content-Digest → 끝내기 → 다 올림(검사 중)', async () => {
const uploads = start()
uploads.addFiles([makeFile('설계도_v3.dwg', 'abcdefghij')])
await flushPromises()
expect(lines()).toEqual([
'POST /file-uploads',
'PUT /file-uploads/u1/chunks/0',
'PUT /file-uploads/u1/chunks/1',
'PUT /file-uploads/u1/chunks/2',
'POST /file-uploads/u1/completion',
])
expect(requests[0]?.data).toEqual({ name: '설계도_v3.dwg', size: 10, purpose: 'mail', lastModified: 1_700_000_000_000 })
const chunk = requests[1]
expect(chunk?.config.headers['Content-Type']).toBe('application/octet-stream')
expect(chunk?.config.headers['Content-Digest']).toBe('sha-256=:sha-4-a:')
expect(chunk?.config.silent).toBe(true)
expect(server.uploads.get('u1')?.chunks.get(2)).toBe('ij')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u1', phase: 'ready', scanStatus: 'pending', receivedBytes: 10 })
expect(uploads.uploadIds.value).toEqual(['u1'])
expect(uploads.isUploading.value).toBe(false)
})
it('잠깐 끊기면 2 · 5 · 15 · 30초 간격으로 저절로 다시 — 1분 넘게 못 하면 멈추고, [다시 시도]는 받은 조각부터', async () => {
const uploads = start()
let failing = true
server.setFault((_request, index) => (failing && index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('현장영상.mp4', 'abcdefghij')])
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('reconnecting')
expect(uploads.items.value[0]?.receivedBytes).toBe(4)
for (const delay of [2_000, 5_000, 15_000, 30_000]) {
clock.advance(delay)
await flushPromises()
}
// 처음 실패한 뒤 52초 — 아직 1분이 안 됐다
expect(uploads.items.value[0]?.phase).toBe('reconnecting')
clock.advance(30_000)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('stalled')
expect(uploads.isUploading.value).toBe(false)
expect(uploads.hasIncomplete.value).toBe(true)
const chunkOneTries = lines().filter((line) => line.endsWith('/chunks/1')).length
expect(chunkOneTries).toBe(6)
failing = false
uploads.resume(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('ready')
// 받은 조각(0번)은 다시 보내지 않았다
expect(lines().filter((line) => line.endsWith('/chunks/0'))).toHaveLength(1)
})
it('조각이 손상되면(FILE_004) 그 조각만 다시 — 세 번 거듭되면 멈춘다', async () => {
const uploads = start()
let corrupt = 2
server.setFault((_request, index) => {
if (index !== 0 || corrupt <= 0) return null
corrupt -= 1
return fail(400, 'FILE_004')
})
uploads.addFiles([makeFile('a.zip', 'abcdef')])
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('ready')
expect(lines().filter((line) => line.endsWith('/chunks/0'))).toHaveLength(3)
server.setFault((_request, index) => (index === 0 ? fail(400, 'FILE_004') : null))
uploads.addFiles([makeFile('b.zip', 'abcdef')])
await flushPromises()
expect(uploads.items.value[1]?.phase).toBe('stalled')
})
it('끝내기가 진행 중이면(FILE_003 — 조각은 다 있음) 쉬지 않고 묻지 않는다 — 2 · 5 · 15 · 30초 뒤 다시, 1분 넘게 그대로면 멈춘다', async () => {
const uploads = start()
let busy = true
server.setCompletionFault(() => (busy ? fail(409, 'FILE_003') : null))
uploads.addFiles([makeFile('설계도_v3.dwg', 'abcdefgh')])
await flushPromises()
// 끝내기 → 409 → 상태를 한 번 묻고 기다린다 (바로 다시 끝내지 않는다)
expect(lines().slice(-2)).toEqual(['POST /file-uploads/u1/completion', 'GET /file-uploads/u1'])
const settled = lines().length
await flushPromises()
expect(lines()).toHaveLength(settled)
expect(uploads.items.value[0]?.phase).toBe('uploading')
clock.advance(2_000)
await flushPromises()
expect(lines().slice(settled)).toEqual(['POST /file-uploads/u1/completion', 'GET /file-uploads/u1'])
// 그사이 끝났다 — 다음 묻기에서 다 올림
busy = false
clock.advance(5_000)
await flushPromises()
expect(uploads.items.value[0]).toMatchObject({ phase: 'ready', scanStatus: 'pending' })
// 끝내 끝나지 않으면 1분 뒤 멈춘다 — [다시 시도]
const stuck = start()
busy = true
stuck.addFiles([makeFile('현장영상.mp4', 'abcd')])
await flushPromises()
for (const delay of [2_000, 5_000, 15_000, 30_000, 30_000]) {
clock.advance(delay)
await flushPromises()
}
expect(stuck.items.value[0]?.phase).toBe('stalled')
// 처음 + 2 · 7 · 22 · 52초 뒤 + 82초 뒤(1분이 넘어 이것을 끝으로 멈춘다) — 잠깐 끊겼을 때와 같은 셈
expect(lines().filter((line) => line === 'POST /file-uploads/u2/completion')).toHaveLength(6)
})
it('[멈춤]은 그 파일만 · [이어 올리기]로 받은 조각부터 잇는다', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('a.mov', 'abcdefghij')])
await flushPromises()
const key = uploads.items.value[0]?.key ?? 0
uploads.pause(key)
expect(uploads.items.value[0]?.phase).toBe('paused')
clock.advance(60_000)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('paused')
server.setFault(null)
uploads.resume(key)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('ready')
})
it('올리기 전 검사 — 2GB 넘는 파일 · 11번째 파일 · 보관 한도 · 막힌 형식은 만들지 않고 빨간 줄 (보내지도 저장하지도 않는다)', async () => {
const uploads = start({ maxFilesPerMessage: 1, limitBytes: 15, maxFileBytes: 12 })
uploads.addFiles([
makeFile('too-big.zip', 'x'.repeat(13)),
makeFile('first.zip', 'x'.repeat(10)),
makeFile('second.zip', 'xx'),
makeFile('setup.exe', 'xx'),
])
await flushPromises()
expect(uploads.items.value.map((item) => [item.name, item.phase, item.errorText])).toEqual([
// 한도 글은 MB · GB 로 적는다 — 이 테스트의 12바이트 한도는 "0MB" 로 보인다
['too-big.zip', 'rejected', '파일 하나는 0MB 까지 보낼 수 있습니다.'],
['first.zip', 'ready', null],
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
])
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
expect(uploads.uploadIds.value).toEqual(['u1'])
})
it('보관 한도 — 받은 사용량에 이 화면에서 만든 올리기를 더해 센다 · [대용량 첨부 보기]를 잇는다', async () => {
const uploads = start({ limitBytes: 20, baseUsedBytes: 8 })
uploads.addFiles([makeFile('a.zip', 'x'.repeat(10)), makeFile('b.zip', 'x'.repeat(3))])
await flushPromises()
expect(uploads.items.value[1]).toMatchObject({ phase: 'rejected', errorLinksSettings: true })
expect(uploads.usedBytes.value).toBe(18)
})
it('만들기를 서버가 거절하면(FILE_005 · FILE_006) 빨간 줄에 그 코드의 사전 문구 — 다시 하지 않는다', async () => {
const uploads = start()
server.setCreateFault(() => fail(409, 'FILE_005'))
uploads.addFiles([makeFile('a.zip', 'abc')])
await flushPromises()
expect(uploads.items.value[0]).toMatchObject({
phase: 'rejected',
uploadId: null,
errorText: ERROR_CODE_MESSAGES.FILE_005,
errorLinksSettings: true,
})
clock.advance(60_000)
await flushPromises()
expect(lines()).toEqual(['POST /file-uploads'])
server.setCreateFault(() => fail(507, 'FILE_006'))
uploads.addFiles([makeFile('b.zip', 'abc')])
await flushPromises()
expect(uploads.items.value[1]).toMatchObject({
phase: 'rejected',
errorText: ERROR_CODE_MESSAGES.FILE_006,
errorLinksSettings: false,
})
})
it('조각을 보내다 서버 공간이 모자라면(FILE_006) 멈추고 사전 문구 — 저절로 다시 하지 않는다', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(507, 'FILE_006') : null))
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
expect(uploads.items.value[0]).toMatchObject({ phase: 'stalled', errorText: ERROR_CODE_MESSAGES.FILE_006 })
clock.advance(60_000)
await flushPromises()
expect(lines().filter((line) => line.endsWith('/chunks/1'))).toHaveLength(1)
})
it('빼기(✕) — 올리던 것을 멈추고 서버의 올리기를 지운다 (알림 없이)', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
uploads.remove(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(uploads.items.value).toEqual([])
expect(lines().at(-1)).toBe('DELETE /file-uploads/u1')
expect(requests.at(-1)?.config.silent).toBe(true)
expect(useToastStore().toasts).toEqual([])
})
it('서버가 올리기를 잊었으면(404 — 24시간) 멈추고 알린다 — [다시 시도]를 누를 때만 처음부터 새로 만든다', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
server.uploads.delete('u1')
server.setFault(null)
clock.advance(2_000)
await flushPromises()
// 저절로 다시 만들지 않는다 (계약 4절 "클라이언트" — 404 는 멈추고 알린다)
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u1', phase: 'stalled', errorText: LARGE_FILE_TEXT.restartNeeded })
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
clock.advance(60_000)
await flushPromises()
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
uploads.resume(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(2)
// 서버가 이미 잊은 올리기는 지울 것이 없다
expect(lines()).not.toContain('DELETE /file-uploads/u1')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u2', phase: 'ready', restarted: true, errorText: null })
})
it('조각이 409 인데 서버가 이미 받았다면 그 조각을 한 번 더 보내 보고, 200 이면 다음 조각으로 잇는다', async () => {
const uploads = start()
let first = true
server.setFault((_request, index) => {
if (index !== 1 || !first) return null
// 앞 요청(응답을 받지 못한 것)이 같은 내용으로 먼저 마쳤다
first = false
server.uploads.get('u1')?.chunks.set(1, 'efgh')
return fail(409, 'FILE_003')
})
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
expect(lines()).toEqual([
'POST /file-uploads',
'PUT /file-uploads/u1/chunks/0',
'PUT /file-uploads/u1/chunks/1',
'GET /file-uploads/u1',
'PUT /file-uploads/u1/chunks/1',
'PUT /file-uploads/u1/chunks/2',
'POST /file-uploads/u1/completion',
])
expect(uploads.items.value[0]).toMatchObject({ phase: 'ready', receivedBytes: 10 })
})
it('다시 보낸 조각도 409 면 받은 조각과 내용이 다르다 — 멈추고, [다시 시도]면 앞 올리기를 지우고 처음부터', async () => {
const uploads = start()
let changed = true
server.setFault((_request, index) => {
if (index !== 1 || !changed) return null
// 서버가 받은 1번 조각은 지금 파일과 내용이 다르다 (올리는 사이 파일이 바뀌었다)
server.uploads.get('u1')?.chunks.set(1, 'XXXX')
return fail(409, 'FILE_003')
})
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
expect(lines().slice(2)).toEqual([
'PUT /file-uploads/u1/chunks/1',
'GET /file-uploads/u1',
'PUT /file-uploads/u1/chunks/1',
])
expect(uploads.items.value[0]).toMatchObject({ phase: 'stalled', errorText: ERROR_CODE_MESSAGES.FILE_003 })
// 다시 보낸 조각은 진행에 더하지 않는다 — 받은 바이트를 넘지 않는다
expect(uploads.items.value[0]?.sendingBytes).toBe(0)
changed = false
uploads.resume(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(lines()).toContain('DELETE /file-uploads/u1')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u2', phase: 'ready', restarted: true })
})
it('여러 파일을 고르면 모두 바로 만든다(저장본에 실린다) — 조각은 3개 파일만 함께 보내고 나머지는 차례를 기다린다', async () => {
const uploads = start()
const release = server.holdChunks()
uploads.addFiles(['a', 'b', 'c', 'd', 'e'].map((name) => makeFile(`${name}.zip`, 'abcdefgh')))
await flushPromises()
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(5)
// 조각 요청은 3개만 나갔다 — 파일마다 한 번에 하나
expect(lines().filter((line) => line.startsWith('PUT '))).toEqual([
'PUT /file-uploads/u1/chunks/0',
'PUT /file-uploads/u2/chunks/0',
'PUT /file-uploads/u3/chunks/0',
])
expect(uploads.items.value.map((item) => item.waitingTurn)).toEqual([false, false, false, true, true])
// 차례를 기다리는 파일도 id 가 있다 — 저장본 · 나가기 확인에 들어간다
expect(uploads.uploadIds.value).toEqual(['u1', 'u2', 'u3', 'u4', 'u5'])
expect(uploads.activeCount.value).toBe(5)
release()
await flushPromises()
await flushPromises()
expect(uploads.items.value.map((item) => item.phase)).toEqual(['ready', 'ready', 'ready', 'ready', 'ready'])
expect(uploads.items.value.some((item) => item.waitingTurn)).toBe(false)
})
it('[멈춤]으로 자리가 나면 차례를 기다리던 파일이 이어 받는다', async () => {
const uploads = start()
const release = server.holdChunks()
uploads.addFiles(['a', 'b', 'c', 'd'].map((name) => makeFile(`${name}.zip`, 'abcd')))
await flushPromises()
expect(uploads.items.value[3]?.waitingTurn).toBe(true)
uploads.pause(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(uploads.items.value[3]?.waitingTurn).toBe(false)
expect(lines()).toContain('PUT /file-uploads/u4/chunks/0')
release()
await flushPromises()
expect(uploads.items.value.map((item) => item.phase)).toEqual(['paused', 'ready', 'ready', 'ready'])
})
it('조각의 SHA-256 을 셈할 수 없으면(보안 컨텍스트가 아님) Content-Digest 없이 보낸다', async () => {
const uploads = scope.run(() =>
useLargeFileUploads({ clock, limits: () => LIMITS, digest: () => Promise.resolve(null) }),
)
uploads?.addFiles([makeFile('a.zip', 'abcd')])
await flushPromises()
const chunk = requests.find((request) => request.method === 'PUT')
expect(chunk?.config.headers['Content-Digest']).toBeUndefined()
expect(uploads?.items.value[0]?.phase).toBe('ready')
})
describe('저장본에서 이어 온 파일 (계약 4절 "새로고침 · 창 닫힘 뒤 이어 올리기")', () => {
async function stoppedUpload(content: string, received: number) {
// 앞 화면에서 조각 received 개를 받은 채 멈춘 올리기를 서버에 만들어 둔다
const earlier = start()
server.setFault((_request, index) => (index >= received ? fail(503, 'MAIL_001') : null))
earlier.addFiles([makeFile('현장영상_1029.mp4', content)])
await flushPromises()
earlier.reset()
server.setFault(null)
const upload = server.uploads.get('u1')
if (!upload) throw new Error('올리기가 없음')
requests.length = 0
return largeFileOf(upload)
}
it('같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각)을 다시 고르면 빠진 조각부터 잇는다', async () => {
const draftFile = await stoppedUpload('abcdefghij', 2)
const uploads = start()
uploads.addFromDraft([draftFile])
expect(uploads.items.value[0]).toMatchObject({ phase: 'waiting-file', receivedBytes: 8, uploadId: 'u1' })
expect(uploads.hasIncomplete.value).toBe(true)
expect(uploads.uploadIds.value).toEqual(['u1'])
await uploads.repick(uploads.items.value[0]?.key ?? 0, makeFile('현장영상_1029.mp4', 'abcdefghij'))
await flushPromises()
expect(lines()).toEqual(['PUT /file-uploads/u1/chunks/2', 'POST /file-uploads/u1/completion'])
expect(uploads.items.value[0]?.phase).toBe('ready')
})
it('이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다', async () => {
const draftFile = await stoppedUpload('abcdefghij', 2)
const uploads = start()
uploads.addFromDraft([draftFile])
await uploads.repick(uploads.items.value[0]?.key ?? 0, makeFile('다른파일.mp4', 'abcdefghij'))
expect(uploads.items.value[0]).toMatchObject({ phase: 'waiting-file', repickMismatch: true })
expect(requests).toEqual([])
})
it('같아 보여도 맨 앞 조각이 다르면 새로 만든다 — 앞 올리기는 지운다', async () => {
const draftFile = await stoppedUpload('abcdefghij', 2)
const uploads = start()
uploads.addFromDraft([draftFile])
await uploads.repick(uploads.items.value[0]?.key ?? 0, makeFile('현장영상_1029.mp4', 'zbcdefghij'))
await flushPromises()
expect(lines()[0]).toBe('DELETE /file-uploads/u1')
expect(lines()).toContain('POST /file-uploads')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u2', phase: 'ready', restarted: false })
})
it('다 올린 파일은 남는 기한과 함께 · 없는 파일은 까닭과 함께 — 없는 파일은 실을 목록에서 빠진다', () => {
const uploads = start()
uploads.addFromDraft([
{
id: 'r1',
name: '설계도_v3.dwg',
size: 1.2 * GB,
status: 'ready',
chunkSize: CHUNK,
chunkCount: 3,
receivedChunks: [0, 1, 2],
firstChunkSha256: 'x',
scanStatus: 'clean',
createdAt: '2026-10-30T05:00:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
lastModified: null,
},
{ id: 'm1', status: 'missing', reason: 'deleted', name: '시공도면_v4.dwg', size: 620 },
])
expect(uploads.items.value.map((item) => [item.phase, item.keptUntil, item.missingReason])).toEqual([
['ready', '2026-11-29T14:59:59.999Z', null],
['missing', null, 'deleted'],
])
expect(uploads.uploadIds.value).toEqual(['r1'])
expect(uploads.hasMissing.value).toBe(true)
})
})
it('다 올린 파일의 보안 검사 — 쓰기 화면이 떠 있는 동안 다시 묻는다 · 실시간 위험 판정이면 "위험"', async () => {
const uploads = start()
uploads.addFiles([makeFile('a.zip', 'abc'), makeFile('b.zip', 'def')])
await flushPromises()
const upload = server.uploads.get('u1')
if (upload) upload.scanStatus = 'clean'
clock.advance(15_000)
await flushPromises()
expect(uploads.items.value[0]?.scanStatus).toBe('clean')
uploads.markInfected('u2')
expect(uploads.items.value[1]?.scanStatus).toBe('infected')
expect(uploads.hasInfected.value).toBe(true)
})
it('보내기가 MAIL_016 이면 다시 묻는다 — 404 는 찾을 수 없는 파일', async () => {
const uploads = start()
uploads.addFiles([makeFile('a.zip', 'abc')])
await flushPromises()
server.uploads.delete('u1')
await uploads.recheck()
expect(uploads.items.value[0]).toMatchObject({ phase: 'missing', missingReason: 'unknown' })
expect(uploads.hasMissing.value).toBe(true)
})
})
@@ -0,0 +1,979 @@
import { computed, onScopeDispose, ref } from 'vue'
import { getErrorMessage, isSessionEnd, toApiError, type ApiError } from './useApi'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useLargeFiles } from './useLargeFiles'
import {
LARGE_FILE_CHUNK_SIZE,
LARGE_FILE_DIGEST_RETRY_LIMIT,
LARGE_FILE_ETA_WARMUP_MS,
LARGE_FILE_ETA_WINDOW_MS,
LARGE_FILE_PARALLEL_UPLOADS,
LARGE_FILE_RETRY_DELAYS_MS,
LARGE_FILE_SCAN_POLL_DELAYS_MS,
LARGE_FILE_SCAN_POLL_LIMIT,
LARGE_FILE_STALL_AFTER_MS,
LARGE_FILE_TEXT,
} from '@/shared/constants/large-file'
import type { DraftLargeFile, LargeFile, ScanStatus } from '@/shared/types/large-file'
import { formatCount } from '@/shared/utils/format'
import {
chunkBounds,
chunkCountOf,
formatSizeLimit,
looksLikeSameFile,
missingChunksOf,
precheckLargeFile,
receivedBytesOf,
sha256Base64,
type LargeFileLimits,
type LargeFilePrecheckProblem,
} from '@/shared/utils/large-file'
// 쓰기 화면의 대용량 첨부 — 조각 올리기 (계약 4절 "올리기" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
// - 고른 파일은 올리기 전에 검사한다(형식 · 한 파일 · 한 통의 개수 · 보관량) — 걸리면 빨간 줄로 남고 만들지 않는다(보내기는 막지 않는다).
// 그 글은 화면 상수다. 서버가 거절한 것은 그 코드의 사전 문구다(getErrorMessage).
// - 만들기(POST /file-uploads) → 조각(PUT …/chunks/{n} — 16MiB, 파일마다 한 번에 한 조각, Content-Digest) → 끝내기(POST …/completion).
// 만들기는 고르자마자 한다 — 그래야 차례를 기다리는 파일도 저장본에 실린다(서버의 동시 한도는 조각 요청만 센다 — 직원마다 4개).
// 조각을 함께 보내는 파일은 LARGE_FILE_PARALLEL_UPLOADS 개 — 나머지는 만든 채 차례를 기다린다(waitingTurn).
// - 조각 요청은 중앙 axios(silent) — 401 이면 세션을 한 번 갱신하고 같은 조각을 다시 보낸다. 실패는 그 파일 줄에 보인다(알림 없음).
// 네트워크 · 시간 초과 · 429 · 502 · 503 · 504 는 2 · 5 · 15 · 30초 간격으로 저절로 다시("다시 잇는 중"), 1분 넘게 못 하면 멈추고 [다시 시도].
// FILE_004(조각 손상)는 그 조각만 다시(세 번 거듭되면 멈춘다).
// FILE_003 은 상태를 묻는다 — 하려던 조각을 서버가 이미 받았다면 그 조각을 한 번 더 보내 200 인지 보고(또 409 면 내용이 다른 조각 — 멈춘다),
// 빠진 조각이 있으면 바로 잇고, 그대로면(같은 조각 · 끝내기를 다른 요청이 하는 중) 잠깐 끊겼을 때와 같은 간격으로 기다린다.
// FILE_006(서버 공간) · 404(서버가 올리기를 잊음 — 마지막 조각 뒤 24시간 · 설정에서 지움)는 멈추고 알린다 (계약 4절 "클라이언트").
// 404 와 내용이 다른 조각은 [다시 시도]를 누를 때만 처음부터 새로 만든다 — 만들기의 FILE_001 · FILE_002 · FILE_005 · FILE_006 은 빨간 줄(만들지 않았다).
// - [멈춤] · [이어 올리기]는 그 파일만. ✕(빼기)는 올리던 것을 멈추고 서버의 올리기도 지운다(DELETE — 알림 없이, 404 는 지운 것으로 본다).
// - 저장본에서 이어 온 올리는 중 파일(새로고침 · 창 닫힘 뒤)은 같은 파일을 다시 골라야 이어진다 — 이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256 으로 맞춘다.
// 이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다. 그것은 같은데 내용이 다르거나 맨 앞 조각을 받기 전이면 새로 만든다(앞 올리기는 지운다).
// - 다 올린 파일의 보안 검사가 끝났는지는 화면이 떠 있는 동안 가끔 다시 묻는다 — 위험 판정은 실시간 알림이 먼저 알린다(markInfected).
// 화면(스코프)이 사라지거나 양식을 새로 채우면(reset) 올리던 요청을 모두 멈춘다 — 서버의 올리기는 지우지 않는다(임시 저장본이 이어 쓴다).
/**
* 줄의 상태
* - rejected: 올리기 전 검사 · 만들기에서 거절 — 빨간 줄 (만들지 않았다 · 보내기를 막지 않는다)
* - uploading: 만들기 · 조각 · 끝내기 (차례를 기다리는 동안 포함) · reconnecting: 잠깐 끊겨 저절로 다시 잇는 중
* - paused: 사용자가 멈춤 · stalled: 오래 끊김 · 거듭된 손상 · 서버 공간 · 서버가 잊음 · 내용이 다른 조각 — [다시 시도]
* - waiting-file: 저장본에서 이어 온 올리는 중 파일 — 같은 파일을 다시 골라야 이어진다
* - ready: 다 올림 (scanStatus — 검사 중 · 깨끗함 · 위험 · 검사하지 못함)
* - missing: 저장본의 지운 · 기간이 지난 · 찾을 수 없는 파일 — 빼야 보낼 수 있다
*/
export type LargeUploadPhase =
| 'rejected'
| 'uploading'
| 'reconnecting'
| 'paused'
| 'stalled'
| 'waiting-file'
| 'ready'
| 'missing'
/** 저장본의 없는 파일의 까닭 — 지움 · 기간 지남 · 모름(보내기가 MAIL_016 이라 다시 물었더니 없음) */
export type LargeUploadMissingReason = 'deleted' | 'expired' | 'unknown'
export interface LargeUploadItem {
/** 화면 키 — 일반 첨부와 따로 센다 */
key: number
/** 서버 올리기 id (= 다 올린 뒤의 파일 id) — 만들기 전 · 거절이면 null */
uploadId: string | null
name: string
size: number
phase: LargeUploadPhase
/** 서버가 받은 조각의 바이트 */
receivedBytes: number
/** 지금 보내는 조각에서 올린 바이트 (진행 표시) */
sendingBytes: number
/** 끝내기 요청 중 */
finishing: boolean
/** 만들었고 조각을 보낼 차례를 기다린다 — 함께 보내는 수(LARGE_FILE_PARALLEL_UPLOADS) 밖 */
waitingTurn: boolean
scanStatus: ScanStatus | null
/** 저장본에서 이어 온 다 올린 파일이 남는 때 — "이 임시 저장본에 …까지 남습니다" */
keptUntil: string | null
missingReason: LargeUploadMissingReason | null
/** 빨간 줄 글 — 올리기 전 검사 · 만들기 거절 · 멈춘 까닭 (없으면 상태의 기본 글) */
errorText: string | null
/** 빨간 줄 끝에 [대용량 첨부 보기] (보관 한도) */
errorLinksSettings: boolean
/** 다시 고른 파일이 멈춘 파일과 다르다 */
repickMismatch: boolean
/** [다시 시도]로 처음부터 다시 올리는 중 (서버가 잊었거나 내용이 다른 조각이었다) */
restarted: boolean
/** 남은 시간(ms) — 모르면 null ("시간 계산 중") */
etaMs: number | null
}
/** 올리기 전 검사 · 만들기 거절의 줄 글 */
export interface LargeUploadProblem {
text: string
/** 줄 끝에 [대용량 첨부 보기] (보관 한도) */
linksSettings: boolean
}
export interface LargeUploadOptions {
/** 한도 — GET /file-quota 의 값(못 받았으면 대체값)과 받은 사용량 */
limits: () => Omit<LargeFileLimits, 'usedBytes'> & { baseUsedBytes: number }
clock?: Clock
/** 조각의 SHA-256(base64) — 테스트는 바꿔 넘긴다. 셈할 수 없으면(보안 컨텍스트가 아님) null */
digest?: (chunk: Blob) => Promise<string | null>
}
/** [다시 시도]를 누르면 처음부터 새로 만든다 — 서버가 올리기를 잊었다(404) · 받은 조각과 내용이 다르다(409) */
type RestartReason = 'gone' | 'changed'
/** 파일마다 화면에 그리지 않는 것 — 고른 파일 · 조각 셈 · 기다림 · 다시 하기 셈 */
interface UploadRuntime {
file: File | null
chunkSize: number
chunkCount: number
received: Set<number>
/** 서버가 아는 맨 앞 조각 해시 — 다시 고른 파일을 견줄 때 */
firstChunkSha256: string | null
lastModified: number | null
controller: AbortController | null
waitTimer: ClockTimer | null
wake: (() => void) | null
/** 이어서 실패하기 시작한 때 — 1분 넘게 다시 잇지 못하면 멈춘다 */
failingSince: number | null
retryCount: number
digestFailures: number
removed: boolean
/** 올리기 고리(drive)가 돌고 있다 */
running: boolean
/** 조각을 보낼 자리를 가졌다 — 함께 보내는 수를 센다 (만들기는 자리 없이 한다) */
slot: boolean
/** 멈춘 뒤 [다시 시도]면 처음부터 새로 만든다 */
restart: RestartReason | null
/** 이미 받았다는 조각을 한 번 더 보내 내용이 같은지 본다 (409 FILE_003 뒤 — 또 409 면 멈춘다) */
verifyChunk: number | null
/** 남은 시간을 셀 최근 진행 (시각 · 올린 바이트) */
samples: { at: number; bytes: number }[]
startedAt: number | null
scanTimer: ClockTimer | null
scanPolls: number
/** 이 화면에서 새로 만든 올리기 — 받은 사용량에 더한다 (저장본에서 이어 온 것은 이미 들어 있다) */
countsTowardSession: boolean
}
/** 저절로 다시 보낼 실패 — 응답 없음(네트워크 · 시간 초과) · 429 · 502 · 503 · 504 */
const RETRYABLE_STATUSES: readonly number[] = [429, 502, 503, 504]
const HTTP_NOT_FOUND = 404
function isRetryable(error: ApiError): boolean {
return error.status === undefined || RETRYABLE_STATUSES.includes(error.status) || error.code === 'REQ_001'
}
function isNotFound(error: ApiError): boolean {
return error.status === HTTP_NOT_FOUND || error.code === 'RES_001'
}
/** 기다림을 끊고 고리를 끝낼 때 던진다 — 멈춤 · 빼기 · 양식을 새로 채움 */
class UploadInterrupted extends Error {}
export function useLargeFileUploads(options: LargeUploadOptions) {
const api = useLargeFiles()
const clock = options.clock ?? systemClock
const digest = options.digest ?? sha256Base64
const items = ref<LargeUploadItem[]>([])
const runtimes = new Map<number, UploadRuntime>()
let nextKey = 1
/** 양식 세대 — reset 마다 올린다. 늦게 끝난 요청이 새 양식을 건드리지 않게 */
let generation = 0
function itemOf(key: number): LargeUploadItem | undefined {
return items.value.find((item) => item.key === key)
}
function newRuntime(file: File | null): UploadRuntime {
return {
file,
chunkSize: LARGE_FILE_CHUNK_SIZE,
chunkCount: file ? chunkCountOf(file.size, LARGE_FILE_CHUNK_SIZE) : 0,
received: new Set(),
firstChunkSha256: null,
lastModified: file?.lastModified ?? null,
controller: null,
waitTimer: null,
wake: null,
failingSince: null,
retryCount: 0,
digestFailures: 0,
removed: false,
running: false,
slot: false,
restart: null,
verifyChunk: null,
samples: [],
startedAt: null,
scanTimer: null,
scanPolls: 0,
countsTowardSession: file !== null,
}
}
function newItem(name: string, size: number, phase: LargeUploadPhase): LargeUploadItem {
const key = nextKey
nextKey += 1
return {
key,
uploadId: null,
name,
size,
phase,
receivedBytes: 0,
sendingBytes: 0,
finishing: false,
waitingTurn: false,
scanStatus: null,
keptUntil: null,
missingReason: null,
errorText: null,
errorLinksSettings: false,
repickMismatch: false,
restarted: false,
etaMs: null,
}
}
// ── 한도 · 셈 ─────────────
/** 이 메일에 실을(보낼 · 저장할) 줄 — 빨간 줄(거절) · 없는 파일은 빼고 */
const carried = computed(() => items.value.filter((item) => item.phase !== 'rejected' && item.phase !== 'missing'))
/** 이 화면에서 새로 만든 올리기의 크기 — 받은 사용량에 아직 들지 않았다 */
const sessionBytes = computed(() =>
items.value.reduce((sum, item) => {
const run = runtimes.get(item.key)
return run?.countsTowardSession && item.phase !== 'rejected' && item.phase !== 'missing' ? sum + item.size : sum
}, 0),
)
function limits(): LargeFileLimits {
const { baseUsedBytes, ...rest } = options.limits()
return { ...rest, usedBytes: baseUsedBytes + sessionBytes.value }
}
/** 지금 보관 사용량의 어림 — 받은 사용량 + 이 화면에서 새로 만든 것 (링크 묶음 머리의 "보관 8.4 / 10 GB") */
const usedBytes = computed(() => options.limits().baseUsedBytes + sessionBytes.value)
/** 서버에 묻기 전 검사의 글 — 화면 상수 (형식은 README 대로 FILE_001 사전 문구) */
function precheckText(problem: LargeFilePrecheckProblem): LargeUploadProblem {
const current = options.limits()
switch (problem) {
case 'type':
return { text: getErrorMessage('FILE_001'), linksSettings: false }
case 'size':
return { text: LARGE_FILE_TEXT.tooLarge(formatSizeLimit(current.maxFileBytes)), linksSettings: false }
case 'count':
return { text: LARGE_FILE_TEXT.tooMany(formatCount(current.maxFilesPerMessage)), linksSettings: false }
case 'quota':
return { text: LARGE_FILE_TEXT.overQuota(formatSizeLimit(current.limitBytes)), linksSettings: true }
}
}
/**
* 링크로 올리기 전 검사 — 지금 실은 파일 수 · 보관량을 센다. 걸리면 줄 글, 아니면 null.
* 「링크로 보내기」가 일반 첨부를 빼기 전에 본다 (걸리면 일반 첨부는 그대로)
*/
function precheck(file: File): LargeUploadProblem | null {
const problem = precheckLargeFile(file, limits(), carried.value.length)
return problem ? precheckText(problem) : null
}
/** 만들기를 서버가 거절한 까닭 → 빨간 줄 글(그 코드의 사전 문구). null 이면 거절로 다루지 않는다 — 다시 하기 · 멈춤 */
function creationRejection(error: ApiError): LargeUploadProblem | null {
switch (error.code) {
case 'FILE_001':
case 'FILE_002':
case 'FILE_006':
case 'VAL_001':
return { text: getErrorMessage(error.code), linksSettings: false }
case 'FILE_005':
return { text: getErrorMessage(error.code), linksSettings: true }
default:
return null
}
}
// ── 진행 · 남은 시간 ─────────────
function resetEta(item: LargeUploadItem, run: UploadRuntime) {
run.samples = []
run.startedAt = null
item.etaMs = null
}
function noteProgress(item: LargeUploadItem, run: UploadRuntime, loaded: number) {
item.sendingBytes = loaded
const now = clock.now()
const bytes = item.receivedBytes + loaded
run.startedAt ??= now
run.samples.push({ at: now, bytes })
while (run.samples.length > 2 && now - (run.samples[0]?.at ?? now) > LARGE_FILE_ETA_WINDOW_MS) run.samples.shift()
const first = run.samples[0]
const span = first ? now - first.at : 0
if (!first || now - run.startedAt < LARGE_FILE_ETA_WARMUP_MS || span <= 0 || bytes <= first.bytes) {
item.etaMs = null
return
}
const rate = (bytes - first.bytes) / span
item.etaMs = Math.max(0, (item.size - bytes) / rate)
}
/** 서버가 알려 준 올리기 상태를 줄에 옮긴다 (받은 조각 · 이름 · 다 올렸으면 검사 상태) */
function applyServerState(item: LargeUploadItem, run: UploadRuntime, upload: LargeFile) {
run.chunkSize = upload.chunkSize > 0 ? upload.chunkSize : run.chunkSize
run.chunkCount = upload.chunkCount > 0 ? upload.chunkCount : chunkCountOf(upload.size, run.chunkSize)
run.received = new Set(upload.receivedChunks)
run.firstChunkSha256 = upload.firstChunkSha256 ?? run.firstChunkSha256
run.lastModified = upload.lastModified ?? run.lastModified
item.uploadId = upload.id
item.name = upload.name || item.name
item.size = upload.size
item.receivedBytes = receivedBytesOf(run.received, run.chunkSize, upload.size)
item.sendingBytes = 0
// 조각은 다 갔다 — 남은 시간 대신 "마무리하는 중"
if (item.receivedBytes >= item.size) item.etaMs = null
if (upload.status === 'ready') {
item.phase = 'ready'
item.waitingTurn = false
item.scanStatus = upload.scanStatus
item.etaMs = null
item.errorText = null
scheduleScanPoll(item.key)
}
}
// ── 기다림 ─────────────
/** 기다린다 — 멈춤 · 빼기 · 다시 시도가 깨운다 */
function sleep(run: UploadRuntime, ms: number): Promise<void> {
return new Promise((resolve) => {
const done = () => {
if (run.waitTimer !== null) clock.clearTimeout(run.waitTimer)
run.waitTimer = null
run.wake = null
resolve()
}
run.wake = done
run.waitTimer = clock.setTimeout(done, ms)
})
}
/** 지금 올리고 있어야 하는 줄인가 — 멈추거나 빼거나 양식이 바뀌었으면 아니다 */
function isActive(key: number, gen: number): boolean {
const item = itemOf(key)
const run = runtimes.get(key)
return (
gen === generation &&
item !== undefined &&
run !== undefined &&
!run.removed &&
run.file !== null &&
(item.phase === 'uploading' || item.phase === 'reconnecting')
)
}
// ── 올리기 고리 ─────────────
/**
* 고리를 시작한다 — 만들기 전인 줄은 모두 바로(저장본에 실리게), 만든 줄은 조각 자리가 남는 만큼(동시 LARGE_FILE_PARALLEL_UPLOADS 개).
* 자리를 받지 못한 줄은 "차례를 기다리는 중"이다
*/
function pump() {
let slots = 0
runtimes.forEach((run) => {
if (run.slot) slots += 1
})
for (const item of items.value) {
const run = runtimes.get(item.key)
if (!run || run.running || run.removed || run.file === null) continue
if (item.phase !== 'uploading' && item.phase !== 'reconnecting') continue
if (item.uploadId !== null) {
if (slots >= LARGE_FILE_PARALLEL_UPLOADS) {
item.waitingTurn = true
continue
}
slots += 1
run.slot = true
}
item.waitingTurn = false
void drive(item.key, generation)
}
}
function stall(item: LargeUploadItem, run: UploadRuntime, text: string | null = null) {
item.phase = 'stalled'
item.errorText = text
item.sendingBytes = 0
item.waitingTurn = false
run.failingSince = null
run.retryCount = 0
resetEta(item, run)
}
/** 만들기 — 거절이면 빨간 줄로 두고 고리를 끝낸다 */
async function create(item: LargeUploadItem, run: UploadRuntime, gen: number): Promise<void> {
const file = run.file
if (!file) throw new UploadInterrupted()
const created = await api.createUpload(
{ name: item.name, size: item.size, purpose: 'mail', lastModified: file.lastModified },
{ silent: true },
)
// 그사이 뺐거나 양식을 새로 채웠다 — 어느 저장본에도 실리지 않을 올리기다. 보관량을 잡지 않게 지운다
if (gen !== generation || run.removed) {
void api.deleteUpload(created.id, { silent: true }).catch(() => undefined)
throw new UploadInterrupted()
}
applyServerState(item, run, created)
}
/** 조각 하나 — 받은 조각을 줄에 옮긴다. 이미 받은 조각을 다시 보내 내용을 견줄 때는 진행을 세지 않는다 */
async function sendChunk(item: LargeUploadItem, run: UploadRuntime, uploadId: string, index: number, gen: number) {
const file = run.file
if (!file) throw new UploadInterrupted()
const { start, end } = chunkBounds(index, run.chunkSize, item.size)
const chunk = file.slice(start, end)
const sha256 = await digest(chunk)
if (!isActive(item.key, gen)) throw new UploadInterrupted()
const verifying = run.verifyChunk === index
const controller = new AbortController()
run.controller = controller
item.sendingBytes = 0
try {
const updated = await api.putChunk(uploadId, index, chunk, {
sha256,
signal: controller.signal,
onProgress: (loaded) => {
if (!verifying && isActive(item.key, gen)) noteProgress(item, run, loaded)
},
})
if (gen !== generation || run.removed) throw new UploadInterrupted()
// 다시 보낸 조각이 받은 것과 같다 — 앞의 409 는 같은 조각을 앞 요청이 먼저 마친 것이었다
if (verifying) run.verifyChunk = null
applyServerState(item, run, updated)
} finally {
if (run.controller === controller) run.controller = null
}
}
/**
* 끝내기 — 다 올렸다. 빠진 조각 · 끝내기가 진행 중이면 서버가 FILE_003 이다(handleFailure 가 상태를 보고 잇는다).
* 200 인데 다 올리지 않았다고 하면(계약 밖) 거듭 묻지 않고 멈춘다 — [다시 시도]
*/
async function complete(item: LargeUploadItem, run: UploadRuntime, uploadId: string, gen: number): Promise<void> {
item.finishing = true
try {
const done = await api.completeUpload(uploadId, { silent: true })
if (gen !== generation || run.removed) throw new UploadInterrupted()
applyServerState(item, run, done)
if (item.phase !== 'ready') {
stall(item, run)
throw new UploadInterrupted()
}
} finally {
item.finishing = false
}
}
/** 처음부터 새로 만든다 — [다시 시도](서버가 잊었다 · 내용이 다른 조각) · 다시 고른 파일의 내용이 다르다 */
function restartFromScratch(item: LargeUploadItem, run: UploadRuntime) {
item.uploadId = null
item.receivedBytes = 0
item.sendingBytes = 0
item.restarted = true
run.received = new Set()
run.firstChunkSha256 = null
run.chunkSize = LARGE_FILE_CHUNK_SIZE
run.chunkCount = chunkCountOf(item.size, LARGE_FILE_CHUNK_SIZE)
run.countsTowardSession = true
run.restart = null
run.verifyChunk = null
resetEta(item, run)
}
/**
* 실패를 가른다 — 'retry': 고리가 이어서 한다(같은 조각 · 상태를 물은 뒤) · 'stop': 고리를 끝낸다(멈춤 · 거절 · 다 올림).
* 저절로 다시 보낼 실패는 여기서 기다린다
*/
async function handleFailure(
item: LargeUploadItem,
run: UploadRuntime,
error: unknown,
gen: number,
): Promise<'retry' | 'stop'> {
if (error instanceof UploadInterrupted) return 'stop'
const apiError = toApiError(error)
if (apiError.canceled || !isActive(item.key, gen)) return 'stop'
// 세션이 끝났다 — 세션 만료 대화상자가 로그인으로 보낸다. 저절로 다시 하지 않는다
if (isSessionEnd(apiError.code)) {
stall(item, run)
return 'stop'
}
if (item.uploadId === null) {
const rejected = creationRejection(apiError)
if (rejected) {
item.phase = 'rejected'
item.errorText = rejected.text
item.errorLinksSettings = rejected.linksSettings
run.file = null
return 'stop'
}
}
if (apiError.code === 'FILE_004') {
run.digestFailures += 1
if (run.digestFailures >= LARGE_FILE_DIGEST_RETRY_LIMIT) {
stall(item, run)
return 'stop'
}
return 'retry'
}
if (apiError.code === 'FILE_003' && item.uploadId !== null) return reconcileState(item, run, item.uploadId, gen)
if (isNotFound(apiError) && item.uploadId !== null) {
if (run.file) {
// 서버가 올리기를 잊었다(마지막 조각 뒤 24시간 · 설정에서 지움) — 멈추고 알린다. [다시 시도]면 처음부터 새로 만든다
stall(item, run, LARGE_FILE_TEXT.restartNeeded)
run.restart = 'gone'
return 'stop'
}
item.phase = 'missing'
item.missingReason = 'unknown'
return 'stop'
}
if (apiError.code === 'FILE_006') {
stall(item, run, getErrorMessage('FILE_006'))
return 'stop'
}
if (!isRetryable(apiError)) {
stall(item, run)
return 'stop'
}
// 잠깐 끊겼다 — 받은 조각은 그대로, 받지 못한 조각부터 저절로 다시 (1분 넘게 못 하면 멈춘다)
return backoff(item, run, gen, true)
}
/**
* 409 FILE_003 — 서버의 지금 상태를 보고 잇는다 (계약 4절 "조각" · "끝내기").
* - 하려던 조각을 다시 보냈는데 또 409: 받은 조각과 내용이 다르다(올리는 사이 파일이 바뀜) — 이어 붙이면 다른 파일이 된다. 멈추고,
* [다시 시도]면 지금 파일로 처음부터 새로 만든다(앞 올리기는 지운다)
* - 하려던 조각을 서버가 이미 받았다: 같은 조각을 앞 요청이 먼저 마쳤거나 내용이 다르다 — 한 번 더 보내 200 인지 본다
* - 빠진 조각이 있다(끝내기가 거절됨): 바로 그 조각부터
* - 그대로다: 같은 조각 · 끝내기를 다른 요청이 하는 중 — 기다렸다가 다시 (16MiB 조각을 쉬지 않고 거듭 보내지 않게)
*/
async function reconcileState(
item: LargeUploadItem,
run: UploadRuntime,
uploadId: string,
gen: number,
): Promise<'retry' | 'stop'> {
if (run.verifyChunk !== null) {
run.verifyChunk = null
stall(item, run, getErrorMessage('FILE_003'))
run.restart = 'changed'
return 'stop'
}
// 하려던 일 — 빠진 첫 조각(없으면 끝내기). 고리는 늘 이것부터 한다
const [attempted] = missingChunksOf(run.received, run.chunkCount)
let state: LargeFile
try {
state = await api.getUpload(uploadId, { silent: true })
} catch (stateError) {
return handleFailure(item, run, stateError, gen)
}
if (!isActive(item.key, gen)) return 'stop'
applyServerState(item, run, state)
if (item.phase === 'ready') return 'stop'
if (attempted !== undefined && run.received.has(attempted)) {
run.verifyChunk = attempted
return 'retry'
}
const [next] = missingChunksOf(run.received, run.chunkCount)
return next !== attempted ? 'retry' : backoff(item, run, gen, false)
}
/**
* 저절로 다시 하기 전에 기다린다 — 2 · 5 · 15 · 30초, 이어서 1분 넘게 나아가지 못했으면 멈춘다([다시 시도]).
* reconnecting 이면 줄에 "다시 잇는 중"을 보인다 (끊김). 아니면 올리는 중 그대로 기다린다 (다른 요청이 같은 일을 하는 중)
*/
async function backoff(
item: LargeUploadItem,
run: UploadRuntime,
gen: number,
reconnecting: boolean,
): Promise<'retry' | 'stop'> {
const now = clock.now()
run.failingSince ??= now
if (now - run.failingSince >= LARGE_FILE_STALL_AFTER_MS) {
stall(item, run)
return 'stop'
}
if (reconnecting) item.phase = 'reconnecting'
item.sendingBytes = 0
resetEta(item, run)
const delays = LARGE_FILE_RETRY_DELAYS_MS
const delay = delays[Math.min(run.retryCount, delays.length - 1)] ?? 0
run.retryCount += 1
await sleep(run, delay)
return isActive(item.key, gen) ? 'retry' : 'stop'
}
/**
* 한 파일의 고리 — 만들기 전이면 만들고(자리 없이), 조각 자리를 가졌으면 다 올릴 때까지.
* 만든 뒤 자리가 없으면 끝내고 차례를 기다린다(pump 가 자리가 나면 다시 부른다). 멈추거나 빼거나 거절 · 멈춤이면 끝난다
*/
async function drive(key: number, gen: number): Promise<void> {
const run = runtimes.get(key)
if (!run) return
run.running = true
try {
while (isActive(key, gen)) {
const item = itemOf(key)
if (!item) return
try {
if (item.uploadId === null) {
await create(item, run, gen)
if (!run.slot) return
} else {
const next = run.verifyChunk ?? missingChunksOf(run.received, run.chunkCount)[0]
if (next === undefined) {
await complete(item, run, item.uploadId, gen)
return
}
await sendChunk(item, run, item.uploadId, next, gen)
}
// 이어졌다 — 다시 잇기 셈을 처음부터
run.failingSince = null
run.retryCount = 0
run.digestFailures = 0
if (item.phase === 'reconnecting') item.phase = 'uploading'
} catch (error) {
if ((await handleFailure(item, run, error, gen)) === 'stop') return
}
}
} finally {
run.running = false
run.slot = false
if (gen === generation) pump()
}
}
// ── 검사 상태 다시 묻기 ─────────────
function clearScanPoll(run: UploadRuntime) {
if (run.scanTimer !== null) clock.clearTimeout(run.scanTimer)
run.scanTimer = null
}
/** 다 올린 파일의 검사가 끝났는지 — 검사 중인 동안 간격을 늘리며 다시 묻는다 (알림 없이) */
function scheduleScanPoll(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || item.phase !== 'ready' || item.scanStatus !== 'pending' || item.uploadId === null) return
if (run.scanTimer !== null || run.scanPolls >= LARGE_FILE_SCAN_POLL_LIMIT) return
const delays = LARGE_FILE_SCAN_POLL_DELAYS_MS
const delay = delays[Math.min(run.scanPolls, delays.length - 1)] ?? 0
const gen = generation
const uploadId = item.uploadId
run.scanTimer = clock.setTimeout(() => {
run.scanTimer = null
run.scanPolls += 1
void api
.getUpload(uploadId, { silent: true })
.then((upload) => {
const current = itemOf(key)
if (gen !== generation || run.removed || !current || current.uploadId !== uploadId) return
if (current.phase === 'ready') current.scanStatus = upload.scanStatus
})
.catch((error: unknown) => {
const current = itemOf(key)
if (gen !== generation || run.removed || !current) return
if (isNotFound(toApiError(error))) {
current.phase = 'missing'
current.missingReason = 'unknown'
}
})
.finally(() => {
if (gen === generation && !run.removed) scheduleScanPoll(key)
})
}, delay)
}
// ── 바깥에서 부르는 일 ─────────────
/**
* 고른 파일을 링크로 올린다 — 고른 차례대로 검사한다(한 통의 개수 · 보관량은 앞 파일을 센다).
* @returns 새로 만든 줄 (거절된 줄 포함) — 화면이 "합계를 넘어 링크로" 안내를 잇는다
*/
function addFiles(files: Iterable<File>): LargeUploadItem[] {
const added: LargeUploadItem[] = []
for (const file of files) {
const problem = precheckLargeFile(file, limits(), carried.value.length)
const item = newItem(file.name, file.size, problem ? 'rejected' : 'uploading')
if (problem) {
const rejected = precheckText(problem)
item.errorText = rejected.text
item.errorLinksSettings = rejected.linksSettings
runtimes.set(item.key, { ...newRuntime(null), countsTowardSession: false })
} else {
runtimes.set(item.key, newRuntime(file))
}
items.value = [...items.value, item]
added.push(item)
}
pump()
// 줄은 반응형 배열 안의 것을 돌려준다 (화면이 key 로 찾는다)
return added.map((item) => itemOf(item.key) ?? item)
}
/** 저장본의 대용량 파일을 줄로 — 다 올린 것 · 올리다 멈춘 것(같은 파일을 다시 골라야 한다) · 없는 것 */
function addFromDraft(files: readonly DraftLargeFile[]): LargeUploadItem[] {
const added: LargeUploadItem[] = []
for (const file of files) {
if (file.status === 'missing') {
const item = newItem(file.name ?? LARGE_FILE_TEXT.unnamed, file.size ?? 0, 'missing')
item.uploadId = file.id
item.missingReason = file.reason
runtimes.set(item.key, { ...newRuntime(null), countsTowardSession: false })
added.push(item)
continue
}
const item = newItem(file.name, file.size, file.status === 'ready' ? 'ready' : 'waiting-file')
const run = { ...newRuntime(null), countsTowardSession: false }
run.chunkSize = file.chunkSize > 0 ? file.chunkSize : LARGE_FILE_CHUNK_SIZE
run.chunkCount = file.chunkCount > 0 ? file.chunkCount : chunkCountOf(file.size, run.chunkSize)
run.received = new Set(file.receivedChunks)
run.firstChunkSha256 = file.firstChunkSha256
run.lastModified = file.lastModified
item.uploadId = file.id
item.receivedBytes = receivedBytesOf(run.received, run.chunkSize, file.size)
if (file.status === 'ready') {
item.scanStatus = file.scanStatus
item.keptUntil = file.expiresAt
}
runtimes.set(item.key, run)
added.push(item)
}
items.value = [...items.value, ...added]
for (const item of added) scheduleScanPoll(item.key)
return added.map((item) => itemOf(item.key) ?? item)
}
/** [멈춤] — 그 파일만. 보내던 조각은 버린다(서버는 다 받은 조각만 적는다) */
function pause(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || (item.phase !== 'uploading' && item.phase !== 'reconnecting')) return
item.phase = 'paused'
item.sendingBytes = 0
item.waitingTurn = false
run.controller?.abort()
run.wake?.()
run.failingSince = null
run.retryCount = 0
resetEta(item, run)
// 차례를 기다리던 다른 파일이 자리를 받는다 (돌던 고리는 끝나며 자리를 돌려준다)
pump()
}
/**
* [이어 올리기] · [다시 시도] — 받은 조각부터 이어서.
* 서버가 잊었거나(404) 받은 조각과 내용이 달라(409) 멈춘 파일은 처음부터 새로 만든다 — 내용이 다른 앞 올리기는 지운다(보관량을 잡지 않게)
*/
function resume(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || run.file === null || (item.phase !== 'paused' && item.phase !== 'stalled')) return
if (run.restart !== null) {
const previous = item.uploadId
if (run.restart === 'changed' && previous) void api.deleteUpload(previous, { silent: true }).catch(() => undefined)
restartFromScratch(item, run)
}
item.phase = 'uploading'
item.errorText = null
run.failingSince = null
run.retryCount = 0
run.digestFailures = 0
resetEta(item, run)
pump()
}
/**
* [파일 고르기] — 저장본에서 이어 온 올리는 중 파일을 같은 파일로 다시 고른다 (계약 4절 "새로고침 · 창 닫힘 뒤 이어 올리기").
* 이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다. 같으면 맨 앞 조각 SHA-256 을 견줘 같으면 받은 조각부터 잇고,
* 다르거나(내용이 바뀜) 맨 앞 조각을 받기 전이면 새로 만든다 — 앞 올리기는 지운다
*/
async function repick(key: number, file: File): Promise<void> {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || item.phase !== 'waiting-file') return
if (!looksLikeSameFile(file, { name: item.name, size: item.size, lastModified: run.lastModified })) {
item.repickMismatch = true
return
}
item.repickMismatch = false
const gen = generation
const expected = run.firstChunkSha256
const firstChunk = file.slice(0, Math.min(file.size, run.chunkSize))
const actual = expected === null ? null : await digest(firstChunk)
const current = itemOf(key)
if (gen !== generation || run.removed || !current || current.phase !== 'waiting-file') return
run.file = file
run.lastModified = file.lastModified
if (expected === null || actual === null || actual !== expected) {
// 이을 것이 없거나 내용이 다르다 — 앞 올리기를 지우고 이 파일로 새로 만든다
const previous = current.uploadId
if (previous) void api.deleteUpload(previous, { silent: true }).catch(() => undefined)
restartFromScratch(current, run)
current.restarted = false
}
current.phase = 'uploading'
resetEta(current, run)
pump()
}
/** ✕ (빼기) — 올리던 것을 멈추고 서버의 올리기 · 파일도 지운다(알림 없이 — 404 는 지운 것으로 본다) */
function remove(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (run) {
run.removed = true
run.controller?.abort()
run.wake?.()
clearScanPoll(run)
}
runtimes.delete(key)
items.value = items.value.filter((candidate) => candidate.key !== key)
if (item?.uploadId && item.phase !== 'missing') {
void api.deleteUpload(item.uploadId, { silent: true }).catch(() => undefined)
}
pump()
}
/** 실시간 알림의 위험 판정 — 그 파일이 이 화면에 있으면 "위험 — 보낼 수 없음" */
function markInfected(fileId: string) {
for (const item of items.value) {
if (item.uploadId !== fileId) continue
if (item.phase === 'ready') item.scanStatus = 'infected'
const run = runtimes.get(item.key)
if (run) clearScanPoll(run)
}
}
/**
* 보내기 · 저장이 MAIL_016 이었다 — 파일마다 상태를 다시 묻고(404 면 없는 파일) 쓸 수 없는 것을 보인다 (계약 4절 "보내기 전 확인").
* 묻지 못한 파일(장애)은 그대로 둔다
*/
async function recheck(): Promise<void> {
const gen = generation
const targets = items.value.filter(
(item) => item.uploadId !== null && item.phase !== 'missing' && item.phase !== 'rejected',
)
await Promise.all(
targets.map(async (target) => {
const uploadId = target.uploadId
if (uploadId === null) return
try {
const upload = await api.getUpload(uploadId, { silent: true })
const item = itemOf(target.key)
const run = runtimes.get(target.key)
if (gen !== generation || !item || !run || item.uploadId !== uploadId) return
if (upload.status === 'ready') {
applyServerState(item, run, upload)
} else if (item.phase === 'ready') {
// 다 올린 줄 알았는데 서버는 올리는 중이라 한다 — 고른 파일이 있으면 잇고, 없으면 다시 골라야 한다
applyServerState(item, run, upload)
item.phase = run.file ? 'uploading' : 'waiting-file'
}
} catch (error) {
const item = itemOf(target.key)
if (gen !== generation || !item || item.uploadId !== uploadId) return
if (isNotFound(toApiError(error))) {
item.phase = 'missing'
item.missingReason = 'unknown'
}
}
}),
)
pump()
}
function abortAll() {
runtimes.forEach((run) => {
run.removed = true
run.controller?.abort()
run.wake?.()
clearScanPoll(run)
})
runtimes.clear()
}
/** 양식을 새로 채웠다 — 올리던 것을 멈춘다 (서버의 올리기는 지우지 않는다 — 저장본이 이어 쓴다) */
function reset() {
generation += 1
abortAll()
items.value = []
}
onScopeDispose(() => {
generation += 1
abortAll()
})
// ── 화면 · 보내기 · 저장이 보는 셈 ─────────────
/** 올리는 중 · 다시 잇는 중인 파일 수(차례를 기다리는 파일 포함) — "파일 N개를 올리는 중입니다", 나가기 확인 */
const activeCount = computed(
() => items.value.filter((item) => item.phase === 'uploading' || item.phase === 'reconnecting').length,
)
const isUploading = computed(() => activeCount.value > 0)
/** 멈춤 · 오래 끊김 · 다시 골라야 이어지는 파일 */
const hasIncomplete = computed(() =>
items.value.some((item) => item.phase === 'paused' || item.phase === 'stalled' || item.phase === 'waiting-file'),
)
/** 지운 · 기간이 지난 · 찾을 수 없는 파일 — 남은 동안 저장하지 않는다 (서버가 MAIL_016 으로 거절한다) */
const hasMissing = computed(() => items.value.some((item) => item.phase === 'missing'))
const hasInfected = computed(() =>
items.value.some((item) => item.phase === 'ready' && item.scanStatus === 'infected'),
)
/** 발송 · 임시 저장의 largeFileIds — 서버 id 가 있는 실을 줄, 겹치지 않게 */
const uploadIds = computed(() => {
const ids: string[] = []
for (const item of carried.value) if (item.uploadId && !ids.includes(item.uploadId)) ids.push(item.uploadId)
return ids
})
/**
* 저장본이 가리키는 파일 — 실을 줄에 없는 파일(지움 · 기간 지남)까지. 쓰기 화면의 "바꿈" 판정이 쓴다:
* 없는 파일을 빼면 바뀐 것이라 다시 저장한다 (남은 동안은 저장하지 않는다)
*/
const trackedIds = computed(() => {
const ids: string[] = []
for (const item of items.value) {
if (item.phase !== 'rejected' && item.uploadId && !ids.includes(item.uploadId)) ids.push(item.uploadId)
}
return ids
})
return {
items,
carried,
usedBytes,
activeCount,
isUploading,
hasIncomplete,
hasMissing,
hasInfected,
uploadIds,
trackedIds,
precheck,
addFiles,
addFromDraft,
pause,
resume,
repick,
remove,
markInfected,
recheck,
reset,
}
}
+145
View File
@@ -0,0 +1,145 @@
import type { AxiosRequestConfig } from 'axios'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import { startBrowserDownload } from './useMail'
import {
LARGE_FILE_CHUNK_CONTENT_TYPE,
LARGE_FILE_CHUNK_TIMEOUT_MS,
LARGE_FILE_DIGEST_HEADER,
LARGE_FILE_REQUEST_TIMEOUT_MS,
} from '@/shared/constants/large-file'
import type {
FileQuota,
FileUploadCreate,
LargeFile,
LargeFileListQuery,
ShareLink,
ShareLinkLookupResult,
} from '@/shared/types/large-file'
import type { PaginatedResult } from '@/shared/types/pagination'
import { contentDigestValue } from '@/shared/utils/large-file'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type LargeFileRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
export interface ChunkUploadOptions {
/** 조각 내용의 SHA-256 (base64) — 있으면 Content-Digest 로 보낸다 (서버가 받은 바이트와 견준다 — 다르면 400 FILE_004) */
sha256: string | null
signal?: AbortSignal
/** 이 조각에서 지금까지 올린 바이트 */
onProgress?: (loaded: number) => void
}
/** 경로에 넣는 id 는 항상 인코딩한다 */
const segment = (value: string) => encodeURIComponent(value)
// 대용량 첨부 API Composable — docs/mail/api-contract.md 4절 "대용량 첨부 — 11단계" (공개 내려받기 밖은 모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 중앙 axios(useApi)를 쓴다 — 401 이면 세션을 한 번 갱신하고 같은 요청을 다시 보낸다(조각도 같은 바이트로), silent 도 그대로 돈다.
// 로그인 없는 공개 화면의 요청은 usePublicShare 가 따로 보낸다 (세션 갱신 · 세션 만료 대화상자를 타지 않는다)
export function useLargeFiles() {
const api = useApi()
// ── 올리기 (계약 4절 "올리기") ─────────────
/** 올리기 만들기 — 형식(400 FILE_001) → 크기(413 FILE_002) → 직원 한도(409 FILE_005) → 서버 공간(507 FILE_006) 차례로 본다 */
function createUpload(body: FileUploadCreate, options?: LargeFileRequestOptions): Promise<LargeFile> {
return api.post<LargeFile, LargeFile>('/file-uploads', body, { timeout: LARGE_FILE_REQUEST_TIMEOUT_MS, ...options })
}
/** 상태 — 끊긴 뒤 무엇을 받았는지 본다. 올리는 중은 마지막 조각 뒤 24시간 지나면 404 */
function getUpload(id: string, options?: LargeFileRequestOptions): Promise<LargeFile> {
return api.get<LargeFile, LargeFile>(`/file-uploads/${segment(id)}`, {
timeout: LARGE_FILE_REQUEST_TIMEOUT_MS,
...options,
})
}
/**
* 조각 하나 — 본문은 그 조각의 바이트 그대로(application/octet-stream). 알림은 끈다 — 실패는 그 파일 줄에 보인다 (계약 4절 "클라이언트").
* 이미 받은 조각을 같은 내용으로 다시 보내면 200, 다른 내용이면 409 FILE_003
*/
function putChunk(id: string, index: number, chunk: Blob, options: ChunkUploadOptions): Promise<LargeFile> {
const headers: Record<string, string> = { 'Content-Type': LARGE_FILE_CHUNK_CONTENT_TYPE }
if (options.sha256) headers[LARGE_FILE_DIGEST_HEADER] = contentDigestValue(options.sha256)
return api.put<LargeFile, LargeFile>(`/file-uploads/${segment(id)}/chunks/${index}`, chunk, {
timeout: LARGE_FILE_CHUNK_TIMEOUT_MS,
silent: true,
signal: options.signal,
headers,
onUploadProgress: (event) => options.onProgress?.(event.loaded),
})
}
/** 끝내기 — 조각이 모두 있으면 ready(검사 차례에 넣는다). 빠진 조각 · 진행 중이면 409 FILE_003, 이미 끝냈으면 그대로 200 */
function completeUpload(id: string, options?: LargeFileRequestOptions): Promise<LargeFile> {
return api.post<LargeFile, LargeFile>(`/file-uploads/${segment(id)}/completion`, null, {
timeout: LARGE_FILE_REQUEST_TIMEOUT_MS,
...options,
})
}
/** 그만두기 · 지우기 — 살아 있는 링크에 실린 파일은 409 FILE_003, 없으면 404 RES_001 */
function deleteUpload(id: string, options?: LargeFileRequestOptions): Promise<void> {
return api.delete<void, void>(`/file-uploads/${segment(id)}`, options)
}
/** 보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 포함), 만든 때 최근부터, total 포함 */
function listUploads(query: LargeFileListQuery, options?: LargeFileRequestOptions): Promise<PaginatedResult<LargeFile>> {
return api.get<PaginatedResult<LargeFile>, PaginatedResult<LargeFile>>('/file-uploads', { ...options, params: query })
}
/** 보관 사용량 · 한도 */
function getFileQuota(options?: LargeFileRequestOptions): Promise<FileQuota> {
return api.get<FileQuota, FileQuota>('/file-quota', options)
}
// ── 보낸 사람 관리 (계약 4절 "보낸 사람 관리") ─────────────
/** 내가 만든 링크 — 보낸 때 최근부터, total 포함 (끝나거나 끊긴 뒤 90일까지 나온다) */
function listShareLinks(query: LargeFileListQuery, options?: LargeFileRequestOptions): Promise<PaginatedResult<ShareLink>> {
return api.get<PaginatedResult<ShareLink>, PaginatedResult<ShareLink>>('/share-links', { ...options, params: query })
}
/** 링크 끊기 — 그 링크(메일)의 파일 모두. 되돌릴 수 없다. 이미 끊었거나 기간이 지났으면 그대로 200 */
function revokeShareLink(id: string, options?: LargeFileRequestOptions): Promise<ShareLink> {
return api.post<ShareLink, ShareLink>(`/share-links/${segment(id)}/revocation`, null, options)
}
/** 인용 속 링크의 상태 — 주소(토큰)를 쿼리에 싣지 않으려고 본문으로 보낸다 (1~20개 · 300자) */
function lookupShareLinks(urls: readonly string[], options?: LargeFileRequestOptions): Promise<ShareLinkLookupResult> {
return api.post<ShareLinkLookupResult, ShareLinkLookupResult>('/share-link-lookups', { urls }, options)
}
// ── 우리 웹메일에서 받기 (계약 4절 "우리 웹메일에서 읽기") ─────────────
/** 메일 속 대용량 첨부 주소 — 첨부처럼 <a href> 로 받는다 (표준 래퍼 없음) */
function messageLargeFileUrl(messageId: string, fileId: string): string {
return buildApiUrl(`/messages/${segment(messageId)}/large-files/${segment(fileId)}`)
}
/**
* 메일 속 대용량 첨부를 내려받는다 — 이동하기 전에 세션을 확인한다 (일반 첨부와 같다 — confirmSession).
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자 · 알림)는 이미 끝났다
*/
async function downloadMessageLargeFile(messageId: string, fileId: string): Promise<boolean> {
if (!(await confirmSession())) return false
startBrowserDownload(messageLargeFileUrl(messageId, fileId))
return true
}
return {
createUpload,
getUpload,
putChunk,
completeUpload,
deleteUpload,
listUploads,
getFileQuota,
listShareLinks,
revokeShareLink,
lookupShareLinks,
messageLargeFileUrl,
downloadMessageLargeFile,
}
}
+2 -1
View File
@@ -98,8 +98,9 @@ const segment = (value: string) => encodeURIComponent(value)
/**
* 화면을 떠나지 않고 브라우저 다운로드를 시작한다 — 임시 <a download> 를 눌러 원래 링크와 똑같이 받는다.
* (location.assign 으로 이동하면 서버가 오류 본문(JSON)을 줄 때 화면이 그 본문으로 바뀐다)
* 대용량 첨부 받기(useLargeFiles · 공개 내려받기 화면)도 같은 방법으로 받는다
*/
function startBrowserDownload(url: string): void {
export function startBrowserDownload(url: string): void {
const anchor = document.createElement('a')
anchor.href = url
// 값을 비워 두면 파일 이름은 서버의 Content-Disposition 을 따른다
+15 -1
View File
@@ -13,7 +13,7 @@ import {
MAIL_EVENTS_VISIBILITY_WAIT_MS,
} from '@/shared/constants/mail-notify'
import { supportsMailRealtime } from '@/shared/utils/browser-support'
import { parseMailEvent, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
import { parseMailEvent, type LargeFileSignal, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
import {
mailEventsLockName,
packRealtimeMessage,
@@ -51,6 +51,8 @@ import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-rea
// - 75초 동안 아무것도 오지 않으면(머리글을 기다리는 동안 포함) 끊긴 것으로 보고 연결을 끊은 뒤 위처럼 다시 잇는다
// - new-mail 은 연결한 탭만 받는다 — onNewMail 이 바탕화면 알림을 정한다(사용자가 보고 있는 탭이 있는지 다른 탭에 묻는다)
// - 사용자가 어느 웹메일 탭이든 보게 되면(보이고 초점이 있음) 연결한 탭에서 onUserReturn — 띄운 바탕화면 알림을 닫는다
// - large-file(올린 대용량 파일의 위험 판정 — 11단계)은 연결한 탭이 받아 다른 탭에 나누고, 모든 탭이 onLargeFile 을 부른다
// (탭마다 알림 · 그 탭의 쓰기 화면이 그 파일을 "위험 — 보낼 수 없음"으로 바꾼다)
//
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
@@ -68,6 +70,8 @@ export interface MailRealtimeOptions {
onNewMail?: (signal: NewMailSignal, context: NewMailContext) => unknown
/** 사용자가 웹메일 탭(어느 탭이든)을 보게 됐다 — 연결한 탭에서 부른다 (띄운 바탕화면 알림을 닫는다) */
onUserReturn?: () => void
/** 올린 대용량 파일의 판정(위험) — 모든 탭에서 부른다 (11단계 — 알림 · 쓰기 화면) */
onLargeFile?: (signal: LargeFileSignal) => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
/** 다시 잇는 간격의 흔들림 (0~1) — 테스트는 고정값을 넘긴다 */
@@ -254,6 +258,10 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
case 'attended':
if (leader) options.onUserReturn?.()
break
case 'large-file':
// 연결한 탭은 자기 연결에서 이미 받았다 (채널은 보낸 탭에 돌아오지 않는다)
if (!leader) options.onLargeFile?.({ fileId: message.fileId, name: message.name, scanStatus: message.scanStatus })
break
}
}
@@ -312,6 +320,12 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
.then(() => options.onNewMail?.({ count: event.count, messages: event.messages }, newMailContext))
.catch(() => undefined)
break
case 'large-file': {
const signal: LargeFileSignal = { fileId: event.fileId, name: event.name, scanStatus: event.scanStatus }
post({ type: 'large-file', ...signal })
options.onLargeFile?.(signal)
break
}
}
}
@@ -0,0 +1,91 @@
import { buildApiUrl, type StandardResponse } from './useApi'
import { startBrowserDownload } from './useMail'
import { SHARE_TOKEN_PATTERN } from '@/shared/constants/large-file'
import type { PublicLink } from '@/shared/types/large-file'
// 공개 내려받기 화면의 요청 — 계약 4절 "공개 내려받기 (로그인 없음)"
// 로그인 · 쿠키를 쓰지 않는다: 중앙 axios(세션 갱신 · 세션 만료 대화상자 · 알림)를 거치지 않고 fetch 를 쿠키 없이(credentials: omit) 부른다.
// 받는 쪽은 바깥 거래처의 브라우저다 — 실패는 화면이 상태로 그린다(알림 없음). 파일은 <a href> 이동으로 받는다(큰 파일을 fetch 로 받으면 메모리에 다 오른다).
export type PublicShareFetch = (input: string, init: RequestInit) => Promise<Response>
/** 링크 정보 결과 — 받음 · 없는 링크(404) · 요청이 너무 많음(429) · 그 밖의 실패(네트워크 · 5xx · 모양이 틀림) */
export type PublicLinkResult =
| { kind: 'ok'; link: PublicLink }
| { kind: 'not-found' }
| { kind: 'too-many' }
| { kind: 'error' }
export interface PublicShareOptions {
/** 테스트는 가짜 서버를 넘긴다 */
fetch?: PublicShareFetch
}
const HTTP_NOT_FOUND = 404
const HTTP_TOO_MANY_REQUESTS = 429
const LINK_STATUSES: readonly string[] = ['active', 'expired', 'revoked']
const defaultFetch: PublicShareFetch = (input, init) => fetch(input, init)
/** 응답이 화면이 그릴 수 있는 모양인가 — 앞단이 다른 것을 돌려줬을 때(HTML 등) 그리다 깨지지 않게 */
function isPublicLink(value: unknown): value is PublicLink {
if (typeof value !== 'object' || value === null) return false
const link = value as Record<string, unknown>
const sender = link.sender as Record<string, unknown> | null | undefined
return (
typeof link.status === 'string' &&
LINK_STATUSES.includes(link.status) &&
typeof sender === 'object' &&
sender !== null &&
typeof sender.email === 'string' &&
typeof link.expiresAt === 'string' &&
Array.isArray(link.files) &&
typeof link.reportAddress === 'string'
)
}
/** 경로의 토큰 — 늘 인코딩한다 (모양 검사를 지난 값이라 바뀌지 않는다) */
const segment = (value: string) => encodeURIComponent(value)
export function usePublicShare(options: PublicShareOptions = {}) {
const fetchLink = options.fetch ?? defaultFetch
/** 링크 정보 — 토큰 모양이 아니면 묻지 않고 없는 링크다 (서버도 그렇게 답한다) */
async function getPublicLink(token: string): Promise<PublicLinkResult> {
if (!SHARE_TOKEN_PATTERN.test(token)) return { kind: 'not-found' }
let response: Response
try {
response = await fetchLink(buildApiUrl(`/public-links/${segment(token)}`), {
method: 'GET',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: { Accept: 'application/json' },
})
} catch {
return { kind: 'error' }
}
if (response.status === HTTP_NOT_FOUND) return { kind: 'not-found' }
if (response.status === HTTP_TOO_MANY_REQUESTS) return { kind: 'too-many' }
if (!response.ok) return { kind: 'error' }
try {
const body = (await response.json()) as StandardResponse<unknown> | null
const link = body?.success ? body.data : null
return isPublicLink(link) ? { kind: 'ok', link } : { kind: 'error' }
} catch {
return { kind: 'error' }
}
}
/** 파일 주소 — 받기 단추의 href (링크 주소 복사 같은 브라우저 기본 기능을 지킨다) */
function publicFileUrl(token: string, fileId: string): string {
return buildApiUrl(`/public-links/${segment(token)}/files/${segment(fileId)}`)
}
/** 받기 — 늘 저장이다(서버가 octet-stream + attachment). 끊기면 브라우저가 범위로 이어 받는다 */
function downloadPublicFile(token: string, fileId: string): void {
startBrowserDownload(publicFileUrl(token, fileId))
}
return { getPublicLink, publicFileUrl, downloadPublicFile }
}