Просмотр исходного кода

fix(team-invite): 用户级分销绑定资产一致性返工(仅前端1文件)

产品裁决: 用户级 referrerId 走完整分销链路发 F24 CF 资产是接受的语义;
记录级 inviter_user_id 保持零副作用/强校验/三口径不变。本轮只堵资产一致性缺口。

P0 串行化: doSubmit 改为返回 boolean(仅 res.code==200 落库成功才 true);
handleSubmit(已登录) 与 onInviteLoginSuccess(浮层登录后) 两条路径同构,
均 await doSubmit 确认成功后才串行 await bindUserReferral, 绝不并发;
申请失败/邀请码无效/查重拒绝/网络失败 → doSubmit 返回 false → 不绑定、不发 CF;
删除旧并发链 doSubmit().catch(function(){}) 与 bind().catch(function(){}), 不再有空 catch 吞失败。

P0 失败可恢复: removeStorageSync('refCode') 从并发块移入绑定成功(resolve 含 skipped)
分支; 失败保留码 + userRefBound 保持 false; showBindRetryHint 弹 modal 承载后端
具体 message(不覆盖 request toast) + 明确重试引导 + '重试绑定'就地补绑入口。

P0 防错配: 用户级绑定源改用组件字段 this.inviteRefCode(与记录级同源, onLoad 解析),
不再读 uni.getStorageSync('refCode'); 码空不绑定; openLoginSheet 透传保持不变。

P1 幂等防重复发: userRefBound 成功/skipped 置 true, 整个流程(含 handleReapply/
handleAddRole 多次提交)至多成功绑定一次; 首次失败保持 false 以便重试补绑(那次未发资产);
后端 CommissionService:140 已有推荐人直接抛为第二道幂等闸, 双重保证不重复发资产。

P1 修注释矛盾: onInviteLoginSuccess 旧注释'本页不重复调用 bindReferralIfNotSelf'
与实际调用矛盾, 改为准确描述(就地浮层登录后主动补绑, 与 user-edit 路径互斥, 受幂等保护)。

自验: vue-template-compiler 模板编译 + acorn script parse 双 OK; 无可选链/CSS Grid/新增 emoji;
仅改 1 文件。CommissionService.bindReferral 幂等: :140 已有推荐人抛错, 走不到 :156 发资产。
Agnes Agent 21 часов назад
Родитель
Сommit
ffc2c8459c
1 измененных файлов с 90 добавлено и 23 удалено
  1. 90 23
      cfc-frontend/pages/profile/service-role-apply.vue

+ 90 - 23
cfc-frontend/pages/profile/service-role-apply.vue

@@ -178,7 +178,14 @@ export default {
       // 独立 storage key:记录级归属专用,不会被 user-edit 的用户级消费逻辑清除
       // 独立 storage key:记录级归属专用,不会被 user-edit 的用户级消费逻辑清除
       inviteRefCodeStorageKey: 'serviceRoleApplyRefCode',
       inviteRefCodeStorageKey: 'serviceRoleApplyRefCode',
       // 未登录点提交后挂起:浮层登录成功回调里续跑提交,避免用户二次点击
       // 未登录点提交后挂起:浮层登录成功回调里续跑提交,避免用户二次点击
-      pendingSubmitAfterLogin: false
+      pendingSubmitAfterLogin: false,
+      // 用户级推荐人绑定幂等标记(资产一致性):整个流程至多成功绑定一次。
+      // bindReferralIfNotSelf 成功(含自邀 skipped:true)才置 true;首次失败保持 false 以便重试补绑。
+      // handleReapply/handleAddRole 多次提交不重置它,避免重复走 /api/invite/bind 重复发 F24 CF 资产、
+      // 或触发后端 CommissionService:140「已绑定推荐人,不可修改」误报错。
+      userRefBound: false,
+      // 绑定请求防重入锁:避免失败重试弹窗连点「重试绑定」导致并发 bind 请求
+      bindingRef: false
     }
     }
   },
   },
   computed: {
   computed: {
@@ -416,37 +423,95 @@ export default {
         this.openLoginSheet({ refCode: this.inviteRefCode })
         this.openLoginSheet({ refCode: this.inviteRefCode })
         return
         return
       }
       }
-      await this.doSubmit(newRoles)
+      // 资产一致性(P0,串行):记录级 doSubmit 必须先执行并确认成功,之后才触发用户级绑定。
+      // doSubmit 失败(邀请码无效/查重拒绝/网络失败)会返回 false 并自行 toast,绝不进入绑定、
+      // 绝不给邀请人发 F24 CF 资产;也绝不让绑定链路的空 catch 吞掉 doSubmit 的失败。
+      var submitted = await this.doSubmit(newRoles)
+      if (submitted) {
+        await this.bindUserReferral()
+      }
     },
     },
     // 就地浮层登录成功回调:自动续跑被挂起的提交(此时 token 已写入 storage)。
     // 就地浮层登录成功回调:自动续跑被挂起的提交(此时 token 已写入 storage)。
