Selaa lähdekoodia

fix: handle public upload session renewal

Developer 3 päivää sitten
vanhempi
commit
bf852e2ec2

+ 81 - 10
src/views/pages/team-background-upload.vue

@@ -93,6 +93,48 @@ import {
 } from '@/api/teamBackgroundFile'
 import { ACCEPT, validateBackgroundFile } from '@/utils/teamBackgroundFile'
 
+const BACKEND_EXPIRY_PATTERN = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
+const ISO_EXPIRY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/
+const GMT_8_OFFSET_MS = 8 * 60 * 60 * 1000
+
+function utcComponentsTimestamp(match) {
+  const year = Number(match[1])
+  const month = Number(match[2])
+  const day = Number(match[3])
+  const hour = Number(match[4])
+  const minute = Number(match[5])
+  const second = Number(match[6])
+  const milliseconds = match[7] ? Number(`0.${match[7]}`) * 1000 : 0
+  const timestamp = Date.UTC(year, month - 1, day, hour, minute, second, milliseconds)
+  const date = new Date(timestamp)
+  if (date.getUTCFullYear() !== year ||
+    date.getUTCMonth() !== month - 1 ||
+    date.getUTCDate() !== day ||
+    date.getUTCHours() !== hour ||
+    date.getUTCMinutes() !== minute ||
+    date.getUTCSeconds() !== second) return NaN
+  return timestamp
+}
+
+export function parseUploadExpiry(value) {
+  if (typeof value !== 'string') return NaN
+  const expiry = value.trim()
+  const backendMatch = BACKEND_EXPIRY_PATTERN.exec(expiry)
+  if (backendMatch) {
+    const timestamp = utcComponentsTimestamp(backendMatch)
+    return Number.isFinite(timestamp) ? timestamp - GMT_8_OFFSET_MS : NaN
+  }
+  const isoMatch = ISO_EXPIRY_PATTERN.exec(expiry)
+  if (!isoMatch || !Number.isFinite(utcComponentsTimestamp(isoMatch))) return NaN
+  if (isoMatch[8] !== 'Z') {
+    const offsetHour = Number(isoMatch[8].slice(1, 3))
+    const offsetMinute = Number(isoMatch[8].slice(4, 6))
+    if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) return NaN
+  }
+  const timestamp = Date.parse(expiry)
+  return Number.isFinite(timestamp) ? timestamp : NaN
+}
+
 export default {
   name: 'TeamBackgroundUpload',
   components: { TeamBackgroundUploadQueue },
@@ -118,11 +160,15 @@ export default {
       return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
     },
     uploadDisabled() {
-      return this.state !== 'ready' || this.completing
+      return this.state !== 'ready' || this.completing || this.hasActiveUpload()
     },
     completeDisabled() {
-      return this.uploadDisabled ||
-        this.queue.some(item => item.status === 'uploading')
+      return this.uploadDisabled
+    }
+  },
+  watch: {
+    '$route.query.token'(token) {
+      return this.resetForRouteToken(token)
     }
   },
   created() {
@@ -135,7 +181,20 @@ export default {
   methods: {
     initialize() {
       const token = this.$route && this.$route.query && this.$route.query.token
-      if (token) return this.loadFromToken(String(token))
+      return this.resetForRouteToken(token)
+    },
+    resetForRouteToken(token) {
+      this.invalidateAsyncWork()
+      this.clearCountdown()
+      this.session = null
+      this.queue = []
+      this.successCount = 0
+      this.code = ''
+      this.remainingSeconds = 0
+      this.completing = false
+      if (this.$refs && this.$refs.fileInput) this.$refs.fileInput.value = ''
+      const normalizedToken = token === null || token === undefined ? '' : String(token).trim()
+      if (normalizedToken) return this.loadFromToken(normalizedToken)
       this.state = 'code'
       return Promise.resolve()
     },
@@ -196,7 +255,7 @@ export default {
     },
     updateRemainingSeconds() {
       if (this.state !== 'ready' || !this.session) return
-      const expiresAt = new Date(this.session.expiresAt).getTime()
+      const expiresAt = parseUploadExpiry(this.session.expiresAt)
       this.remainingSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000))
       if (!Number.isFinite(expiresAt) || this.remainingSeconds === 0) this.expireSession()
     },
@@ -226,23 +285,36 @@ export default {
         error: ''
       }))
     },
