Răsfoiți Sursa

merge(miniprogram): 团队邀请改造——分享直达服务人员申请表 + 两级归属 合入 cfclub

特性链: c97749df6 -> ffc2c8459 -> ac4ba5613
记录级 inviter_user_id(申请挂邀请人名下,零分销副作用) + 用户级 referrerId(推荐人=邀请人,走完整分销发F24 CF资产,产品裁决接受)。
入口改微信分享直达申请表(带refCode);未注册微信静默授权;就地浮层登录后串行 doSubmit成功再bind(失败留码可重试/幂等防重复发/两级同源防错配/删虚假恢复承诺文案)。
后端8文件P0全PASS + 前端A/B资产闸门全PASS;merge-base=7e03ffcc,8文件主线零触碰,三方合并零冲突;迁移467幂等补列(不回填存量)。
未测: mvn编译(.m2空交CI兜底) / 真机运行时联调。
Agnes Agent 1 zi în urmă
părinte
comite
9afaa6e467

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3499,6 +3499,11 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
 
         // 迁移466: diet_recommendations 补视角隔离列 view_key + 索引(今日食谱随成员/视角切换)
         // 迁移466: diet_recommendations 补视角隔离列 view_key + 索引(今日食谱随成员/视角切换)
         addDietRecommendationViewKey();
         addDietRecommendationViewKey();
