Ver código fonte

fix: settle team workflow retries once

Developer 3 dias atrás
pai
commit
59c5da0abf

+ 22 - 12
components/CusTeamBackgroundFiles/index.vue

@@ -107,7 +107,7 @@ export default {
 			pcUploadDeferred: false,
 			pcUploadDeferred: false,
 			deferredPcResolve: null,
 			deferredPcResolve: null,
 			deferredPcReject: null,
 			deferredPcReject: null,
-			deferredPcPromise: null,
+			deferredPcWaitPromise: null,
 			files: [],
 			files: [],
 			count: this.initialCount || 0,
 			count: this.initialCount || 0,
 			filesReady: false,
 			filesReady: false,
@@ -513,23 +513,33 @@ export default {
 		hasDeferredPcUpload() {
 		hasDeferredPcUpload() {
 			return this.pcUploadDeferred
 			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.deferredPcResolve = resolve
 				this.deferredPcReject = reject
 				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) {
 		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.deferredPcResolve = null
 			this.deferredPcReject = null
 			this.deferredPcReject = null
-			this.deferredPcPromise = null
+			this.deferredPcWaitPromise = null
+			if (error && reject) reject(error)
+			else if (resolve) resolve()
 		},
 		},
 		resolveDeferredPcUpload() {
 		resolveDeferredPcUpload() {
 			this.settleDeferredPcUpload()
 			this.settleDeferredPcUpload()
@@ -544,7 +554,7 @@ export default {
 		},
 		},
 		async finishDeferredPcUpload() {
 		async finishDeferredPcUpload() {
 			const teamId = this.activeTeamId()
 			const teamId = this.activeTeamId()
-			const hasDeferredWaiter = Boolean(this.deferredPcPromise)
+			const hasDeferredWaiter = Boolean(this.deferredPcWaitPromise)
 			this.closePcDialog()
 			this.closePcDialog()
 			this.pcUploadDeferred = false
 			this.pcUploadDeferred = false
 			try {
 			try {

+ 14 - 8
pagesMy/teamEdit.vue

@@ -15,9 +15,11 @@
 	</view>
 	</view>
 </template>
 </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 },
 		components:{ CusTeamInfoFill },
 		data(){
 		data(){
 			return {
 			return {
@@ -30,6 +32,7 @@
 				show:false,
 				show:false,
 				saving:false,
 				saving:false,
 				saveSucceeded:false,
 				saveSucceeded:false,
+				savedPayloadSnapshot:'',
 				saveStage:'idle',
 				saveStage:'idle',
 				navigationTimer:null,
 				navigationTimer:null,
 				navigationReject:null,
 				navigationReject:null,
@@ -91,21 +94,24 @@
 				})
 				})
 			},
 			},
 			handleConfirm(data){
 			handleConfirm(data){
-				this.submitDto = data;
-				this.show = true;
-			},
+				this.submitDto = createTeamSubmitPayload(data);
+				this.show = true;
+			},
 			async editConfirm(){
 			async editConfirm(){
 				if(this.saving) return
 				if(this.saving) return
 				this.saving = true
 				this.saving = true
+				const payload = createTeamSubmitPayload(this.submitDto)
+				const payloadSnapshot = createTeamPayloadSnapshot(payload)
 				try {
 				try {
-					if(!this.saveSucceeded) {
+					if(!this.saveSucceeded || this.savedPayloadSnapshot !== payloadSnapshot) {
 						this.saveStage = 'save'
 						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){
 						if(res.code!==0){
 							this.$showToast(res.msg)
 							this.$showToast(res.msg)
 							return
 							return
 						}
 						}
 						this.saveSucceeded = true
 						this.saveSucceeded = true
+						this.savedPayloadSnapshot = payloadSnapshot
 						this.show = false
 						this.show = false
 						this.$showToast('编辑成功')
 						this.$showToast('编辑成功')
 					}
 					}

+ 17 - 13
pagesPublish/fillTeamInfo.vue

@@ -56,7 +56,10 @@
 				const payloadSnapshot = createTeamPayloadSnapshot(payload)
 				const payloadSnapshot = createTeamPayloadSnapshot(payload)
 				try {
 				try {
 					let teamId = this.creationState.createdTeamId
 					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){
 					if(!teamId){
 						setTeamSubmissionStage(this.creationState, 'save')
 						setTeamSubmissionStage(this.creationState, 'save')
 						const { data:res } = await this.$api.post('/core/user/team',payload)
 						const { data:res } = await this.$api.post('/core/user/team',payload)
@@ -72,19 +75,19 @@
 						rememberCreatedTeam(this.creationState, teamId)
 						rememberCreatedTeam(this.creationState, teamId)
 						this.creationState.teamPayloadSnapshot = payloadSnapshot
 						this.creationState.teamPayloadSnapshot = payloadSnapshot
 					} else if(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
 						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) {
 					if(resumeNavigation) {
@@ -123,6 +126,7 @@
 				if(this.pageUnloaded) return
 				if(this.pageUnloaded) return
 				const prefix = {
 				const prefix = {
 					save:'团队保存失败',
 					save:'团队保存失败',
+					update:'团队更新失败',
 					upload:'背景资料处理失败',
 					upload:'背景资料处理失败',
 					pcSession:'电脑上传流程失败',
 					pcSession:'电脑上传流程失败',
 					publish:'问卷发布失败',
 					publish:'问卷发布失败',

+ 105 - 6
tests/teamBackgroundFile.test.js

@@ -96,7 +96,8 @@ assert.deepEqual(creationState, {
 	completed: false,
 	completed: false,
 	teamPayloadSnapshot: '',
 	teamPayloadSnapshot: '',
 	teamQuestionnaireId: '',
 	teamQuestionnaireId: '',
-	publishResult: null
+	publishResult: null,
+	resumeStage: ''
 })
 })
 const firstPayloadSnapshot = rules.createTeamPayloadSnapshot({
 const firstPayloadSnapshot = rules.createTeamPayloadSnapshot({
 	teamName: '团队', enterpriseWebsite: ' https://example.com ', coachId: 7
 	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')
 const headerComponentPath = path.join(__dirname, '../components/CusHeader/index.vue')
 
 
 async function testDeferredPcSelectionSurvivesSessionFailure() {
 async function testDeferredPcSelectionSurvivesSessionFailure() {
+	let createCalls = 0
+	const firstRequest = createDeferred()
 	const component = await loadVueComponent(backgroundComponentPath, {
 	const component = await loadVueComponent(backgroundComponentPath, {
 		http: {
 		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, {
 	const instance = instantiateComponent(component, {
@@ -682,10 +691,25 @@ async function testDeferredPcSelectionSurvivesSessionFailure() {
 		$showToast() {},
 		$showToast() {},
 		$emit() {}
 		$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.pcUploadDeferred, true)
 	assert.equal(instance.pcDialogVisible, false)
 	assert.equal(instance.pcDialogVisible, false)
 	assert.equal(instance.deferredPcResolve, null)
 	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() {
 async function testSessionRefreshFailureKeepsOldTimer() {
@@ -824,6 +848,39 @@ async function testDeferredPcFinishWaitsForListRefresh() {
 	await assert.rejects(waiting, /list refresh failed/)
 	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() {
 async function testTeamContextResetAndUploadIsolation() {
 	const uploads = []
 	const uploads = []
 	const component = await loadVueComponent(backgroundComponentPath, {
 	const component = await loadVueComponent(backgroundComponentPath, {
@@ -1100,6 +1157,9 @@ async function testCreatedTeamNavigationIsAwaitableAndResumable() {
 					assert.equal(url, '/core/user/team')
 					assert.equal(url, '/core/user/team')
 					assert.equal(payload.id, 88)
 					assert.equal(payload.id, 88)
 					assert.equal(payload.enterpriseWebsite, 'https://new.example')
 					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 } })
 					return Promise.resolve({ data: { code: 0, data: true } })
 				}
 				}
 			},
 			},
@@ -1138,6 +1198,18 @@ async function testCreatedTeamNavigationIsAwaitableAndResumable() {
 		assert.ok(toasts.some(message => String(message).includes('页面跳转失败')))
 		assert.ok(toasts.some(message => String(message).includes('页面跳转失败')))
 
 
 		navigationOptions = null
 		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({
 		const retry = instance.handleConfirm({
 			teamName: '团队', enterpriseWebsite: 'https://new.example'
 			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(teamPostCalls, 1, 'retry must reuse the created team id')
 		assert.equal(publishCalls, 1, 'retry after publish success must not publish again')
 		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(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)
 		assert.equal(timers.count(), 1)
 		timers.runNext()
 		timers.runNext()
 		await flushPromises()
 		await flushPromises()
@@ -1207,13 +1279,18 @@ async function testTeamEditRedirectIsLockedAndResumable() {
 	}
 	}
 	const component = await loadVueComponent(teamEditPagePath, { uni, timers })
 	const component = await loadVueComponent(teamEditPagePath, { uni, timers })
 	const instance = instantiateComponent(component, {
 	const instance = instantiateComponent(component, {
-		submitDto: { id: 12, teamName: '团队' },
+		submitDto: { id: 12, teamName: '团队', enterpriseWebsite: 'https://old.example' },
 		show: true,
 		show: true,
 		$api: {
 		$api: {
 			put(url, payload) {
 			put(url, payload) {
 				putCalls += 1
 				putCalls += 1
 				assert.equal(url, '/core/user/team')
 				assert.equal(url, '/core/user/team')
 				assert.equal(payload.id, 12)
 				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 } })
 				return Promise.resolve({ data: { code: 0, data: true } })
 			}
 			}
 		},
 		},
@@ -1251,9 +1328,30 @@ async function testTeamEditRedirectIsLockedAndResumable() {
 	timers.runNext()
 	timers.runNext()
 	await flushPromises()
 	await flushPromises()
 	assert.ok(redirectOptions)
 	assert.ok(redirectOptions)
-	redirectOptions.success({})
+	redirectOptions.fail({ errMsg: 'redirectTo:fail still missing' })
 	await retry
 	await retry
 	assert.equal(putCalls, 1)
 	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)
 	assert.equal(instance.saving, false)
 }
 }
 
 
@@ -1346,6 +1444,7 @@ async function main() {
 	await testPcSessionSingleFlightSurvivesSameTeamReset()
 	await testPcSessionSingleFlightSurvivesSameTeamReset()
 	await testPcSessionFailureClearsSingleFlightForRetry()
 	await testPcSessionFailureClearsSingleFlightForRetry()
 	await testDeferredPcFinishWaitsForListRefresh()
 	await testDeferredPcFinishWaitsForListRefresh()
+	await testDeferredPcWaiterSingleFlightFinishesAllCallers()
 	await testTeamContextResetAndUploadIsolation()
 	await testTeamContextResetAndUploadIsolation()
 	await testFileActionsWaitForCurrentListAndDocumentContext()
 	await testFileActionsWaitForCurrentListAndDocumentContext()
 	await testTeamFillFlushesBeforeEmitAndLocks()
 	await testTeamFillFlushesBeforeEmitAndLocks()

+ 2 - 1
utils/teamBackgroundFile.js

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