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

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 18 часов назад
Родитель
С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 + 索引(今日食谱随成员/视角切换)
         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,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      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 street;
     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)
     private Long id;
     private Long userId;
+    /** 记录级归属:本条申请挂在哪个邀请人名下(来自分享 refCode 服务端反查,不触发分销副作用) */
+    private Long inviterUserId;
     private String roles;
     private String idCard;
     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
     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(
                 new LambdaQueryWrapper<ServiceRoleApplication>()
                         .eq(ServiceRoleApplication::getUserId, userId)
@@ -57,6 +77,7 @@ public class ServiceRoleApplicationServiceImpl
 
         ServiceRoleApplication app = new ServiceRoleApplication();
         app.setUserId(userId);
+        app.setInviterUserId(inviterUserId);
         app.setRoles(JSON.toJSONString(dto.getRoles()));
         app.setIdCard(dto.getIdCard());
         app.setIdCardImages(dto.getIdCardImages() != null ? JSON.toJSONString(dto.getIdCardImages()) : null);
@@ -68,6 +89,8 @@ public class ServiceRoleApplicationServiceImpl
         app.setAddressDetail(dto.getAddressDetail());
         app.setStatus(0);
         this.save(app);
+        // 两级解耦铁律:记录级归属仅落 inviter_user_id 一列,
+        // 绝不调用 CommissionService.applyReferralSideEffects()(会物化推荐树、directCount+1、发 F24 CF 真实资产等)。
         return Result.success("申请提交成功,请等待审核");
     }
 
@@ -258,6 +281,8 @@ public class ServiceRoleApplicationServiceImpl
             Map<String, Object> row = new LinkedHashMap<>();
             row.put("id", app.getId());
             row.put("userId", app.getUserId());
+            // 记录级归属:本条申请挂在哪个邀请人名下(null=用户主动申请,非邀请链路)
+            row.put("inviterUserId", app.getInviterUserId());
             row.put("roles", app.getRoles());
             row.put("idCard", app.getIdCard());
             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` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `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"]',
   `id_card` varchar(18) DEFAULT '' COMMENT '身份证号',
   `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,
   PRIMARY KEY (`id`),
   KEY `idx_user_id` (`user_id`),
+  KEY `idx_inviter_user_id` (`inviter_user_id`),
   KEY `idx_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表';
 

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

@@ -97,8 +97,10 @@
         </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-eye qr-eye-tl"></view>
           <view class="qr-eye qr-eye-tr"></view>
@@ -111,6 +113,7 @@
             <view class="qr-pip"></view>
           </view>
         </view>
+        <button class="share-overlay-btn" open-type="share" @tap="ensureShareCode"></button>
       </view>
 
       <!-- 家族成员速览 -->
@@ -208,12 +211,14 @@
             <text class="ach-num">{{ referralEarnings }}</text>
             <text class="ach-label">推广收益</text>
           </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)">
               <text class="ach-icon">🤝</text>
             </view>
             <text class="ach-num">+</text>
             <text class="ach-label">邀请成员</text>
+            <!-- 团队邀请:透明原生分享按钮覆盖网格项,点按直接调起微信转发(不再生成海报) -->
+            <button class="share-overlay-btn" open-type="share" @tap="ensureShareCode"></button>
           </view>
         </view>
       </view>
@@ -383,6 +388,10 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 import { isActiveMember } from '@/utils/membershipGate.js'
 import shareMixin from '../../components/share-mixin.js'
 