+
+        // 迁移467: service_role_applications 补记录级归属列 inviter_user_id + 索引(团队邀请改造)。
+        // 本列仅记录「某条服务角色申请挂在哪个邀请人名下」,与 users.referrerId(用户级推荐人)并存互不替代,
+        // 且服务端写入时不触发任何分销/佣金副作用(物化推荐树/directCount/F24 CF 等)。
+        addServiceRoleApplicationInviterColumn();
     }
     }
 
 
     /**
     /**
@@ -15712,6 +15717,29 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         }
     }
     }
 
 
+    /**
+     * 迁移467: service_role_applications 补记录级归属列 inviter_user_id + 普通索引(团队邀请改造)。
+     *
+     * <p>本列仅记录「某条服务角色申请挂在哪个邀请人名下」,由后端按分享 refCode 服务端反查写入,
+     * 与用户级 {@code users.referrerId}(推荐人=邀请人)并存、互不替代;写入时不触发任何分销/佣金副作用。</p>
+     *
+     * <p>幂等:ensureColumn 忽略「列已存在」;indexExists 兜底补普通索引(不加唯一索引,存量行该列为 NULL)。
+     * 存量申请保持 NULL(兼容用户从个人中心主动进入的历史记录),不做回填覆盖。</p>
+     */
+    private void addServiceRoleApplicationInviterColumn() {
+        ensureColumn("service_role_applications", "inviter_user_id",
+                "BIGINT NULL COMMENT '邀请人ID(记录级归属:本条申请挂在哪个邀请人名下,来自分享refCode服务端反查;不触发分销副作用)'");
+        try {
+            if (!indexExists("service_role_applications", "idx_inviter_user_id")) {
+                jdbcTemplate.execute("ALTER TABLE service_role_applications "
+                        + "ADD INDEX idx_inviter_user_id (inviter_user_id)");
+                log.info("迁移467: 已为 service_role_applications 补建索引 idx_inviter_user_id");
+            }
+        } catch (Exception e) {
+            log.warn("迁移467: idx_inviter_user_id 索引检查/补建失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      int sort, String sourceSystem, String nodeKind,
                                      int sort, String sourceSystem, String nodeKind,

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java

@@ -17,4 +17,10 @@ public class ServiceRoleApplyDTO {
     private String district;
     private String district;
     private String street;
     private String street;
     private String addressDetail;
     private String addressDetail;
+    /**
+     * 团队邀请分享码(可选)。由 /pages/profile/service-role-apply?refCode=xxx 直达链接携带。
+     * 服务端据此反查邀请人写记录级归属 inviter_user_id;不接受前端直传 inviterUserId(防冒领)。
+     * 为空(用户从个人中心主动进入申请)时正常受理,inviter_user_id=null。
+     */
+    private String refCode;
 }
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java

@@ -13,6 +13,8 @@ public class ServiceRoleApplication {
     @TableId(type = IdType.AUTO)
     @TableId(type = IdType.AUTO)
     private Long id;
     private Long id;
     private Long userId;
     private Long userId;
+    /** 记录级归属:本条申请挂在哪个邀请人名下(来自分享 refCode 服务端反查,不触发分销副作用) */
+    private Long inviterUserId;
     private String roles;
     private String roles;
     private String idCard;
     private String idCard;
     private String idCardImages;
     private String idCardImages;

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java

@@ -47,6 +47,26 @@ public class ServiceRoleApplicationServiceImpl
 
 
     @Override
     @Override
     public Result submit(Long userId, ServiceRoleApplyDTO dto) {
     public Result submit(Long userId, ServiceRoleApplyDTO dto) {
+        // 记录级归属:由服务端按分享 refCode 反查邀请人,绝不接受前端直传 inviterUserId(防冒领)。
+        // 与用户级 users.referrerId 相互独立、并存互不替代;本链路不触发任何分销/佣金副作用。
+        String refCode = dto.getRefCode();
+        Long inviterUserId = null;
+        if (refCode != null && !refCode.trim().isEmpty()) {
+            User inviter = userMapper.selectOne(
+                    new LambdaQueryWrapper<User>()
+                            .eq(User::getReferralCode, refCode.trim()));
+            // 口径②:带码但反查失败/自邀请 → 显式拒绝提交,绝不静默吞(否则前端会误报成功)
+            if (inviter == null || inviter.getId().equals(userId)) {
+                return Result.error("邀请码无效或不能邀请自己,请通过邀请人的分享链接进入");
+            }
+            // 口径③:带有效码 → 写记录级归属
+            inviterUserId = inviter.getId();
+        }
+        // 口径①:无 refCode(用户从个人中心主动进入申请)→ inviterUserId 保持 null,正常受理,不报错。
+
+        // 查重维持全局 (userId, status=0) 不变:同一用户仅允许一条待审记录。
+        // 注意:这是记录级查重,不是用户级推荐关系幂等拦截——每条申请独立判定归属,
+        // 不因「已绑定推荐人」而沿用/拒写 inviterUserId,也不因已有记录而跨记录沿用 inviter。
         ServiceRoleApplication existing = this.getOne(
         ServiceRoleApplication existing = this.getOne(
                 new LambdaQueryWrapper<ServiceRoleApplication>()
                 new LambdaQueryWrapper<ServiceRoleApplication>()
                         .eq(ServiceRoleApplication::getUserId, userId)
                         .eq(ServiceRoleApplication::getUserId, userId)
@@ -57,6 +77,7 @@ public class ServiceRoleApplicationServiceImpl
 
 
         ServiceRoleApplication app = new ServiceRoleApplication();
         ServiceRoleApplication app = new ServiceRoleApplication();
         app.setUserId(userId);
         app.setUserId(userId);
+        app.setInviterUserId(inviterUserId);
         app.setRoles(JSON.toJSONString(dto.getRoles()));
         app.setRoles(JSON.toJSONString(dto.getRoles()));
         app.setIdCard(dto.getIdCard());
         app.setIdCard(dto.getIdCard());
         app.setIdCardImages(dto.getIdCardImages() != null ? JSON.toJSONString(dto.getIdCardImages()) : null);
         app.setIdCardImages(dto.getIdCardImages() != null ? JSON.toJSONString(dto.getIdCardImages()) : null);
@@ -68,6 +89,8 @@ public class ServiceRoleApplicationServiceImpl
         app.setAddressDetail(dto.getAddressDetail());
         app.setAddressDetail(dto.getAddressDetail());
         app.setStatus(0);
         app.setStatus(0);
         this.save(app);
         this.save(app);
+        // 两级解耦铁律:记录级归属仅落 inviter_user_id 一列,
+        // 绝不调用 CommissionService.applyReferralSideEffects()(会物化推荐树、directCount+1、发 F24 CF 真实资产等)。
         return Result.success("申请提交成功,请等待审核");
         return Result.success("申请提交成功,请等待审核");
     }
     }
 
 
@@ -258,6 +281,8 @@ public class ServiceRoleApplicationServiceImpl
             Map<String, Object> row = new LinkedHashMap<>();
             Map<String, Object> row = new LinkedHashMap<>();
             row.put("id", app.getId());
             row.put("id", app.getId());
             row.put("userId", app.getUserId());
             row.put("userId", app.getUserId());
+            // 记录级归属:本条申请挂在哪个邀请人名下(null=用户主动申请,非邀请链路)
+            row.put("inviterUserId", app.getInviterUserId());
             row.put("roles", app.getRoles());
             row.put("roles", app.getRoles());
             row.put("idCard", app.getIdCard());
             row.put("idCard", app.getIdCard());
             row.put("idCardImages", app.getIdCardImages());
             row.put("idCardImages", app.getIdCardImages());

+ 2 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4500,6 +4500,7 @@ CREATE TABLE IF NOT EXISTS article_comments (
 CREATE TABLE IF NOT EXISTS `service_role_applications` (
 CREATE TABLE IF NOT EXISTS `service_role_applications` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `user_id` int(11) NOT NULL COMMENT '申请人ID',
   `user_id` int(11) NOT NULL COMMENT '申请人ID',
+  `inviter_user_id` bigint(20) DEFAULT NULL COMMENT '邀请人ID(记录级归属:本条申请挂在哪个邀请人名下,来自分享refCode服务端反查;不触发分销副作用)',
   `roles` varchar(255) NOT NULL COMMENT '申请角色,JSON数组 e.g. ["planner","nutritionist"]',
   `roles` varchar(255) NOT NULL COMMENT '申请角色,JSON数组 e.g. ["planner","nutritionist"]',
   `id_card` varchar(18) DEFAULT '' COMMENT '身份证号',
   `id_card` varchar(18) DEFAULT '' COMMENT '身份证号',
   `id_card_images` text COMMENT '身份证照片,JSON数组[正面URL,反面URL]',
   `id_card_images` text COMMENT '身份证照片,JSON数组[正面URL,反面URL]',
@@ -4517,6 +4518,7 @@ CREATE TABLE IF NOT EXISTS `service_role_applications` (
   `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   PRIMARY KEY (`id`),
   PRIMARY KEY (`id`),
   KEY `idx_user_id` (`user_id`),
   KEY `idx_user_id` (`user_id`),
+  KEY `idx_inviter_user_id` (`inviter_user_id`),
   KEY `idx_status` (`status`)
   KEY `idx_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表';
 
 

+ 72 - 3
cfc-frontend/pages/profile-main/profile.vue

@@ -97,8 +97,10 @@
         </view>
         </view>
       </view>
       </view>
 
 
-      <!-- 我的分享海报浮动按钮(纯 CSS 二维码图标,避免 emoji 渲染成计算器) -->
-      <view class="share-qr-float" @click="showShareQr">
+      <!-- 团队邀请浮动按钮(纯 CSS 二维码图标):点按直接调起微信转发,不再生成海报。
+           用透明 <button open-type="share"> 覆盖原入口,保留既有视觉与布局,仅由原生按钮接管点击。
+           分享 path 由页面 onShareAppMessage 返回,直达服务角色申请表并携带本人 refCode。 -->
+      <view class="share-qr-float">
         <view class="qr-glyph">
         <view class="qr-glyph">
           <view class="qr-eye qr-eye-tl"></view>
           <view class="qr-eye qr-eye-tl"></view>
           <view class="qr-eye qr-eye-tr"></view>
           <view class="qr-eye qr-eye-tr"></view>
@@ -111,6 +113,7 @@
             <view class="qr-pip"></view>
             <view class="qr-pip"></view>
           </view>
           </view>
         </view>
         </view>
+        <button class="share-overlay-btn" open-type="share" @tap="ensureShareCode"></button>
       </view>
       </view>
 
 
       <!-- 家族成员速览 -->
       <!-- 家族成员速览 -->
@@ -208,12 +211,14 @@
             <text class="ach-num">{{ referralEarnings }}</text>
             <text class="ach-num">{{ referralEarnings }}</text>
             <text class="ach-label">推广收益</text>
             <text class="ach-label">推广收益</text>
           </view>
           </view>
-          <view class="ach-item" @click="showShareQr">
+          <view class="ach-item ach-item-share">
             <view class="ach-icon-wrap" style="background:rgba(99,102,241,0.12)">
             <view class="ach-icon-wrap" style="background:rgba(99,102,241,0.12)">
               <text class="ach-icon">🤝</text>
               <text class="ach-icon">🤝</text>
             </view>
             </view>
             <text class="ach-num">+</text>
             <text class="ach-num">+</text>
             <text class="ach-label">邀请成员</text>
             <text class="ach-label">邀请成员</text>
+            <!-- 团队邀请:透明原生分享按钮覆盖网格项,点按直接调起微信转发(不再生成海报) -->
+            <button class="share-overlay-btn" open-type="share" @tap="ensureShareCode"></button>
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
@@ -383,6 +388,10 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 import { isActiveMember } from '@/utils/membershipGate.js'
 import { isActiveMember } from '@/utils/membershipGate.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
 
 
+// 团队邀请:本人推广码本地缓存 key(模块级常量,供同步分享钩子使用,不依赖组件 this)。
+// 与用户级 storage.refCode(被邀请人码)、记录级 storage.serviceRoleApplyRefCode 均不同,互不覆盖。
+var TEAM_INVITE_SHARE_CODE_KEY = 'myReferralCode'
+
 export default {
 export default {
   mixins: [fontScaleMixin, shareMixin],
   mixins: [fontScaleMixin, shareMixin],
   components: { ContentSharePoster },
   components: { ContentSharePoster },
@@ -472,12 +481,30 @@ export default {
       return '「' + name + '」邀请你加入浠艾福'
       return '「' + name + '」邀请你加入浠艾福'
     }
     }
   },
   },
+  /**
+   * 团队邀请:页面级微信转发配置(覆盖 share-mixin.js 的同名 hook 返回值)。
+   * 已核实 mp.runtime 的 __call_hook 按 mixin→页面顺序调用并返回最后一个 ret,
+   * 故这里的返回值生效,mixin 的默认首页分享不再覆盖本页邀请 path。
+   * 分享 path 直达服务角色申请表,携带本人推广码作为 refCode(用户级/记录级两级共用此码)。
+   */
+  onShareAppMessage() {
+    // 同步钩子:data 未就绪时读本地缓存兜底,确保 refCode 不为空(否则推荐人丢失)
+    var code = (this.referralCode || '') || uni.getStorageSync(TEAM_INVITE_SHARE_CODE_KEY) || ''
+    return {
+      title: '邀请你加入浠艾福服务团队',
+      path: '/pages/profile/service-role-apply' + (code ? '?refCode=' + code : ''),
+      imageUrl: '/static/logo.png'
+    }
+  },
   onShow() {
   onShow() {
     // 重新读取登录态(登录浮层成功后 mixin 会触发 onShow),驱动页面切换到已登录视图
     // 重新读取登录态(登录浮层成功后 mixin 会触发 onShow),驱动页面切换到已登录视图
     this.isLoggedIn = !!uni.getStorageSync('token')
     this.isLoggedIn = !!uni.getStorageSync('token')
     if (!this.isLoggedIn) {
     if (!this.isLoggedIn) {
       return
       return
     }
     }
+    // 团队邀请:进入即预热本人推广码,保证任意时刻点分享时 refCode 已就绪;
+    // 已缓存则 loadReferralCode 内部直接复用,不重复请求。
+    this.loadReferralCode()
     // 每次进入刷新会员有效态(覆盖从升级/续费页返回)
     // 每次进入刷新会员有效态(覆盖从升级/续费页返回)
     this.refreshMemberActive()
     this.refreshMemberActive()
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
@@ -807,12 +834,26 @@ export default {
       try {
       try {
         var res = await getReferralCode()
         var res = await getReferralCode()
         self.referralCode = extractReferralCode(res) || ''
         self.referralCode = extractReferralCode(res) || ''
+        // 团队邀请:写本地缓存,供同步 onShareAppMessage 在 data 尚未就绪时兜底读,保证 refCode 非空
+        if (self.referralCode) {
+          uni.setStorageSync(TEAM_INVITE_SHARE_CODE_KEY, self.referralCode)
+        }
       } catch (e) {
       } catch (e) {
         console.error('获取推广码失败', e)
         console.error('获取推广码失败', e)
       } finally {
       } finally {
         self.referralCodeLoaded = true
         self.referralCodeLoaded = true
       }
       }
     },
     },
+    // 团队邀请:分享按钮点按时的前置兜底。onShow 已预热缓存,这里再确保一次:
+    // 若本地无码且 data 也为空(如冷启动首帧即点),因 onShareAppMessage 是同步钩子无法 await,
+    // 只能提示用户「请稍候重试」——但正常路径 onShow 预热已完成,此分支仅为极端时序保险。
+    ensureShareCode() {
+      var code = (this.referralCode || '') || uni.getStorageSync(TEAM_INVITE_SHARE_CODE_KEY) || ''
+      if (!code) {
+        uni.showToast({ title: '邀请码准备中,请稍候重试', icon: 'none' })
+        this.loadReferralCode()
+      }
+    },
     goToServiceCenter() {
     goToServiceCenter() {
       uni.navigateTo({ url: '/pages/service-center/index' })
       uni.navigateTo({ url: '/pages/service-center/index' })
     },
     },
@@ -1547,6 +1588,34 @@ $gp-ghost: str-slice($gp-ghost-hex, 2);
   font-weight: 500;
   font-weight: 500;
 }
 }
 
 
+/* 团队邀请:透明原生分享按钮,绝对定位铺满父入口,仅由微信接管点击、不改变任何视觉。
+   彻底重置 uni-app <button> 默认样式(margin/padding/line-height/border/背景),
+   用 opacity:0 而非 display:none(后者会使按钮不可点击)。父入口需为定位上下文。 */
+.share-overlay-btn {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  border: none;
+  border-radius: inherit;
+  background: transparent;
+  line-height: 1;
+  font-size: 0;
+  opacity: 0;
+  z-index: 2;
+}
+.share-overlay-btn::after {
+  border: none;
+}
+/* 网格「邀请成员」项作为透明分享按钮的定位上下文;项本身已是 flex 布局,仅补定位 */
+.ach-item-share {
+  position: relative;
+  overflow: hidden;
+}
+
 /* 分享推广码浮动按钮 */
 /* 分享推广码浮动按钮 */
 .share-qr-float {
 .share-qr-float {
   position: fixed;
   position: fixed;

+ 158 - 8
cfc-frontend/pages/profile/service-role-apply.vue

@@ -125,11 +125,17 @@
         <text>提交后将在 1-3 个工作日内完成审核</text>
         <text>提交后将在 1-3 个工作日内完成审核</text>
       </view>
       </view>
     </view>
     </view>
+
+    <!-- 团队邀请:未登录提交时就地弹出登录浮层,登录后自动续提交。
+         用本地浮层而非 401 → reLaunch 整页登录,是为了不销毁本组件:
+         表单已填数据、inviteRefCode 均保留,登录后回到表单且不丢码。
+         login-sheet 由 main.js 全局注册;全局 loginSheetMixin 的 openLoginSheet 会命中本 ref。 -->
+    <login-sheet ref="loginSheet" @success="onInviteLoginSuccess" @close="onInviteLoginClose" />
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage } from '../../utils/api'
+import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage, bindReferralIfNotSelf } from '../../utils/api'
 import { pickImage } from '../../utils/image-picker.js'
 import { pickImage } from '../../utils/image-picker.js'
 import AddressPicker from '@/components/address-picker.vue'
 import AddressPicker from '@/components/address-picker.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -162,7 +168,24 @@ export default {
       },
       },
       submitting: false,
       submitting: false,
       applyRecord: null,
       applyRecord: null,
-      showStatusView: false
+      showStatusView: false,
+      // 团队邀请:直达链接 /pages/profile/service-role-apply?refCode=xxx 携带的邀请码(记录级归属)。
+      // 用户级推荐人链路的 storage.refCode 会被 user-edit.vue:733 消费并清除,绝不能复用;
+      // 故记录级另存独立 key 'serviceRoleApplyRefCode',与用户级彻底解耦、并存互不覆盖。
+      // 本组件字段用于常规提交;若未登录触发整页登录导致组件销毁,登录后回跳无参时由
+      // onLoad 从独立 key 恢复本字段,保证 refCode 端到端不丢。
+      inviteRefCode: '',
+      // 独立 storage key:记录级归属专用,不会被 user-edit 的用户级消费逻辑清除
+      inviteRefCodeStorageKey: 'serviceRoleApplyRefCode',
+      // 未登录点提交后挂起:浮层登录成功回调里续跑提交,避免用户二次点击
+      pendingSubmitAfterLogin: false,
+      // 用户级推荐人绑定幂等标记(资产一致性):整个流程至多成功绑定一次。
+      // bindReferralIfNotSelf 成功(含自邀 skipped:true)才置 true;首次失败保持 false 以便重试补绑。
+      // handleReapply/handleAddRole 多次提交不重置它,避免重复走 /api/invite/bind 重复发 F24 CF 资产、
+      // 或触发后端 CommissionService:140「已绑定推荐人,不可修改」误报错。
+      userRefBound: false,
+      // 绑定请求防重入锁:避免失败重试弹窗连点「重试绑定」导致并发 bind 请求
+      bindingRef: false
     }
     }
   },
   },
   computed: {
   computed: {
@@ -224,7 +247,19 @@ export default {
       }[this.applyStatus] || ''
       }[this.applyStatus] || ''
     }
     }
   },
   },
