generated from ttipo/project-base
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 을 따른다
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user