+    hasActiveUpload() {
+      return this.queue.some(item => item.status === 'uploading')
+    },
     async handleFileChange(event) {
       const selectedFiles = Array.from((event.target && event.target.files) || [])
       if (event.target) event.target.value = ''
       if (this.state !== 'ready' || this.completing || !selectedFiles.length) return
+      if (this.hasActiveUpload()) {
+        this.$message.error('文件正在上传,请稍后再试')
+        return
+      }
       if (selectedFiles.length > 10) {
         this.$message.error('一次最多选择 10 个文件')
         return
       }
       const items = this.createQueueItems(selectedFiles)
+      const batchGeneration = this.asyncGeneration
+      const batchContext = this.sessionContext()
       this.queue = this.queue.concat(items)
       for (const item of items) {
-        if (this.state !== 'ready' || this.completing) break
+        if (batchGeneration !== this.asyncGeneration ||
+          !this.isSessionContextCurrent(batchContext) ||
+          this.completing) break
         await this.uploadOne(item)
+        if (batchGeneration !== this.asyncGeneration ||
+          !this.isSessionContextCurrent(batchContext)) break
       }
     },
     async uploadOne(item) {
-      if (this.state !== 'ready' || this.completing || !this.session) return
+      if (this.state !== 'ready' || this.completing || !this.session || this.hasActiveUpload()) return
       const validationError = validateBackgroundFile(item.file)
       if (validationError) {
         item.status = 'failed'
@@ -272,7 +344,7 @@ export default {
       }
     },
     retryUpload(item) {
-      if (this.state !== 'ready' || this.completing) return Promise.resolve()
+      if (this.state !== 'ready' || this.completing || this.hasActiveUpload()) return Promise.resolve()
       return this.uploadOne(item)
     },
     removeQueueItem(item) {
@@ -281,8 +353,7 @@ export default {
       if (index >= 0) this.queue.splice(index, 1)
     },
     async completeUpload() {
-      if (this.state !== 'ready' || this.completing || !this.session ||
-        this.queue.some(item => item.status === 'uploading')) return
+      if (this.state !== 'ready' || this.completing || !this.session || this.hasActiveUpload()) return
       const context = this.sessionContext()
       this.completing = true
       try {

+ 291 - 2
tests/unit/teamBackgroundPublicUpload.spec.js

@@ -92,14 +92,24 @@ function expectPage(source) {
   return source !== ''
 }
 
-function loadPageOptions(api) {
+function loadPageModule(api) {
   const source = readIfPresent(pagePath)
   const descriptor = parseComponent(source)
   return evaluateModule(descriptor.script.content, {
     '@/api/teamBackgroundFile': api,
     '@/utils/teamBackgroundFile': { ACCEPT, formatFileSize, validateBackgroundFile },
     '@/components/team-background/TeamBackgroundUploadQueue.vue': {}
-  }).default
+  })
+}
+
+function loadPageOptions(api) {
+  return loadPageModule(api).default
+}
+
+function loadExpiryParser(api) {
+  const parser = loadPageModule(api).parseUploadExpiry
+  expect(parser).toEqual(expect.any(Function))
+  return parser
 }
 
 function createPageVm(api, overrides = {}) {
@@ -120,6 +130,14 @@ function computed(vm, name) {
   return vm.$componentOptions.computed[name].call(vm)
 }
 
+function invokeWatcher(vm, name, value, oldValue) {
+  const watcher = vm.$componentOptions.watch && vm.$componentOptions.watch[name]
+  expect(watcher).toBeDefined()
+  if (!watcher) return
+  const handler = typeof watcher === 'function' ? watcher : watcher.handler
+  return handler.call(vm, value, oldValue)
+}
+
 describe('public team background upload route', () => {
   test('is a page route outside the authenticated layout and short-circuits before menu requests', () => {
     const { default: router, pageRoutes, moduleRoutes, http } = loadRouter()
@@ -158,6 +176,59 @@ describe('public team background upload page state machine', () => {
     jest.useRealTimers()
   })
 
+  test('parses the backend timestamp as GMT+8 at the countdown boundary', () => {
+    if (!expectPage(source)) return
+    const parseUploadExpiry = loadExpiryParser(api)
+    if (typeof parseUploadExpiry !== 'function') return
+
+    expect(parseUploadExpiry('2026-07-21 10:00:02')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2))
+
+    jest.useFakeTimers()
+    jest.setSystemTime(new Date('2026-07-21T02:00:00.000Z'))
+    const vm = createPageVm(api)
+    vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21 10:00:02' })
+
+    expect(vm.remainingSeconds).toBe(2)
+    jest.advanceTimersByTime(2000)
+    expect(vm.state).toBe('expired')
+  })
+
+  test('retains valid ISO timestamps with explicit offsets', () => {
+    if (!expectPage(source)) return
+    const parseUploadExpiry = loadExpiryParser(api)
+    if (typeof parseUploadExpiry !== 'function') return
+
+    expect(parseUploadExpiry('2026-07-21T02:00:02.500Z')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2, 500))
+    expect(parseUploadExpiry('2026-07-21T10:00:02+08:00')).toBe(Date.UTC(2026, 6, 21, 2, 0, 2))
+  })
+
+  test('rejects malformed, timezone-free and impossible expiry values', () => {
+    if (!expectPage(source)) return
+    const parseUploadExpiry = loadExpiryParser(api)
+    if (typeof parseUploadExpiry !== 'function') return
+
+    for (const invalid of [
+      '',
+      '2026-02-29 10:00:00',
+      '2026-13-01 10:00:00',
+      '2026-07-21 24:00:00',
+      '2026-02-30T10:00:00Z',
+      '2026-07-21T10:00:00',
+      'not-a-date'
+    ]) {
+      expect(Number.isNaN(parseUploadExpiry(invalid))).toBe(true)
+    }
+  })
+
+  test('preserves valid leap days and one-second calendar rollover', () => {
+    if (!expectPage(source)) return
+    const parseUploadExpiry = loadExpiryParser(api)
+    if (typeof parseUploadExpiry !== 'function') return
+
+    expect(parseUploadExpiry('2028-02-29 08:00:00')).toBe(Date.UTC(2028, 1, 29, 0, 0, 0))
+    expect(parseUploadExpiry('2027-01-01 00:00:00') - parseUploadExpiry('2026-12-31 23:59:59')).toBe(1000)
+  })
+
   test('auto-validates a token and ignores its deferred result after expiry', async () => {
     if (!expectPage(source)) return
     const request = deferred()
@@ -176,6 +247,183 @@ describe('public team background upload page state machine', () => {
     expect(vm.session).toBeNull()
   })
 
+  test('renews an expired component for a changed token and ignores the old verification', async () => {
+    if (!expectPage(source)) return
+    const oldRequest = deferred()
+    const newRequest = deferred()
+    api.getPublicUploadSession
+      .mockReturnValueOnce(oldRequest.promise)
+      .mockReturnValueOnce(newRequest.promise)
+    const vm = createPageVm(api, { $route: { query: { token: 'old-token' } } })
+
+    const initial = vm.$componentOptions.created.call(vm)
+    expect(api.getPublicUploadSession).toHaveBeenCalledTimes(1)
+    vm.expireSession()
+    vm.$route.query.token = ' new-token '
+    const renewal = invokeWatcher(vm, '$route.query.token', ' new-token ', 'old-token')
+    if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
+
+    expect(api.getPublicUploadSession.mock.calls).toEqual([['old-token'], ['new-token']])
+    expect(vm.state).toBe('verifying')
+    expect(vm.session).toBeNull()
+
+    oldRequest.resolve({ code: 0, data: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' } })
+    await initial
+    expect(vm.state).toBe('verifying')
+
+    newRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
+    await renewal
+    expect(vm.state).toBe('ready')
+    expect(vm.session.uploadToken).toBe('new-upload-token')
+    vm.$componentOptions.beforeDestroy.call(vm)
+  })
+
+  test('resets a completed component to code entry for a blank token without duplicate initial verification', async () => {
+    if (!expectPage(source)) return
+    jest.useFakeTimers()
+    const vm = createPageVm(api, {
+      $route: { query: { token: 'finished-token' } },
+      state: 'completed',
+      session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
+      queue: [{ id: 'old', status: 'success' }],
+      successCount: 1,
+      code: '123456',
+      completing: true,
+      remainingSeconds: 30,
+      countdownTimer: setInterval(() => {}, 1000)
+    })
+    const generation = vm.asyncGeneration
+    const requestId = vm.verificationRequestId
+
+    vm.$route.query.token = '   '
+    const resetting = invokeWatcher(vm, '$route.query.token', '   ', 'finished-token')
+    if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
+    await resetting
+
+    expect(vm).toMatchObject({
+      state: 'code',
+      session: null,
+      queue: [],
+      successCount: 0,
+      code: '',
+      completing: false,
+      remainingSeconds: 0,
+      countdownTimer: null
+    })
+    expect(vm.asyncGeneration).toBeGreaterThan(generation)
+    expect(vm.verificationRequestId).toBeGreaterThan(requestId)
+    expect(jest.getTimerCount()).toBe(0)
+    expect(api.getPublicUploadSession).not.toHaveBeenCalled()
+  })
+
+  test('route renewal from ready ignores old upload progress and result', async () => {
+    if (!expectPage(source)) return
+    const uploadRequest = deferred()
+    const renewalRequest = deferred()
+    let oldProgress
+    api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, onProgress) => {
+      oldProgress = onProgress
+      return uploadRequest.promise
+    })
+    api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
+    const item = { id: 'old', file: { name: 'old.pdf', size: 1 }, name: 'old.pdf', progress: 0, status: 'pending', error: '' }
+    const vm = createPageVm(api, {
+      $route: { query: { token: 'old-link-token' } },
+      state: 'ready',
+      session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
+      queue: [item],
+      code: '123456',
+      successCount: 2
+    })
+
+    const uploading = vm.uploadOne(item)
+    expect(item.status).toBe('uploading')
+    vm.$route.query.token = 'new-link-token'
+    const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
+    if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
+
+    expect(vm).toMatchObject({ state: 'verifying', session: null, queue: [], code: '', successCount: 0 })
+    oldProgress({ loaded: 4, total: 5 })
+    uploadRequest.resolve({ code: 0, data: {} })
+    await uploading
+    expect(item).toMatchObject({ status: 'uploading', progress: 0 })
+    expect(vm.state).toBe('verifying')
+
+    renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
+    await renewal
+    expect(vm.state).toBe('ready')
+    expect(vm.session.uploadToken).toBe('new-upload-token')
+    vm.$componentOptions.beforeDestroy.call(vm)
+  })
+
+  test('route renewal cannot resume remaining files from an old batch with the new token', async () => {
+    if (!expectPage(source)) return
+    const oldFirstUpload = deferred()
+    const renewalRequest = deferred()
+    api.uploadPublicTeamBackgroundFile
+      .mockReturnValueOnce(oldFirstUpload.promise)
+      .mockResolvedValue({ code: 0, data: {} })
+    api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
+    const vm = createPageVm(api, {
+      $route: { query: { token: 'old-link-token' } },
+      state: 'ready',
+      session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' }
+    })
+
+    const oldBatch = vm.handleFileChange({
+      target: { files: [{ name: 'old-one.pdf', size: 1 }, { name: 'old-two.pdf', size: 1 }] }
+    })
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledWith('old-upload-token', expect.objectContaining({ name: 'old-one.pdf' }), expect.any(Function))
+
+    vm.$route.query.token = 'new-link-token'
+    const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
+    renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
+    await renewal
+    expect(vm.state).toBe('ready')
+    expect(vm.session.uploadToken).toBe('new-upload-token')
+
+    oldFirstUpload.resolve({ code: 0, data: {} })
+    await oldBatch
+
+    vm.$componentOptions.beforeDestroy.call(vm)
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
+    expect(vm.queue).toEqual([])
+    expect(vm.session.uploadToken).toBe('new-upload-token')
+  })
+
+  test('route renewal from ready ignores an old deferred completion', async () => {
+    if (!expectPage(source)) return
+    const completionRequest = deferred()
+    const renewalRequest = deferred()
+    api.completePublicUpload.mockReturnValue(completionRequest.promise)
+    api.getPublicUploadSession.mockReturnValue(renewalRequest.promise)
+    const vm = createPageVm(api, {
+      $route: { query: { token: 'old-link-token' } },
+      state: 'ready',
+      session: { teamName: 'Old', uploadToken: 'old-upload-token', expiresAt: '2099-01-01 08:00:00' },
+      queue: [{ id: 'old', status: 'success' }]
+    })
+
+    const completing = vm.completeUpload()
+    expect(vm.completing).toBe(true)
+    vm.$route.query.token = 'new-link-token'
+    const renewal = invokeWatcher(vm, '$route.query.token', 'new-link-token', 'old-link-token')
+    if (!vm.$componentOptions.watch || !vm.$componentOptions.watch['$route.query.token']) return
+
+    expect(vm).toMatchObject({ state: 'verifying', session: null, queue: [], completing: false })
+    completionRequest.resolve({ code: 0 })
+    await completing
+    expect(vm.state).toBe('verifying')
+    expect(vm.successCount).toBe(0)
+
+    renewalRequest.resolve({ code: 0, data: { teamName: 'New', uploadToken: 'new-upload-token', expiresAt: '2099-01-01 08:00:00' } })
+    await renewal
+    expect(vm.state).toBe('ready')
+    expect(vm.session.uploadToken).toBe('new-upload-token')
+    vm.$componentOptions.beforeDestroy.call(vm)
+  })
+
   test('shows code entry without a token and accepts exactly six ASCII digits', async () => {
     if (!expectPage(source)) return
     const request = deferred()
@@ -299,6 +547,47 @@ describe('public team background upload page state machine', () => {
     expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
   })
 
+  test('rejects overlapping batches, retry and completion until the active upload settles', async () => {
+    if (!expectPage(source)) return
+    const activeRequest = deferred()
+    api.uploadPublicTeamBackgroundFile
+      .mockReturnValueOnce(activeRequest.promise)
+      .mockResolvedValue({ code: 0, data: {} })
+    api.completePublicUpload.mockResolvedValue({ code: 0 })
+    const failed = { id: 'failed', file: { name: 'retry.pdf', size: 1 }, name: 'retry.pdf', progress: 0, status: 'failed', error: '网络失败' }
+    const vm = createPageVm(api, {
+      state: 'ready',
+      session: { uploadToken: 'upload-token' },
+      queue: [failed]
+    })
+    const firstEvent = { target: { value: 'first', files: [{ name: 'first.pdf', size: 1 }] } }
+
+    const firstBatch = vm.handleFileChange(firstEvent)
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
+    expect(vm.queue[1]).toMatchObject({ name: 'first.pdf', status: 'uploading' })
+    expect(computed(vm, 'uploadDisabled')).toBe(true)
+
+    const secondEvent = { target: { value: 'second', files: [{ name: 'second.pdf', size: 1 }] } }
+    await vm.handleFileChange(secondEvent)
+    await vm.retryUpload(failed)
+    await vm.completeUpload()
+
+    expect(secondEvent.target.value).toBe('')
+    expect(vm.queue).toHaveLength(2)
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
+    expect(api.completePublicUpload).not.toHaveBeenCalled()
+    expect(failed.status).toBe('failed')
+    expect(vm.$message.error).toHaveBeenCalledWith('文件正在上传,请稍后再试')
+
+    activeRequest.resolve({ code: 0, data: {} })
+    await firstBatch
+    expect(computed(vm, 'uploadDisabled')).toBe(false)
+
+    await vm.retryUpload(failed)
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(2)
+    expect(failed).toMatchObject({ status: 'success', progress: 100, error: '' })
+  })
+
   test('a session-level upload failure expires the page and stops later files in the batch', async () => {
     if (!expectPage(source)) return
     api.uploadPublicTeamBackgroundFile.mockRejectedValue({ msg: '上传会话已过期' })