-  onLoad() {
+  onLoad(options) {
+    // 团队邀请:解析直达链接的邀请码,存组件字段 + 独立 storage key(记录级归属专用)。
+    // 不用 storage.refCode:那是「用户级推荐人」链路(App.vue 捕获→user-edit.vue:733 消费并清除),
+    // 与记录级彻底解耦、互不覆盖。
+    if (options && options.refCode) {
+      this.inviteRefCode = options.refCode
+      uni.setStorageSync(this.inviteRefCodeStorageKey, options.refCode)
+    } else {
+      // 未登录提交触发整页登录 / 401 reLaunch 时组件会被销毁;登录成功回跳若不带 refCode 参数,
+      // 从独立 key 恢复,确保「登录后回到表单且 inviteRefCode 不丢」。
+      var cached = uni.getStorageSync(this.inviteRefCodeStorageKey)
+      if (cached) this.inviteRefCode = cached
+    }
     this.checkExistingApply()
     this.checkExistingApply()
   },
   },
   methods: {
   methods: {
@@ -378,6 +413,109 @@ export default {
         return
         return
       }
       }
 
 
+      // 团队邀请:未登录时先就地弹登录浮层,登录后自动续提交。
+      // 不走接口层 401 → reLaunch 整页登录的原因:那会销毁本组件、丢失已填表单与 inviteRefCode,
+      // 且登录成功后回不到本表单。就地浮层由本页 <login-sheet> 承载,组件不销毁。
+      if (!uni.getStorageSync('token')) {
+        this.pendingSubmitAfterLogin = true
+        // refCode 一并透传给浮层:未登录态 App.vue 已把分享码存入用户级 storage.refCode,
+        // 这里再以 options.refCode 覆盖同源值(互不冲突),用户级 referrerId 仍由现状链路消费。
+        this.openLoginSheet({ refCode: this.inviteRefCode })
+        return
+      }
+      // 资产一致性(P0,串行):记录级 doSubmit 必须先执行并确认成功,之后才触发用户级绑定。
+      // doSubmit 失败(邀请码无效/查重拒绝/网络失败)会返回 false 并自行 toast,绝不进入绑定、
+      // 绝不给邀请人发 F24 CF 资产;也绝不让绑定链路的空 catch 吞掉 doSubmit 的失败。
+      var submitted = await this.doSubmit(newRoles)
+      if (submitted) {
+        await this.bindUserReferral()
+      }
+    },
+    // 就地浮层登录成功回调:自动续跑被挂起的提交(此时 token 已写入 storage)。
+    // 就地浮层登录不走 user-edit.vue:714 的推荐人消费点、也不触发 App.vue onShow 带参补绑,
+    // 故本页在「记录级申请成功」后主动补绑一次用户级推荐人(product 裁决:用户级走完整分销链路、
+    // 发 F24 CF 值是接受的语义)。绑定制约:①仅在 doSubmit 成功后串行触发,失败绝不发资产;
+    // ②与 user-edit 路径互斥受 userRefBound 幂等标记保护,整个流程至多成功一次,不重复发资产。
+    async onInviteLoginSuccess() {
+      if (!this.pendingSubmitAfterLogin) return
+      this.pendingSubmitAfterLogin = false
+
+      const newRoles = (this.selectedRoles || []).filter(r => this.approvedRoles.indexOf(r) === -1)
+      // 资产一致性(P0,串行):与已登录路径完全同构。先确认记录级申请成功,再绑定用户级推荐人。
+      // doSubmit 失败返回 false 并自行 toast,绝不进入绑定、绝不给邀请人发 CF 资产。
+      var submitted = await this.doSubmit(newRoles)
+      if (submitted) {
+        await this.bindUserReferral()
+      }
+    },
+    // 浮层关闭(含「暂不登录先逛逛」handleSkipLogin):复位挂起标记,
+    // 避免用户放弃登录后标记残留,导致下次提交误走自动续提交分支。
+    onInviteLoginClose() {
+      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「已绑定推荐人」误报错。
+    // 失败可恢复:绑定源 this.inviteRefCode 组件字段全程未清、userRefBound 与 bindingRef 均保持 false,
+    //      配合 modal「重试绑定」回调即可就地重新发起绑定(真实可达的恢复路径)。
+    //      注意:storage.refCode 已成僵尸字段(不参与绑定、清不清都不影响),可重试不依赖它;
+    //      「从邀请链接重新进入」也不是补绑路径——重进会命中 checkExistingApply 的 status=0 记录进入审核态、无补绑入口,
+    //      故离开本页后只能联系邀请人/客服补绑,modal 文案据实说明、不承诺走不通的路径。
+    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)才置幂等标记,并清掉僵尸字段 storage.refCode(不参与绑定,仅为避免残留)。
+          // 可重试的真正保证是 this.inviteRefCode 组件字段未清 + userRefBound=false + modal 回调,与 storage.refCode 无关。
+          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
+        // 失败:userRefBound 保持 false、inviteRefCode 未清,故可就地重试(不依赖 storage.refCode)。
+        // 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、bindingRef 已归 false、inviteRefCode 未清,重试重新发起绑定;成功即置位
+            this.bindUserReferral()
+          }
+        }
+      })
+    },
+    // 返回 boolean:仅当记录级申请真正落库成功(res.code===200)才返回 true。
+    // 调用方据此决定是否触发用户级绑定——这是「资产一致性」的唯一闸门:申请失败绝不发 CF 资产。
+    async doSubmit(newRoles) {
       this.submitting = true
       this.submitting = true
       uni.showLoading({ title: '提交中...' })
       uni.showLoading({ title: '提交中...' })
       try {
       try {
@@ -390,19 +528,31 @@ export default {
           city: this.form.city,
           city: this.form.city,
           district: this.form.district,
           district: this.form.district,
           street: this.form.street,
           street: this.form.street,
-          addressDetail: this.form.addressDetail
+          addressDetail: this.form.addressDetail,
+          // 团队邀请记录级归属:透传直达链接的 refCode,由后端服务端反查邀请人 userId 写
+          // inviter_user_id。不传前端自算 inviterUserId(防冒领),不触发任何分销副作用。
+          refCode: this.inviteRefCode
         })
         })
         uni.hideLoading()
         uni.hideLoading()
         if (res.code === 200) {
         if (res.code === 200) {
-          // 提交成功,刷新状态
+          // 提交成功,刷新状态。此刻邀请归属已落库,清除独立 key 防止下次无码进入时误挂旧邀请人。
+          uni.removeStorageSync(this.inviteRefCodeStorageKey)
           await this.checkExistingApply()
           await this.checkExistingApply()
           if (!this.showStatusView) this.showStatusView = true
           if (!this.showStatusView) this.showStatusView = true
-        } else {
-          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()
-        uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        // 邀请码无效/自邀请等后端业务错误:request() 已 showToast(e.message),这里用同一 message
+        // 再弹一次作双保险(同内容覆盖,不会泛化),确保「邀请码绑定失败必须有可见错误反馈」。
+        // 绝不用「提交失败,请重试」覆盖后端具体原因;401/纯网络异常由 request() 统一提示,此处静默。
+        if (e && e.message && e.code !== 401) {
+          uni.showToast({ title: e.message, icon: 'none' })
+        }
+        return false
       } finally {
       } finally {
         this.submitting = false
         this.submitting = false
       }
       }