-    // 用户级 referrerId 由 App.vue onShow / user-edit.vue 的现状链路消费 storage.refCode 写入,
-    // 本页不重复调用 bindReferralIfNotSelf,避免记录级链路触发分销副作用。
-    onInviteLoginSuccess() {
+    // 就地浮层登录不走 user-edit.vue:714 的推荐人消费点、也不触发 App.vue onShow 带参补绑,
+    // 故本页在「记录级申请成功」后主动补绑一次用户级推荐人(product 裁决:用户级走完整分销链路、
+    // 发 F24 CF 值是接受的语义)。绑定制约:①仅在 doSubmit 成功后串行触发,失败绝不发资产;
+    // ②与 user-edit 路径互斥受 userRefBound 幂等标记保护,整个流程至多成功一次,不重复发资产。
+    async onInviteLoginSuccess() {
       if (!this.pendingSubmitAfterLogin) return
       if (!this.pendingSubmitAfterLogin) return
       this.pendingSubmitAfterLogin = false
       this.pendingSubmitAfterLogin = false
 
 
-      // 用户级 referrerId 消费(推荐人=邀请人):就地浮层登录不走 user-edit.vue:714 的消费点,
-      // 也不触发 App.vue onShow 带参补绑,故在此显式消费 App.vue:85 已存入的用户级 storage.refCode。
-      // 以下三行严格同构 user-edit.vue:714-733 的「读码 → bindReferralIfNotSelf → 清码」,
-      // 只清 refCode,不碰 inviteCode/inviteType(避免影响其他链路)。
-      // 两级隔离:用户级 → storage.refCode → users.referrerId(走 /api/invite/bind,固有分销副作用);
-      //          记录级 → storage.serviceRoleApplyRefCode → doSubmit 透传 refCode → 后端反查写
-      //          inviter_user_id(绝不经 /api/invite/bind,绝不触发 applyReferralSideEffects)。
-      // 不 await、不阻塞记录级提交;绑定失败由 request() 统一 toast,可见但不影响申请受理。
-      const userRefCode = uni.getStorageSync('refCode')
-      if (userRefCode) {
-        bindReferralIfNotSelf(userRefCode).catch(function() {})
-        uni.removeStorageSync('refCode')
-      }
-
       const newRoles = (this.selectedRoles || []).filter(r => this.approvedRoles.indexOf(r) === -1)
       const newRoles = (this.selectedRoles || []).filter(r => this.approvedRoles.indexOf(r) === -1)
-      this.doSubmit(newRoles).catch(function() {})
+      // 资产一致性(P0,串行):与已登录路径完全同构。先确认记录级申请成功,再绑定用户级推荐人。
+      // doSubmit 失败返回 false 并自行 toast,绝不进入绑定、绝不给邀请人发 CF 资产。
+      var submitted = await this.doSubmit(newRoles)
+      if (submitted) {
+        await this.bindUserReferral()
+      }
     },
     },
     // 浮层关闭(含「暂不登录先逛逛」handleSkipLogin):复位挂起标记,
     // 浮层关闭(含「暂不登录先逛逛」handleSkipLogin):复位挂起标记,
     // 避免用户放弃登录后标记残留,导致下次提交误走自动续提交分支。
     // 避免用户放弃登录后标记残留,导致下次提交误走自动续提交分支。
     onInviteLoginClose() {
     onInviteLoginClose() {
       this.pendingSubmitAfterLogin = false
       this.pendingSubmitAfterLogin = false
     },
     },
