浏览代码

fix: settle team workflow retries once

Developer 3 天之前
父节点
当前提交
59c5da0abf

+ 22 - 12
components/CusTeamBackgroundFiles/index.vue

@@ -107,7 +107,7 @@ export default {
 			pcUploadDeferred: false,
 			deferredPcResolve: null,
 			deferredPcReject: null,
-			deferredPcPromise: null,
+			deferredPcWaitPromise: null,
 			files: [],
 			count: this.initialCount || 0,
 			filesReady: false,
@@ -513,23 +513,33 @@ export default {
 		hasDeferredPcUpload() {
 			return this.pcUploadDeferred
 		},
-		async waitForDeferredPcUpload(teamId) {
-			if (this.deferredPcPromise) return this.deferredPcPromise
-			const session = await this.openPcSession(teamId)
-			if (!session || this.componentDestroyed) return
-			this.deferredPcPromise = new Promise((resolve, reject) => {
+		waitForDeferredPcUpload(teamId) {
+			if (this.deferredPcWaitPromise) return this.deferredPcWaitPromise
+			const waiter = new Promise((resolve, reject) => {
 				this.deferredPcResolve = resolve
 				this.deferredPcReject = reject
 			})
-			this.pcUploadDeferred = false
-			return this.deferredPcPromise
+			this.deferredPcWaitPromise = waiter
+			this.openPcSession(teamId).then(session => {
+				if (this.deferredPcWaitPromise !== waiter) return
+				if (!session || this.componentDestroyed) {
+					this.settleDeferredPcUpload(new Error('上传会话已失效'))
+					return
+				}
+				this.pcUploadDeferred = false
+			}, error => {
+				if (this.deferredPcWaitPromise === waiter) this.settleDeferredPcUpload(error)
+			})
+			return waiter
 		},
 		settleDeferredPcUpload(error) {
-			if (error && this.deferredPcReject) this.deferredPcReject(error)
-			else if (this.deferredPcResolve) this.deferredPcResolve()
+			const resolve = this.deferredPcResolve
+			const reject = this.deferredPcReject
 			this.deferredPcResolve = null
 			this.deferredPcReject = null
-			this.deferredPcPromise = null
+			this.deferredPcWaitPromise = null
+			if (error && reject) reject(error)
+			else if (resolve) resolve()
 		},
 		resolveDeferredPcUpload() {
 			this.settleDeferredPcUpload()
@@ -544,7 +554,7 @@ export default {
 		},
 		async finishDeferredPcUpload() {
 			const teamId = this.activeTeamId()
-			const hasDeferredWaiter = Boolean(this.deferredPcPromise)
+			const hasDeferredWaiter = Boolean(this.deferredPcWaitPromise)
 			this.closePcDialog()
 			this.pcUploadDeferred = false
 			try {

+ 14 - 8
pagesMy/teamEdit.vue

@@ -15,9 +15,11 @@
 	</view>
 </template>
 
-<script>
-	import CusTeamInfoFill from '@/components/CusTeamInfoFill/index.vue'
-	export default {
+<script>
+	import CusTeamInfoFill from '@/components/CusTeamInfoFill/index.vue'
+	import teamBackgroundRules from '@/utils/teamBackgroundFile.js'
+	const { createTeamPayloadSnapshot, createTeamSubmitPayload } = teamBackgroundRules
+	export default {
 		components:{ CusTeamInfoFill },
 		data(){
 			return {
@@ -30,6 +32,7 @@
 				show:false,
 				saving:false,
 				saveSucceeded:false,
+				savedPayloadSnapshot:'',
 				saveStage:'idle',
 				navigationTimer:null,
 				navigationReject:null,
@@ -91,21 +94,24 @@
 				})
 			},
 			handleConfirm(data){
-				this.submitDto = data;
-				this.show = true;
-			},
+				this.submitDto = createTeamSubmitPayload(data);
+				this.show = true;
+			},
 			async editConfirm(){
 				if(this.saving) return
 				this.saving = true
+				const payload = createTeamSubmitPayload(this.submitDto)
+				const payloadSnapshot = createTeamPayloadSnapshot(payload)
 				try {
-					if(!this.saveSucceeded) {
+					if(!this.saveSucceeded || this.savedPayloadSnapshot !== payloadSnapshot) {
 						this.saveStage = 'save'
-						const { data:res } = await this.$api.put('/core/user/team',this.submitDto)
+						const { data:res } = await this.$api.put('/core/user/team',payload)
 						if(res.code!==0){
 							this.$showToast(res.msg)
 							return
 						}
 						this.saveSucceeded = true
+						this.savedPayloadSnapshot = payloadSnapshot
 						this.show = false
 						this.$showToast('编辑成功')
 					}

+ 17 - 13
pagesPublish/fillTeamInfo.vue

@@ -56,7 +56,10 @@
 				const payloadSnapshot = createTeamPayloadSnapshot(payload)
 				try {
 					let teamId = this.creationState.createdTeamId
-					const resumeNavigation = this.creationState.stage === 'navigate'
+					const resumeStage = this.creationState.stage === 'update'
+						? this.creationState.resumeStage
+						: this.creationState.stage
+					const resumeNavigation = resumeStage === 'navigate'
 					if(!teamId){
 						setTeamSubmissionStage(this.creationState, 'save')
 						const { data:res } = await this.$api.post('/core/user/team',payload)
@@ -72,19 +75,19 @@
 						rememberCreatedTeam(this.creationState, teamId)
 						this.creationState.teamPayloadSnapshot = payloadSnapshot
 					} else if(this.creationState.teamPayloadSnapshot !== payloadSnapshot) {
-						setTeamSubmissionStage(this.creationState, 'save')
-						let res
-						try {
-							const response = await this.$api.put('/core/user/team', Object.assign({}, payload, {
-								id:teamId
-							}))
-							res = response.data
-							if(res.code!==0) throw new Error(res.msg || '团队更新请求失败')
-						} catch(error) {
-							if(resumeNavigation) setTeamSubmissionStage(this.creationState, 'navigate')
-							throw error
-						}
+						this.creationState.resumeStage = resumeStage
+						setTeamSubmissionStage(this.creationState, 'update')
+						const response = await this.$api.put('/core/user/team', Object.assign({}, payload, {
+							id:teamId
+						}))
+						const res = response.data
+						if(res.code!==0) throw new Error(res.msg || '团队更新请求失败')
 						this.creationState.teamPayloadSnapshot = payloadSnapshot
+						setTeamSubmissionStage(this.creationState, resumeStage)
+						this.creationState.resumeStage = ''
+					} else if(this.creationState.stage === 'update') {
+						setTeamSubmissionStage(this.creationState, resumeStage)
+						this.creationState.resumeStage = ''
 					}
 
 					if(resumeNavigation) {
@@ -123,6 +126,7 @@
 				if(this.pageUnloaded) return
 				const prefix = {
 					save:'团队保存失败',
+					update:'团队更新失败',
 					upload:'背景资料处理失败',
 					pcSession:'电脑上传流程失败',
 					publish:'问卷发布失败',

+ 105 - 6
tests/teamBackgroundFile.test.js

@@ -96,7 +96,8 @@ assert.deepEqual(creationState, {
 	completed: false,
 	teamPayloadSnapshot: '',
 	teamQuestionnaireId: '',
-	publishResult: null
+	publishResult: null,
+	resumeStage: ''
 })
 const firstPayloadSnapshot = rules.createTeamPayloadSnapshot({
 	teamName: '团队', enterpriseWebsite: ' https://example.com ', coachId: 7
@@ -669,9 +670,17 @@ const teamEditPagePath = path.join(__dirname, '../pagesMy/teamEdit.vue')
 const headerComponentPath = path.join(__dirname, '../components/CusHeader/index.vue')
 
 async function testDeferredPcSelectionSurvivesSessionFailure() {
+	let createCalls = 0
+	const firstRequest = createDeferred()
 	const component = await loadVueComponent(backgroundComponentPath, {
 		http: {
-			createTeamBackgroundSession: () => Promise.reject(new Error('session unavailable'))
+			createTeamBackgroundSession() {
+				createCalls += 1
+				if (createCalls === 1) return firstRequest.promise
+				return Promise.resolve({
+					code: '123456', uploadUrl: 'https://upload.example/retry', expiresAt: '2099-01-01 00:00:00'
+				})
+			}
 		}
 	})
 	const instance = instantiateComponent(component, {
@@ -682,10 +691,25 @@ async function testDeferredPcSelectionSurvivesSessionFailure() {
 		$showToast() {},
 		$emit() {}
 	})
-	await assert.rejects(instance.waitForDeferredPcUpload(12), /session unavailable/)
+	const first = instance.waitForDeferredPcUpload(12)
+	const second = instance.waitForDeferredPcUpload(12)
+	assert.equal(first, second, 'the whole deferred flow must expose one shared waiter')
+	assert.equal(createCalls, 1)
+	firstRequest.reject(new Error('session unavailable'))
+	const failed = await Promise.allSettled([first, second])
+	assert.deepEqual(failed.map(result => result.status), ['rejected', 'rejected'])
+	assert.match(failed[0].reason.message, /session unavailable/)
 	assert.equal(instance.pcUploadDeferred, true)
 	assert.equal(instance.pcDialogVisible, false)
 	assert.equal(instance.deferredPcResolve, null)
+	assert.equal(instance.deferredPcWaitPromise, null)
+
+	const retry = instance.waitForDeferredPcUpload(12)
+	await flushPromises()
+	assert.equal(createCalls, 2, 'a failed shared waiter must be retryable')
+	assert.equal(typeof instance.deferredPcResolve, 'function')
+	instance.skipDeferredPcUpload()
+	await retry
 }
 
 async function testSessionRefreshFailureKeepsOldTimer() {
@@ -824,6 +848,39 @@ async function testDeferredPcFinishWaitsForListRefresh() {
 	await assert.rejects(waiting, /list refresh failed/)
 }
 
+async function testDeferredPcWaiterSingleFlightFinishesAllCallers() {
+	let createCalls = 0
+	const sessionRequest = createDeferred()
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession() {
+				createCalls += 1
+				return sessionRequest.promise
+			},
+			listTeamBackgroundFiles: () => Promise.resolve([])
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '', editable: true, boundTeamId: 12, pcUploadDeferred: true,
+		$showToast() {}, $emit() {}
+	})
+	const first = instance.waitForDeferredPcUpload(12)
+	const second = instance.waitForDeferredPcUpload(12)
+	assert.equal(first, second)
+	assert.equal(createCalls, 1)
+	let settled = false
+	first.then(() => { settled = true })
+	sessionRequest.resolve({
+		code: '654321', uploadUrl: 'https://upload.example/', expiresAt: '2099-01-01 00:00:00'
+	})
+	await flushPromises()
+	assert.equal(settled, false, 'session creation must not settle the dialog waiter')
+	await instance.finishDeferredPcUpload()
+	await Promise.all([first, second])
+	assert.equal(settled, true)
+	assert.equal(instance.deferredPcWaitPromise, null)
+}
+
 async function testTeamContextResetAndUploadIsolation() {
 	const uploads = []
 	const component = await loadVueComponent(backgroundComponentPath, {
@@ -1100,6 +1157,9 @@ async function testCreatedTeamNavigationIsAwaitableAndResumable() {
 					assert.equal(url, '/core/user/team')
 					assert.equal(payload.id, 88)
 					assert.equal(payload.enterpriseWebsite, 'https://new.example')
+					if (teamPutCalls === 1) {
+						return Promise.resolve({ data: { code: 9, msg: '官网更新失败' } })
+					}
 					return Promise.resolve({ data: { code: 0, data: true } })
 				}
 			},
@@ -1138,6 +1198,18 @@ async function testCreatedTeamNavigationIsAwaitableAndResumable() {
 		assert.ok(toasts.some(message => String(message).includes('页面跳转失败')))
 
 		navigationOptions = null
+		const failedUpdate = instance.handleConfirm({
+			teamName: '团队', enterpriseWebsite: 'https://new.example'
+		})
+		await failedUpdate
+		assert.equal(teamPostCalls, 1)
+		assert.equal(publishCalls, 1)
+		assert.equal(flushCalls, 1)
+		assert.equal(teamPutCalls, 1)
+		assert.equal(instance.creationState.stage, 'update')
+		assert.ok(toasts.some(message => String(message).includes('团队更新失败:官网更新失败')))
+		assert.equal(timers.count(), 0)
+
 		const retry = instance.handleConfirm({
 			teamName: '团队', enterpriseWebsite: 'https://new.example'
 		})
@@ -1145,7 +1217,7 @@ async function testCreatedTeamNavigationIsAwaitableAndResumable() {
 		assert.equal(teamPostCalls, 1, 'retry must reuse the created team id')
 		assert.equal(publishCalls, 1, 'retry after publish success must not publish again')
 		assert.equal(flushCalls, 1, 'navigate-stage retry must not repeat file processing')
-		assert.equal(teamPutCalls, 1, 'changed sanitized payload must update the same team')
+		assert.equal(teamPutCalls, 2, 'changed sanitized payload must update the same team')
 		assert.equal(timers.count(), 1)
 		timers.runNext()
 		await flushPromises()
@@ -1207,13 +1279,18 @@ async function testTeamEditRedirectIsLockedAndResumable() {
 	}
 	const component = await loadVueComponent(teamEditPagePath, { uni, timers })
 	const instance = instantiateComponent(component, {
-		submitDto: { id: 12, teamName: '团队' },
+		submitDto: { id: 12, teamName: '团队', enterpriseWebsite: 'https://old.example' },
 		show: true,
 		$api: {
 			put(url, payload) {
 				putCalls += 1
 				assert.equal(url, '/core/user/team')
 				assert.equal(payload.id, 12)
+				assert.equal(Object.hasOwn(payload, 'coachId'), false)
+				assert.equal(Object.hasOwn(payload, 'uploaderId'), false)
+				assert.equal(Object.hasOwn(payload, 'source'), false)
+				assert.equal(payload.enterpriseWebsite,
+					putCalls === 1 ? 'https://old.example' : 'https://new.example')
 				return Promise.resolve({ data: { code: 0, data: true } })
 			}
 		},
@@ -1251,9 +1328,30 @@ async function testTeamEditRedirectIsLockedAndResumable() {
 	timers.runNext()
 	await flushPromises()
 	assert.ok(redirectOptions)
-	redirectOptions.success({})
+	redirectOptions.fail({ errMsg: 'redirectTo:fail still missing' })
 	await retry
 	assert.equal(putCalls, 1)
+
+	redirectOptions = null
+	instance.handleConfirm({
+		id: 12,
+		teamName: '团队',
+		enterpriseWebsite: ' https://new.example ',
+		coachId: 7,
+		uploaderId: 8,
+		source: 'WECHAT'
+	})
+	const changedRetry = instance.editConfirm()
+	await flushPromises()
+	assert.equal(putCalls, 2, 'a changed payload must update the saved team before redirect')
+	assert.equal(instance.saving, true)
+	assert.equal(timers.count(), 1)
+	timers.runNext()
+	await flushPromises()
+	assert.ok(redirectOptions)
+	redirectOptions.success({})
+	await changedRetry
+	assert.equal(putCalls, 2)
 	assert.equal(instance.saving, false)
 }
 
@@ -1346,6 +1444,7 @@ async function main() {
 	await testPcSessionSingleFlightSurvivesSameTeamReset()
 	await testPcSessionFailureClearsSingleFlightForRetry()
 	await testDeferredPcFinishWaitsForListRefresh()
+	await testDeferredPcWaiterSingleFlightFinishesAllCallers()
 	await testTeamContextResetAndUploadIsolation()
 	await testFileActionsWaitForCurrentListAndDocumentContext()
 	await testTeamFillFlushesBeforeEmitAndLocks()

+ 2 - 1
utils/teamBackgroundFile.js

@@ -128,7 +128,8 @@ function createTeamCreationState() {
 		completed: false,
 		teamPayloadSnapshot: '',
 		teamQuestionnaireId: '',
-		publishResult: null
+		publishResult: null,
+		resumeStage: ''
 	}
 }