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

feat(team-invite): 团队邀请两级归属 + 分享直达服务角色申请表

用户拍板三项决策落地:
1. 两级归属并存(互不替代)
   - 记录级: service_role_applications.inviter_user_id, 后端按 refCode 服务端
     反查 User.referral_code 写入, 拒绝前端直传 inviterUserId(防冒领)
   - 用户级: users.referrerId, 沿用 App.vue→storage.refCode→bindReferralIfNotSelf
     链路; 就地浮层登录成功后显式消费一次, 保证推荐人=邀请人
2. 注册判据=微信授权建号, 不引入短信验证码, 不碰 register-with-invite
3. 分享直达现有 /pages/profile/service-role-apply?refCode=<码>, 不新建邀请页,
   不加邀请人头像/昵称展示

后端三口径: ①无refCode正常受理(inviter_user_id=NULL) ②带码反查失败/自邀请
Result.error 显式拒绝不落库 ③带有效码写归属。记录级写入绝不调用
CommissionService.applyReferralSideEffects(不物化推荐树/directCount/F24 CF等)。

DDL: schema.sql 新部署建列+索引; DatabaseInitializer 迁移467 存量幂等补列+索引。
前端: profile 两个邀请入口改透明 button open-type=share 覆盖, 页面级
onShareAppMessage 直达申请表并携带本人推广码(data+storage 双缓冲保证非空);
申请页 onLoad 解析 refCode 存独立 key, 提交透传, 未登录就地浮层登录后续提交。

改动 8 文件: 后端 5 / 前端 2 / API_REFERENCE 1。
Agnes Agent 1 день назад
Родитель
Сommit
c97749df66

+ 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;

+ 86 - 6
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,17 @@ 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
     }
     }
   },
   },
   computed: {
   computed: {
@@ -224,7 +240,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 +406,48 @@ 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
+      }
+      await this.doSubmit(newRoles)
+    },
+    // 就地浮层登录成功回调:自动续跑被挂起的提交(此时 token 已写入 storage)。
+    // 用户级 referrerId 由 App.vue onShow / user-edit.vue 的现状链路消费 storage.refCode 写入,
+    // 本页不重复调用 bindReferralIfNotSelf,避免记录级链路触发分销副作用。
+    onInviteLoginSuccess() {
+      if (!this.pendingSubmitAfterLogin) return
+      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)
+      this.doSubmit(newRoles).catch(function() {})
+    },
+    // 浮层关闭(含「暂不登录先逛逛」handleSkipLogin):复位挂起标记,
+    // 避免用户放弃登录后标记残留,导致下次提交误走自动续提交分支。
+    onInviteLoginClose() {
+      this.pendingSubmitAfterLogin = false
+    },
+    async doSubmit(newRoles) {
       this.submitting = true
       this.submitting = true
       uni.showLoading({ title: '提交中...' })
       uni.showLoading({ title: '提交中...' })
       try {
       try {
@@ -390,19 +460,29 @@ 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 {
         } else {
+          // 少数业务码(如 5001 无家庭)会被 request() resolve 而非 reject,此时 request 未弹提示,需在此兜底
           uni.showToast({ title: res.message || '提交失败', icon: 'none' })
           uni.showToast({ title: res.message || '提交失败', icon: 'none' })
         }
         }
       } 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' })
+        }
       } 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`)