+// 团队邀请:本人推广码本地缓存 key(模块级常量,供同步分享钩子使用,不依赖组件 this)。
+// 与用户级 storage.refCode(被邀请人码)、记录级 storage.serviceRoleApplyRefCode 均不同,互不覆盖。
+var TEAM_INVITE_SHARE_CODE_KEY = 'myReferralCode'
+
 export default {
   mixins: [fontScaleMixin, shareMixin],
   components: { ContentSharePoster },
@@ -472,12 +481,30 @@ export default {
       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() {
     // 重新读取登录态(登录浮层成功后 mixin 会触发 onShow),驱动页面切换到已登录视图
     this.isLoggedIn = !!uni.getStorageSync('token')
     if (!this.isLoggedIn) {
       return
     }
+    // 团队邀请:进入即预热本人推广码,保证任意时刻点分享时 refCode 已就绪;
+    // 已缓存则 loadReferralCode 内部直接复用,不重复请求。
+    this.loadReferralCode()
     // 每次进入刷新会员有效态(覆盖从升级/续费页返回)
     this.refreshMemberActive()
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
@@ -807,12 +834,26 @@ export default {
       try {
         var res = await getReferralCode()
         self.referralCode = extractReferralCode(res) || ''
+        // 团队邀请:写本地缓存,供同步 onShareAppMessage 在 data 尚未就绪时兜底读,保证 refCode 非空
+        if (self.referralCode) {
+          uni.setStorageSync(TEAM_INVITE_SHARE_CODE_KEY, self.referralCode)
+        }
       } catch (e) {
         console.error('获取推广码失败', e)
       } finally {
         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() {
       uni.navigateTo({ url: '/pages/service-center/index' })
     },
@@ -1547,6 +1588,34 @@ $gp-ghost: str-slice($gp-ghost-hex, 2);
   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 {
   position: fixed;

+ 86 - 6
cfc-frontend/pages/profile/service-role-apply.vue

@@ -125,11 +125,17 @@
         <text>提交后将在 1-3 个工作日内完成审核</text>
       </view>
     </view>
+
+    <!-- 团队邀请:未登录提交时就地弹出登录浮层,登录后自动续提交。
+         用本地浮层而非 401 → reLaunch 整页登录,是为了不销毁本组件:
+         表单已填数据、inviteRefCode 均保留,登录后回到表单且不丢码。
+         login-sheet 由 main.js 全局注册;全局 loginSheetMixin 的 openLoginSheet 会命中本 ref。 -->
+    <login-sheet ref="loginSheet" @success="onInviteLoginSuccess" @close="onInviteLoginClose" />
   </view>
 </template>
 
 <script>
-import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage } from '../../utils/api'
+import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage, bindReferralIfNotSelf } from '../../utils/api'
 import { pickImage } from '../../utils/image-picker.js'
 import AddressPicker from '@/components/address-picker.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -162,7 +168,17 @@ export default {
       },
       submitting: false,
       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: {
@@ -224,7 +240,19 @@ export default {
       }[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()
   },
   methods: {
@@ -378,6 +406,48 @@ export default {
         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
       uni.showLoading({ title: '提交中...' })
       try {
@@ -390,19 +460,29 @@ export default {
           city: this.form.city,
           district: this.form.district,
           street: this.form.street,
-          addressDetail: this.form.addressDetail
+          addressDetail: this.form.addressDetail,
+          // 团队邀请记录级归属:透传直达链接的 refCode,由后端服务端反查邀请人 userId 写
+          // inviter_user_id。不传前端自算 inviterUserId(防冒领),不触发任何分销副作用。
+          refCode: this.inviteRefCode
         })
         uni.hideLoading()
         if (res.code === 200) {
-          // 提交成功,刷新状态
+          // 提交成功,刷新状态。此刻邀请归属已落库,清除独立 key 防止下次无码进入时误挂旧邀请人。
+          uni.removeStorageSync(this.inviteRefCodeStorageKey)
           await this.checkExistingApply()
           if (!this.showStatusView) this.showStatusView = true
         } else {
+          // 少数业务码(如 5001 无家庭)会被 request() resolve 而非 reject,此时 request 未弹提示,需在此兜底
           uni.showToast({ title: res.message || '提交失败', icon: 'none' })
         }
       } catch (e) {
         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 {
         this.submitting = false
       }

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

@@ -1649,6 +1649,21 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 | `certificateImages` | List\<String\> | 资质证书图片 URL 数组 |
 | `province` / `city` / `district` / `street` | 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`)