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

fix(invite): 家庭邀请分享流程修复 + 平台推荐人绑定规则(需求)

前端:
- join.vue: onLoad 解析邀请 token(含 scene invite= 前缀),被邀请用户跳转 user-edit 完善家庭身份并提交加入申请
- login.vue: 登录完成后检测待处理家庭邀请,直接跳转 user-edit 续接流程

后端:
- CommissionService: 新增 bindReferrerByUserId 幂等绑定方法(已有推荐人忽略),抽取 applyReferralSideEffects 复用副作用
- FamilyJoinRequestService: 审批通过后绑定平台推荐人为邀请人的上级推荐人(仅 token 路径、已有推荐人不动、邀请人无推荐人则跳过、失败不阻断审批)
- 新增 FamilyJoinRequestServiceReferrerBindTest 5 用例覆盖需求规则
liaoxg 2 месяцев назад
Родитель
Сommit
dfc0621f57

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -132,6 +132,37 @@ public class CommissionService {
         user.setReferrerId(referrer.getId());
         userMapper.updateById(user);
 
+        applyReferralSideEffects(userId, referrer);
+    }
+
+    /**
+     * 按用户ID静默绑定平台推荐人(家庭邀请等场景):
+     * 幂等——用户已有推荐人则忽略(不动),referrerUserId 为空、self 或 referrer 不存在则忽略。
+     * 副作用与 bindReferral 一致(物化推荐树、directCount、onboarding、milestone、leaderboard)。
+     */
+    public void bindReferrerByUserId(Long userId, Long referrerUserId) {
+        if (userId == null || referrerUserId == null || userId.equals(referrerUserId)) {
+            return;
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getReferrerId() != null) {
+            return; // 已绑定推荐人 → 不动
+        }
+        User referrer = userMapper.selectById(referrerUserId);
+        if (referrer == null) {
+            return;
+        }
+        user.setReferrerId(referrer.getId());
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
+        applyReferralSideEffects(userId, referrer);
+    }
+
+    /**
+     * 推荐人绑定后的共享副作用(bindReferral / bindReferrerByUserId 均调用)
+     */
+    private void applyReferralSideEffects(Long userId, User referrer) {
         // 物化推荐树(全层级)
         try { cfReferralService.materializeReferralTree(userId); } catch (Exception e) { log.warn("推荐树物化失败 userId={}", userId, e); }
 

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java

@@ -40,6 +40,9 @@ public class FamilyJoinRequestService {
     @Resource
     private UnlockGateService unlockGateService;
 
+    @Resource
+    private CommissionService commissionService;
+
     /**
      * 创建家庭加入请求(待审批)
      */
@@ -164,6 +167,9 @@ public class FamilyJoinRequestService {
             }
         }
 
+        // 平台推荐人绑定(需求:未注册/已注册但无推荐人 → 绑定为邀请人的上级推荐人;已有推荐人 → 不动)
+        bindReferrerFromInviter(request, user);
+
         request.setStatus("approved");
         request.setApproverUserId(approverUserId);
         request.setComment(comment);
@@ -293,6 +299,35 @@ public class FamilyJoinRequestService {
         familyJoinRequestMapper.updateById(req);
     }
 
+    /**
+     * 平台推荐人绑定(需求:未注册/已注册但无推荐人 → 绑定为邀请人的上级推荐人;已有推荐人 → 不动):
+     * - 仅 token 路径(inviterUserId 非空)有明确邀请人,短码路径无邀请人则不处理;
+     * - 被邀请者已有推荐人则跳过(需求:已有推荐人不动);
+     * - 邀请人自身无推荐人则跳过(被邀请者保持无推荐人);
+     * - 幂等性由 commissionService.bindReferrerByUserId 双保险(已有推荐人忽略);
+     * - 绑定失败不阻断审批(仅记录日志)。
+     */
+    private void bindReferrerFromInviter(FamilyJoinRequest request, User requester) {
+        Long inviterUserId = request.getInviterUserId();
+        if (inviterUserId == null) {
+            return;
+        }
+        if (requester != null && requester.getReferrerId() != null) {
+            return;
+        }
+        try {
+            User inviter = userMapper.selectById(inviterUserId);
+            if (inviter == null || inviter.getReferrerId() == null) {
+                return;
+            }
+            commissionService.bindReferrerByUserId(requester.getId(), inviter.getReferrerId());
+            log.info("家庭加入已绑定平台推荐人: userId={}, referrerId={}, requestId={}",
+                    requester.getId(), inviter.getReferrerId(), request.getId());
+        } catch (Exception e) {
+            log.warn("家庭加入绑定平台推荐人失败(不阻断审批): requestId={}, error={}", request.getId(), e.getMessage());
+        }
+    }
+
     /**
      * 校验审批人权限:
      * 申请绑定邀请人的 → 仅该邀请人(且仍为目标家庭成员)可处理;

+ 176 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyJoinRequestServiceReferrerBindTest.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyJoinRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyJoinRequestMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 家庭加入审批通过时的平台推荐人绑定规则测试(需求):
+ * 1. 未注册/无推荐人用户被邀请加入家庭 → 绑定为邀请人的上级推荐人(requester.referrerId = inviter.referrerId)
+ * 2. 已注册用户无推荐人 → 同样绑定为邀请人的上级推荐人
+ * 3. 已注册用户已有推荐人 → 不动(不调用绑定)
+ * 4. 短码路径(无邀请人 inviterUserId=null)→ 不处理
+ * 5. 邀请人自身无推荐人 → 不处理(被邀请者保持无推荐人)
+ *
+ * 仅 token 路径(FamilyJoinRequest.inviterUserId 非空)会绑定;
+ * 幂等由 CommissionService.bindReferrerByUserId 保证(已有推荐人直接忽略)。
+ */
+class FamilyJoinRequestServiceReferrerBindTest {
+
+    @Mock
+    private FamilyJoinRequestMapper familyJoinRequestMapper;
+
+    @Mock
+    private FamilyMapper familyMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyInvitationService familyInvitationService;
+
+    @Mock
+    private UnlockGateService unlockGateService;
+
+    @Mock
+    private CommissionService commissionService;
+
+    @InjectMocks
+    private FamilyJoinRequestService service;
+
+    private Map<Long, User> userDb;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        userDb = new HashMap<>();
+    }
+
+    // ==================== 用例 1:新用户(无推荐人)→ 绑定为邀请人的推荐人 ====================
+
+    @Test
+    void approve_tokenPath_newUserWithoutReferrer_bindsInviterReferrer() {
+        FamilyJoinRequest request = pendingRequest(1L, 3L, 100L, 2L, "by_token");
+        User requester = user(3L, null, null);   // 未注册(无推荐人、未加入家庭)
+        User approver = user(2L, 100L, 9L);      // 审批人 = 邀请人,其上级推荐人为 9
+        stubApprovalFlow(request, requester, approver, approver);
+
+        service.approveJoinRequest(1L, 2L, "");
+
+        verify(commissionService).bindReferrerByUserId(3L, 9L);
+        assertEquals(100L, requester.getFamilyId());  // 同时被加入家庭
+    }
+
+    // ==================== 用例 2:已注册用户(无推荐人)→ 绑定为邀请人的推荐人 ====================
+
+    @Test
+    void approve_tokenPath_registeredUserWithoutReferrer_bindsInviterReferrer() {
+        FamilyJoinRequest request = pendingRequest(1L, 3L, 100L, 2L, "by_token");
+        User requester = user(3L, 50L, null);   // 已注册、已在其他家庭(走合并分支)、无推荐人
+        User approver = user(2L, 100L, 9L);
+        stubApprovalFlow(request, requester, approver, approver);
+
+        service.approveJoinRequest(1L, 2L, "");
+
+        verify(commissionService).bindReferrerByUserId(3L, 9L);
+    }
+
+    // ==================== 用例 3:已注册用户已有推荐人 → 不动 ====================
+
+    @Test
+    void approve_tokenPath_userWithReferrer_doesNotRebind() {
+        FamilyJoinRequest request = pendingRequest(1L, 3L, 100L, 2L, "by_token");
+        User requester = user(3L, null, 8L);    // 已有推荐人 8
+        User approver = user(2L, 100L, 9L);
+        stubApprovalFlow(request, requester, approver, approver);
+
+        service.approveJoinRequest(1L, 2L, "");
+
+        verify(commissionService, never()).bindReferrerByUserId(any(), any());
+    }
+
+    // ==================== 用例 4:短码路径(无邀请人)→ 不处理 ====================
+
+    @Test
+    void approve_shortCodePath_noInviter_doesNotBind() {
+        FamilyJoinRequest request = pendingRequest(1L, 3L, 100L, null, "by_code");
+        User requester = user(3L, null, null);
+        User approver = user(2L, 100L, 9L);
+        approver.setIsFamilyAdmin(true);        // 短码路径回退家庭管理员审批
+        stubApprovalFlow(request, requester, approver, null);
+
+        service.approveJoinRequest(1L, 2L, "");
+
+        verify(commissionService, never()).bindReferrerByUserId(any(), any());
+    }
+
+    // ==================== 用例 5:邀请人自身无推荐人 → 不处理 ====================
+
+    @Test
+    void approve_inviterWithoutReferrer_doesNotBind() {
+        FamilyJoinRequest request = pendingRequest(1L, 3L, 100L, 2L, "by_token");
+        User requester = user(3L, null, null);
+        User approver = user(2L, 100L, null);   // 邀请人自身没有推荐人
+        stubApprovalFlow(request, requester, approver, approver);
+
+        service.approveJoinRequest(1L, 2L, "");
+
+        verify(commissionService, never()).bindReferrerByUserId(any(), any());
+        assertEquals(null, requester.getReferrerId());  // 被邀请者保持无推荐人
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private FamilyJoinRequest pendingRequest(Long id, Long requesterId, Long familyId, Long inviterId, String method) {
+        FamilyJoinRequest r = new FamilyJoinRequest();
+        r.setId(id);
+        r.setRequesterUserId(requesterId);
+        r.setTargetFamilyId(familyId);
+        r.setInviterUserId(inviterId);
+        r.setJoinMethod(method);
+        r.setStatus("pending");
+        return r;
+    }
+
+    private User user(Long id, Long familyId, Long referrerId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        u.setReferrerId(referrerId);
+        return u;
+    }
+
+    private void stubApprovalFlow(FamilyJoinRequest request, User requester, User approver, User inviter) {
+        Family family = new Family();
+        family.setId(request.getTargetFamilyId());
+        family.setName("测试家庭");
+
+        when(familyJoinRequestMapper.selectById(request.getId())).thenReturn(request);
+        when(familyMapper.selectById(request.getTargetFamilyId())).thenReturn(family);
+
+        approver.setFamilyId(request.getTargetFamilyId());  // 审批人是目标家庭成员
+        userDb.put(approver.getId(), approver);
+        userDb.put(requester.getId(), requester);
+        if (inviter != null) {
+            userDb.put(inviter.getId(), inviter);
+        }
+        when(userMapper.selectById(any(Long.class))).thenAnswer(inv -> userDb.get(inv.getArgument(0)));
+    }
+}

+ 42 - 11
cfc-frontend/pages/invite/join.vue

@@ -108,13 +108,22 @@ export default {
   onLoad(query) {
     this._onLoadFired = true
     query = query || {}
+
+    // 0. 防御:若 URL 直接带 familyInviteCode(未经过本页分享链路的直达场景),
+    //    确保本地 pendingFamilyInviteCode 已写入,供 user-edit 识别家庭邀请流程
+    if (query.familyInviteCode) {
+      uni.setStorageSync('pendingFamilyInviteCode', query.familyInviteCode)
+    }
     
-    // 1. 解析家庭邀请码(单一参数,兼容 inviteCode/familyInviteCode)
-    var familyInviteCode = query.familyInviteCode || query.inviteCode || ''
+    // 1. 解析家庭邀请码(单一参数,兼容 inviteCode/familyInviteCode/token)
+    //    token 为家庭成员管理页(family-members.vue)分享卡片/小程序码传入的邀请令牌,值与邀请码一致
+    var familyInviteCode = query.familyInviteCode || query.inviteCode || query.token || ''
     if (!familyInviteCode && query.scene) {
       var sceneDecoded = decodeURIComponent(query.scene)
-      // 支持 scene=fam_xxx 或 ref=xxx 格式
-      if (sceneDecoded.indexOf('fam_') === 0) {
+      // 支持 scene=invite=xxx(小程序码)、fam_xxx 或 ref=xxx 格式
+      if (sceneDecoded.indexOf('invite=') === 0) {
+        familyInviteCode = sceneDecoded.substring(7)
+      } else if (sceneDecoded.indexOf('fam_') === 0) {
         familyInviteCode = sceneDecoded
       } else if (sceneDecoded.indexOf('ref=') === 0) {
         // ref=xxx 归为推广码
@@ -248,16 +257,38 @@ export default {
     goNext() {
       if (this.redirecting) return
       this.redirecting = true
-      
-      if (this.inviteInfo && this.familyStatus && this.familyStatus.inFamily && 
+
+      // 已在目标家庭 -> 直接进首页
+      if (this.inviteInfo && this.familyStatus && this.familyStatus.inFamily &&
           this.familyStatus.familyId === this.inviteInfo.familyId) {
+        this.redirecting = false
         this.goHome()
-      } else {
-        // 跳转个人信息完善页,带上邀请码参数
-        uni.navigateTo({ 
-          url: '/pages/user-edit/user-edit?familyInviteCode=' + this.familyInviteCode 
-        })
+        return
+      }
+
+      // 未登录:先去登录(pendingFamilyInviteCode 已存本地,登录后新用户流程会提交加入申请)
+      if (!this.isLoggedIn) {
+        this.redirecting = false
+        this.goLogin()
+        return
       }
+
+      // 已登录但不在目标家庭:跳个人信息完善页。
+      // 需带 token+userId+autoParent=true,让 user-edit 进入 isNewUserFlow 模式,
+      // 才会显示「家庭身份」下拉并在保存时提交加入申请(requestJoinByCode)
+      this.goUserEdit()
+    },
+
+    // 已登录用户跳转个人信息完善页(新用户家庭邀请流程)
+    goUserEdit() {
+      var token = uni.getStorageSync('token') || ''
+      var userId = uni.getStorageSync('userId') || ''
+      uni.navigateTo({
+        url: '/pages/user-edit/user-edit?token=' + encodeURIComponent(token) +
+          '&userId=' + userId +
+          '&autoParent=true' +
+          '&familyInviteCode=' + encodeURIComponent(this.familyInviteCode || '')
+      })
     },
 
     goHome() {

+ 5 - 2
cfc-frontend/pages/login/login.vue

@@ -163,7 +163,10 @@ export default {
       self.$store.commit('setFamilyId', data.familyId)
       uni.showToast({ title: '登录成功', icon: 'success' })
       self.loading = false
-      if (data.needsRoleSelect) {
+      // 新用户,或存在待加入的家庭邀请(join.vue 已写入 pendingFamilyInviteCode):
+      // 都进入个人信息完善页,填写家庭身份并提交加入申请
+      var hasPendingFamilyInvite = !!uni.getStorageSync('pendingFamilyInviteCode')
+      if (data.needsRoleSelect || hasPendingFamilyInvite) {
         uni.redirectTo({ url: '/pages/user-edit/user-edit?token=' + data.token + '&userId=' + data.userId + '&autoParent=true' })
       } else {
         self.handlePostLoginRouting()
@@ -399,7 +402,7 @@ export default {
   background: #fff;
   border-radius: 20rpx;
   padding: 36rpx 36rpx 28rpx;
-  margin-top: auto;
+  margin-top: 48rpx;
   box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.06);
 }