+ 15 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1649,6 +1649,21 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 | `certificateImages` | List\<String\> | 资质证书图片 URL 数组 |
 | `certificateImages` | List\<String\> | 资质证书图片 URL 数组 |
 | `province` / `city` / `district` / `street` | String | 四级地址 |
 | `province` / `city` / `district` / `street` | String | 四级地址 |
 | `addressDetail` | String | 详细地址 |
 | `addressDetail` | String | 详细地址 |
+| `refCode` | String | **团队邀请分享码**(来自直达链接 `/pages/profile/service-role-apply?refCode=xxx`),可选。服务端据此反查邀请人写记录级归属 `inviter_user_id`;**不接受前端直传 inviterUserId**(防冒领) |
+
+**记录级邀请归属(团队邀请改造,两级解耦)**
+
+`service_role_applications.inviter_user_id`(`BIGINT NULL`,索引 `idx_inviter_user_id`)记录「本条申请挂在哪个邀请人名下」,与用户级 `users.referrerId`(推荐人=邀请人,由 `bindReferralIfNotSelf` 链路写入)**并存、互不替代**。后端 `submit()` 按 `refCode` **服务端反查** `User::getReferralCode` 得到邀请人 userId 后写入;写入**绝不触发** `CommissionService.applyReferralSideEffects()`(不物化推荐树、不 `directCount+1`、不发 F24 CF 真实资产、不触发 onboarding/milestone/排行榜),以免污染分销佣金体系。查重仍维持全局 `(userId, status=0)` 一条待审不变,每条申请独立判定归属,不跨记录沿用 inviter。
+
+**`POST /api/service-role/apply` 的 `refCode` 三种处理口径(失败显式反馈,绝不静默吞)**
+
+| 场景 | 行为 |
+|------|------|
+| ① 无 `refCode`(用户从个人中心主动进入申请) | 正常受理,`inviter_user_id = NULL`,不报错(兼容现状入口) |
+| ② 带 `refCode` 但反查失败、`refCode` 对应用户不存在,或邀请人 == 申请人(自邀请) | 返回 `Result.error("邀请码无效或不能邀请自己,请通过邀请人的分享链接进入")`(code=500),**拒绝提交、不落库**,前端 toast 可见 |
+| ③ 带有效 `refCode` | 写入 `inviter_user_id = 邀请人 userId`,正常受理 |
+
+> 管理端 `admin/list` 返回行新增 `inviterUserId` 字段(`null` 表示该申请非邀请链路)。本期仅做后台记录归属,不提供「邀请人查看/审批名下申请」的专用接口与页面。
 
 
 **审批通过后的角色落地规则**(`ServiceRoleApplicationServiceImpl.approve`)
 **审批通过后的角色落地规则**(`ServiceRoleApplicationServiceImpl.approve`)