+    // 用户级推荐人绑定(资产一致性核心 P0/P1)。
+    // 前置:仅由「记录级 doSubmit 成功」后的调用方触发;申请失败/无码绝不进这里,确保绝不误发 CF 资产。
+    // 语义:走完整分销链路 bindReferralIfNotSelf → /api/invite/bind → CommissionService.bindReferral
+    //       → applyReferralSideEffects(发 F24 CF 值、directCount+1、排行榜)——用户裁决接受的语义。
+    // 两级隔离:记录级零副作用、强校验;用户级才发资产。两者同源于组件字段 inviteRefCode。
+    // 幂等:userRefBound 成功(含自邀 skipped)置 true,整个流程(含 handleReapply/handleAddRole
+    //      多次提交)至多成功一次,避免重复发资产 / 触发后端 CommissionService:140「已绑定推荐人」误报错。
+    // 失败可恢复:失败时保留 storage.refCode、userRefBound 保持 false,弹可见提示 + 就地重试入口;
+    //      用户可点「重试绑定」就地补绑,或从邀请链接重新进入走完整流程。
+    async bindUserReferral() {
+      if (this.userRefBound) return          // 幂等:已成功绑定过,不重复发资产
+      if (this.bindingRef) return            // 防重入:重试连点/异常时序不并发
+      // 防错配(P0):与记录级同源于组件字段 inviteRefCode(onLoad 解析的那份),
+      // 绝不从 uni.getStorageSync('refCode') 读,消除「storage 残留旧码 → 用户级绑到旧人」的窄边界;码空不绑定。
+      var code = this.inviteRefCode
+      if (!code) return
+
+      this.bindingRef = true
+      try {
+        // await 串行:绑定结果 settle 后才决定清码/置标记/提示,绝不 fire-and-forget
+        var bindRes = await bindReferralIfNotSelf(code)
+        if (bindRes && (bindRes.code === 200 || bindRes.skipped)) {
+          // 仅成功(含自邀 skipped:true)才置幂等标记 + 清用户级码;
+          // 失败分支绝不清码,保证可重试/重新进入补绑(不丢资产)。
+          this.userRefBound = true
+          uni.removeStorageSync('refCode')
+          return
+        }
+        // 非 200 且非 skipped 理论上由 request() reject 走 catch,此处防御性兜底按失败处理
+        this.showBindRetryHint(bindRes && bindRes.message)
+      } catch (e) {
+        // 401 由 request() 统一 reLaunch 登录页,不在这里叠加 modal
+        if (e && e.code === 401) return
+        // 失败:保留 storage.refCode、userRefBound 保持 false。
+        // request() 拦截器已对非 200 业务响应 toast 后端具体 message(如「邀请码无效」「已有推荐人」),
+        // 这里用 modal 同时承载后端原文 + 明确重试引导:既不覆盖具体错误,又给出可见的重试入口。
+        this.showBindRetryHint(e && e.message)
+      } finally {
+        this.bindingRef = false
+      }
+    },
+    // 绑定失败提示 + 就地重试入口。申请已成功,故措辞不泛化「提交失败」;
+    // 后端具体 message 放 content 保留可见(request() toast 可能被 modal 盖住,原文不丢)。
+    showBindRetryHint(serverMsg) {
+      uni.showModal({
+        title: '推荐人绑定失败',
+        content: (serverMsg ? serverMsg + '\n' : '') + '申请已提交成功,但推荐人未绑定成功。请重试,或从邀请链接重新进入。',
+        confirmText: '重试绑定',
+        cancelText: '稍后',
+        success: (res) => {
+          if (res.confirm) {
+            // 幂等标记仍为 false、码仍保留,重试会重新发起绑定;成功即置位并清码
+            this.bindUserReferral()
+          }
+        }
+      })
+    },
+    // 返回 boolean:仅当记录级申请真正落库成功(res.code===200)才返回 true。
+    // 调用方据此决定是否触发用户级绑定——这是「资产一致性」的唯一闸门:申请失败绝不发 CF 资产。
     async doSubmit(newRoles) {
     async doSubmit(newRoles) {
       this.submitting = true
       this.submitting = true
       uni.showLoading({ title: '提交中...' })
       uni.showLoading({ title: '提交中...' })
@@ -471,10 +536,11 @@ export default {
           uni.removeStorageSync(this.inviteRefCodeStorageKey)
           uni.removeStorageSync(this.inviteRefCodeStorageKey)
           await this.checkExistingApply()
           await this.checkExistingApply()
           if (!this.showStatusView) this.showStatusView = true
           if (!this.showStatusView) this.showStatusView = true
-        } else {
-          // 少数业务码(如 5001 无家庭)会被 request() resolve 而非 reject,此时 request 未弹提示,需在此兜底
-          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+          return true
         }
         }
+        // 少数业务码(如 5001 无家庭)会被 request() resolve 而非 reject,此时 request 未弹提示,需在此兜底
+        uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        return false
       } catch (e) {
       } catch (e) {
         uni.hideLoading()
         uni.hideLoading()
         // 邀请码无效/自邀请等后端业务错误:request() 已 showToast(e.message),这里用同一 message
         // 邀请码无效/自邀请等后端业务错误:request() 已 showToast(e.message),这里用同一 message
@@ -483,6 +549,7 @@ export default {
         if (e && e.message && e.code !== 401) {
         if (e && e.message && e.code !== 401) {
           uni.showToast({ title: e.message, icon: 'none' })
           uni.showToast({ title: e.message, icon: 'none' })
         }
         }
+        return false
       } finally {
       } finally {
         this.submitting = false
         this.submitting = false
       }
       }