Przeglądaj źródła

feat(family): 家庭加入申请改为邀请人审批模式,谁分享的卡谁确认

- FamilyJoinRequest 新增 inviter_user_id,token 路径绑定分&入人(createdBy)
- approve/reject 权限改为邀请人(须仍为目标家庭成员),旧数据无邀请人由家庭管理员兜底
- approve 通过后刷新家庭通关解锁,与 /accept 直加路径一致
- getPendingRequests 改按邀请人查询,控制器去掉 familyId/admin 校验,透传 token
- 迁移262: family_join_requests 添加 inviter_user_id 列 + idx_inviter 索引
- 前端:join.vue 文案改等待邀请人确认+进入回显待确认申请+取消申请按钮
- family-members.vue 新增加入申请审批区块(同意/拒绝),按邀请人过滤
- 同步 API_REFERENCE.md 与 api.js 注释语义
liaoxg 2 miesięcy temu
rodzic
commit
e26a809ef0

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

@@ -9190,6 +9190,15 @@ private void runMigration100() {
 		// 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
 		ensureColumn("butler_profiles", "accepting",
 				"TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");
+		// 迁移262: family_join_requests 添加 inviter_user_id 列(审批人=邀请人,谁分享的卡谁确认)
+		try {
+			jdbcTemplate.execute("ALTER TABLE family_join_requests ADD COLUMN inviter_user_id BIGINT NULL COMMENT '邀请人用户ID(分享邀请令牌的人)' AFTER invite_ref");
+			jdbcTemplate.execute("ALTER TABLE family_join_requests ADD INDEX idx_inviter (inviter_user_id)");
+			log.info("已添加inviter_user_id列到family_join_requests表");
+		} catch (Exception e) {
+			// 列/索引已存在,忽略错误
+			log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage());
+		}
 	}
 
 	/**

+ 20 - 28
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -104,7 +104,7 @@ public class FamilyInviteController {
     }
 
     /**
-     * 通过邀请码/令牌提交加入家庭请求(需管理员审批)
+     * 通过邀请码/令牌提交加入家庭请求(令牌路径绑定邀请人,等待邀请人确认;旧邀请码路径由家庭管理员兜底审批)
      */
     @Operation(summary = "通过邀请码申请加入家庭")
     @PostMapping("/request-join-by-code")
@@ -119,16 +119,11 @@ public class FamilyInviteController {
         // 获取申请人角色
         String familyRole = body.get("familyRole");
 
-        // 兼容邀请令牌(token):通过 token 解析到 familyId
+        // 兼容邀请令牌(token):直接透传原始 token,服务层解析并绑定邀请人(审批人)
         if (inviteCode == null || inviteCode.trim().isEmpty()) {
             String token = body.get("token");
             if (token != null && !token.trim().isEmpty()) {
-                try {
-                    InviteValidateDTO dto = familyInvitationService.validateInvitation(token);
-                    inviteCode = String.valueOf(dto.getFamilyId());
-                } catch (Exception e) {
-                    return Result.error("邀请令牌无效");
-                }
+                inviteCode = token;
             }
         }
 
@@ -142,20 +137,20 @@ public class FamilyInviteController {
             Map<String, Object> result = new HashMap<>();
             result.put("requestId", request.getId());
             result.put("familyName", family != null ? family.getName() : "");
-            return Result.success("申请已提交,等待家庭管理员审批", result);
+            return Result.success("申请已提交,等待邀请人确认", result);
         } catch (Exception e) {
             return Result.error(e.getMessage());
         }
     }
 
     /**
-     * 管理员审批加入请求
+     * 邀请人审批加入请求(谁分享的卡谁确认;旧数据无邀请人时由家庭管理员兜底)
      */
     @Operation(summary = "审批加入请求")
     @PostMapping("/approve-request")
     public Result<Void> approveJoinRequest(@RequestBody Map<String, Object> body,
-        @RequestAttribute("userId") Long adminUserId) {
-        if (adminUserId == null) {
+        @RequestAttribute("userId") Long approverUserId) {
+        if (approverUserId == null) {
             return Result.error("请先登录");
         }
         Long requestId = ParamUtils.getLong(body.get("requestId"));
@@ -164,7 +159,7 @@ public class FamilyInviteController {
             return Result.error("请求ID不能为空");
         }
         try {
-            familyJoinRequestService.approveJoinRequest(requestId, adminUserId, comment);
+            familyJoinRequestService.approveJoinRequest(requestId, approverUserId, comment);
             return Result.success();
         } catch (Exception e) {
             return Result.error(e.getMessage());
@@ -172,13 +167,13 @@ public class FamilyInviteController {
     }
 
     /**
-     * 管理员拒绝加入请求
+     * 邀请人拒绝加入请求(旧数据无邀请人时由家庭管理员兜底)
      */
     @Operation(summary = "拒绝加入请求")
     @PostMapping("/reject-request")
     public Result<Void> rejectJoinRequest(@RequestBody Map<String, Object> body,
-        @RequestAttribute("userId") Long adminUserId) {
-        if (adminUserId == null) {
+        @RequestAttribute("userId") Long approverUserId) {
+        if (approverUserId == null) {
             return Result.error("请先登录");
         }
         Long requestId = ParamUtils.getLong(body.get("requestId"));
@@ -187,7 +182,7 @@ public class FamilyInviteController {
             return Result.error("请求ID不能为空");
         }
         try {
-            familyJoinRequestService.rejectJoinRequest(requestId, adminUserId, comment);
+            familyJoinRequestService.rejectJoinRequest(requestId, approverUserId, comment);
             return Result.success();
         } catch (Exception e) {
             return Result.error(e.getMessage());
@@ -195,23 +190,20 @@ public class FamilyInviteController {
     }
 
     /**
-     * 查询家庭待审批请求列表
+     * 查询待我处理的加入申请(我是邀请人的;旧数据未绑定邀请人的由目标家庭管理员兜底可见)
      */
-    @Operation(summary = "查询待审批加入请求")
+    @Operation(summary = "查询待我处理的加入申请")
     @PostMapping("/pending-requests")
     public Result<java.util.List<com.etotem.cfc.dto.FamilyJoinRequestDTO>> getPendingRequests(
-        @RequestAttribute(value = "familyId", required = false) Long familyId,
         @RequestAttribute("userId") Long userId) {
-        if (familyId == null) {
-            return Result.noFamily("请先创建或加入家庭");
+        if (userId == null) {
+            return Result.error("请先登录");
         }
-        // 验证管理员身份
-        User admin = userMapper.selectById(userId);
-        if (admin == null || !admin.getFamilyId().equals(familyId)
-                || !Boolean.TRUE.equals(admin.getIsFamilyAdmin())) {
-            return Result.error("只有家庭管理员才能查看待审批请求");
+        try {
+            return Result.success(familyJoinRequestService.getPendingRequests(userId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
         }
-        return Result.success(familyJoinRequestService.getPendingRequests(familyId));
     }
 
     /**

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyJoinRequestDTO.java

@@ -11,7 +11,9 @@ public class FamilyJoinRequestDTO implements Serializable {
     private Long id;
     private Long requesterUserId;
     private String requesterNickname;
+    private String requesterAvatar;
     private String inviteCode;
+    private Long inviterUserId;
     private Long targetFamilyId;
     private String targetFamilyName;
     private String status; // pending / approved / rejected

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyJoinRequest.java

@@ -29,10 +29,13 @@ public class FamilyJoinRequest implements Serializable {
     /** 邀请码或令牌 */
     private String inviteRef;
 
+    /** 邀请人用户ID(分享邀请令牌的人,审批由其确认;旧数据/邀请码路径为空) */
+    private Long inviterUserId;
+
     /** 状态:pending / approved / rejected */
     private String status;
 
-    /** 审批人(family admin)ID */
+    /** 审批人ID(邀请人或旧数据场景下的家庭管理员) */
     private Long approverUserId;
 
     /** 审批备注 */

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

@@ -37,11 +37,16 @@ public class FamilyJoinRequestService {
     @Resource
     private FamilyInvitationService familyInvitationService;
 
+    @Resource
+    private UnlockGateService unlockGateService;
+
     /**
      * 创建家庭加入请求(待审批)
      */
     public FamilyJoinRequest createJoinRequest(Long userId, String inviteCode, String familyRole) {
         Family family;
+        Long inviterUserId = null;
+        String joinMethod = "by_code";
 
         // 支持传入 familyId(数字)作为 inviteCode
         try {
@@ -68,6 +73,9 @@ public class FamilyJoinRequestService {
                 if (family == null) {
                     throw new RuntimeException("邀请令牌无效");
                 }
+                // 审批绑定邀请人:谁分享的令牌,申请就由谁确认
+                inviterUserId = invitation.getCreatedBy();
+                joinMethod = "by_token";
             } else {
                 family = familyMapper.selectOne(
                         new LambdaQueryWrapper<Family>()
@@ -104,7 +112,8 @@ public class FamilyJoinRequestService {
         FamilyJoinRequest request = new FamilyJoinRequest();
         request.setRequesterUserId(userId);
         request.setTargetFamilyId(family.getId());
-        request.setJoinMethod("by_code");
+        request.setInviterUserId(inviterUserId);
+        request.setJoinMethod(joinMethod);
         request.setInviteRef(inviteCode.trim());
         request.setFamilyRole(familyRole);
         request.setStatus("pending");
@@ -112,14 +121,15 @@ public class FamilyJoinRequestService {
         request.setUpdatedAt(new Date());
         familyJoinRequestMapper.insert(request);
 
-        log.info("已创建家庭加入请求: userId={}, familyId={}, requestId={}", userId, family.getId(), request.getId());
+        log.info("已创建家庭加入请求: userId={}, familyId={}, inviterUserId={}, requestId={}",
+                userId, family.getId(), inviterUserId, request.getId());
         return request;
     }
 
     /**
-     * 家庭管理员审批加入请求
+     * 审批通过加入请求(审批人=邀请人,谁分享的卡谁确认;旧数据无邀请人时回退为家庭管理员)
      */
-    public void approveJoinRequest(Long requestId, Long adminUserId, String comment) {
+    public void approveJoinRequest(Long requestId, Long approverUserId, String comment) {
         FamilyJoinRequest request = familyJoinRequestMapper.selectById(requestId);
         if (request == null) {
             throw new RuntimeException("加入请求不存在");
@@ -128,16 +138,12 @@ public class FamilyJoinRequestService {
             throw new RuntimeException("该请求已处理");
         }
 
-        // 验证管理员身份
         Family family = familyMapper.selectById(request.getTargetFamilyId());
         if (family == null) {
             throw new RuntimeException("目标家庭不存在");
         }
-        User admin = userMapper.selectById(adminUserId);
-        if (admin == null || !admin.getFamilyId().equals(family.getId())
-                || !Boolean.TRUE.equals(admin.getIsFamilyAdmin())) {
-            throw new RuntimeException("只有家庭管理员才能审批");
-        }
+        // 验证审批人权限(邀请人,或旧数据兜底的家庭管理员)
+        checkApprovePermission(request, approverUserId);
 
         // 审批通过:将用户加入家庭
         User user = userMapper.selectById(request.getRequesterUserId());
@@ -159,18 +165,25 @@ public class FamilyJoinRequestService {
         }
 
         request.setStatus("approved");
-        request.setApproverUserId(adminUserId);
+        request.setApproverUserId(approverUserId);
         request.setComment(comment);
         request.setUpdatedAt(new Date());
         familyJoinRequestMapper.updateById(request);
 
-        log.info("已审批通过加入请求: requestId={}, userId={}, adminId={}", requestId, user.getId(), adminUserId);
+        // 与 /accept 直加路径保持一致:成员变更后刷新家庭通关解锁
+        try {
+            unlockGateService.refreshFamilyGates(family.getId(), user.getId());
+        } catch (Exception e) {
+            log.warn("刷新家庭通关解锁失败(不阻断审批): requestId={}, error={}", requestId, e.getMessage());
+        }
+
+        log.info("已审批通过加入请求: requestId={}, userId={}, approverId={}", requestId, user.getId(), approverUserId);
     }
 
     /**
-     * 家庭管理员拒绝加入请求
+     * 拒绝加入请求(审批人=邀请人;旧数据无邀请人时回退为家庭管理员)
      */
-    public void rejectJoinRequest(Long requestId, Long adminUserId, String comment) {
+    public void rejectJoinRequest(Long requestId, Long approverUserId, String comment) {
         FamilyJoinRequest request = familyJoinRequestMapper.selectById(requestId);
         if (request == null) {
             throw new RuntimeException("加入请求不存在");
@@ -179,37 +192,41 @@ public class FamilyJoinRequestService {
             throw new RuntimeException("该请求已处理");
         }
 
-        // 验证管理员身份
-        Family family = familyMapper.selectById(request.getTargetFamilyId());
-        if (family == null) {
-            throw new RuntimeException("目标家庭不存在");
-        }
-        User admin = userMapper.selectById(adminUserId);
-        if (admin == null || !admin.getFamilyId().equals(family.getId())
-                || !Boolean.TRUE.equals(admin.getIsFamilyAdmin())) {
-            throw new RuntimeException("只有家庭管理员才能审批");
-        }
+        // 验证审批人权限(邀请人,或旧数据兜底的家庭管理员)
+        checkApprovePermission(request, approverUserId);
 
         request.setStatus("rejected");
-        request.setApproverUserId(adminUserId);
+        request.setApproverUserId(approverUserId);
         request.setComment(comment);
         request.setUpdatedAt(new Date());
         familyJoinRequestMapper.updateById(request);
 
-        log.info("已拒绝加入请求: requestId={}, userId={}, adminId={}, comment={}",
-                requestId, request.getRequesterUserId(), adminUserId, comment);
+        log.info("已拒绝加入请求: requestId={}, userId={}, approverId={}, comment={}",
+                requestId, request.getRequesterUserId(), approverUserId, comment);
     }
 
     /**
-     * 查询家庭待审批请求列表
+     * 查询待我处理的加入申请列表:
+     * 新数据按邀请人查询(谁分享的卡,申请出现在谁的列表);
+     * 旧数据未绑定邀请人的,由目标家庭管理员兜底可见。
      */
-    public List<FamilyJoinRequestDTO> getPendingRequests(Long familyId) {
-        List<FamilyJoinRequest> requests = familyJoinRequestMapper.selectList(
-                new LambdaQueryWrapper<FamilyJoinRequest>()
-                        .eq(FamilyJoinRequest::getTargetFamilyId, familyId)
-                        .eq(FamilyJoinRequest::getStatus, "pending")
-                        .orderByDesc(FamilyJoinRequest::getCreatedAt)
-        );
+    public List<FamilyJoinRequestDTO> getPendingRequests(Long viewerUserId) {
+        User viewer = userMapper.selectById(viewerUserId);
+        if (viewer == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        boolean adminFallback = Boolean.TRUE.equals(viewer.getIsFamilyAdmin()) && viewer.getFamilyId() != null;
+        LambdaQueryWrapper<FamilyJoinRequest> wrapper = new LambdaQueryWrapper<FamilyJoinRequest>()
+                .eq(FamilyJoinRequest::getStatus, "pending")
+                .and(w -> {
+                    w.eq(FamilyJoinRequest::getInviterUserId, viewerUserId);
+                    if (adminFallback) {
+                        w.or(o -> o.isNull(FamilyJoinRequest::getInviterUserId)
+                                .eq(FamilyJoinRequest::getTargetFamilyId, viewer.getFamilyId()));
+                    }
+                })
+                .orderByDesc(FamilyJoinRequest::getCreatedAt);
+        List<FamilyJoinRequest> requests = familyJoinRequestMapper.selectList(wrapper);
 
         return requests.stream().map(req -> {
             User user = userMapper.selectById(req.getRequesterUserId());
@@ -217,6 +234,8 @@ public class FamilyJoinRequestService {
             dto.setId(req.getId());
             dto.setRequesterUserId(req.getRequesterUserId());
             dto.setRequesterNickname(user != null ? user.getNickname() : "未知用户");
+            dto.setRequesterAvatar(user != null ? user.getAvatar() : "");
+            dto.setInviterUserId(req.getInviterUserId());
             dto.setInviteCode(req.getInviteRef());
             dto.setTargetFamilyId(req.getTargetFamilyId());
             Family family = familyMapper.selectById(req.getTargetFamilyId());
@@ -273,4 +292,33 @@ public class FamilyJoinRequestService {
         req.setUpdatedAt(new Date());
         familyJoinRequestMapper.updateById(req);
     }
+
+    /**
+     * 校验审批人权限:
+     * 申请绑定邀请人的 → 仅该邀请人(且仍为目标家庭成员)可处理;
+     * 未绑定邀请人的旧数据 → 回退为原家庭管理员规则。
+     */
+    private void checkApprovePermission(FamilyJoinRequest request, Long approverUserId) {
+        User approver = userMapper.selectById(approverUserId);
+        if (approver == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        Family family = familyMapper.selectById(request.getTargetFamilyId());
+        if (family == null) {
+            throw new RuntimeException("目标家庭不存在");
+        }
+        boolean isMember = approver.getFamilyId() != null && approver.getFamilyId().equals(request.getTargetFamilyId());
+        boolean allowed;
+        if (request.getInviterUserId() != null) {
+            allowed = request.getInviterUserId().equals(approverUserId) && isMember;
+        } else {
+            // 旧数据兼容:未绑定邀请人时按原家庭管理员规则
+            allowed = isMember && Boolean.TRUE.equals(approver.getIsFamilyAdmin());
+        }
+        if (!allowed) {
+            throw new RuntimeException(request.getInviterUserId() != null
+                    ? "只有邀请人才能处理该申请"
+                    : "只有家庭管理员才能审批");
+        }
+    }
 }

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

@@ -4342,13 +4342,14 @@ CREATE TABLE IF NOT EXISTS coupon_grant_log (
     INDEX idx_coupon (coupon_id),
     INDEX idx_user (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表';
--- 家庭加入请求(待管理员审批)
+-- 家庭加入请求(邀请人审批,旧数据由家庭管理员兜底)
 CREATE TABLE IF NOT EXISTS family_join_requests (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     requester_user_id BIGINT NOT NULL COMMENT '申请加入的用户ID',
     target_family_id BIGINT NOT NULL COMMENT '目标家庭ID',
     join_method VARCHAR(20) NOT NULL COMMENT '加入方式: by_code/by_token',
     invite_ref VARCHAR(50) COMMENT '邀请码或令牌',
+    inviter_user_id BIGINT COMMENT '邀请人用户ID(分享邀请令牌的人)',
     status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected/cancelled',
     approver_user_id BIGINT COMMENT '审批人ID',
     comment TEXT COMMENT '审批备注',
@@ -4356,7 +4357,8 @@ CREATE TABLE IF NOT EXISTS family_join_requests (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_requester (requester_user_id),
     INDEX idx_target_family (target_family_id),
-    INDEX idx_status (status)
+    INDEX idx_status (status),
+    INDEX idx_inviter (inviter_user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭加入请求(待审批)';
 
 -- 用户同意记录表

+ 68 - 4
cfc-frontend/pages/invite/join.vue

@@ -34,7 +34,7 @@
           <text v-if="!loginLoading">微信一键登录并加入家庭</text>
           <text v-else>登录中...</text>
         </button>
-        <text class="tip-text">登录后将自动加入此家庭</text>
+        <text class="tip-text">登录后即可申请加入此家庭</text>
       </view>
 
       <!-- 已登录:显示关系选择 -->
@@ -78,8 +78,9 @@
       <view class="pending-card" v-if="requestSubmitted && !accepted">
         <view class="pending-icon">🕐</view>
         <text class="pending-title">申请已提交</text>
-        <text class="pending-text">等待家庭管理员审批</text>
+        <text class="pending-text">等待邀请人确认</text>
         <text class="pending-hint">通常几分钟内会有回复</text>
+        <button class="btn btn-outline btn-cancel-request" @tap="onCancelRequest">取消申请</button>
       </view>
 
       <!-- 申请被拒绝 -->
@@ -144,7 +145,7 @@
 </template>
 
 <script>
-import { validateInvitation, checkUserFamily, bindReferral, requestJoinByCode, getMyPendingRequest } from '@/utils/api.js'
+import { validateInvitation, checkUserFamily, bindReferral, requestJoinByCode, getMyPendingRequest, cancelMyJoinRequest } from '@/utils/api.js'
 
 export default {
   data() {
@@ -164,6 +165,7 @@ export default {
       accepted: false,
       requestSubmitted: false,
       requestStatus: '',
+      requestId: null,
       requestTimer: null,
       rejectMessage: '',
       error: '',
@@ -247,6 +249,10 @@ export default {
         var res = await validateInvitation(this.token)
         if (res && res.data) {
           this.inviteInfo = res.data
+          // 已登录用户进入落地页:回显已有的待确认申请(避免重复显示提交表单)
+          if (this.isLoggedIn) {
+            this.checkExistingRequest()
+          }
         } else {
           this.error = '邀请已失效'
         }
@@ -268,6 +274,9 @@ export default {
           // 如果已经在目标家庭,直接显示成功
           if (this.inviteInfo && this.familyStatus.inFamily && this.familyStatus.familyId === this.inviteInfo.familyId) {
             this.accepted = true
+          } else if (!this.familyStatus.inFamily && this.inviteInfo) {
+            // 已登录且不在目标家庭:回显已有申请状态,避免重复显示提交表单
+            this.checkExistingRequest()
           }
         }
       } catch (e) {
@@ -346,8 +355,9 @@ export default {
         if (res && res.code === 200) {
           this.requestSubmitted = true
           this.requestStatus = 'pending'
+          this.requestId = (res.data && res.data.id) || null
           this.acceptLoading = false
-          uni.showToast({ title: '申请已提交,等待审批', icon: 'success' })
+          uni.showToast({ title: '申请已提交,等待邀请人确认', icon: 'success' })
           this.startPolling()
         } else {
           this.acceptLoading = false
@@ -377,6 +387,7 @@ export default {
         var res = await getMyPendingRequest()
         if (res && res.code === 200 && res.data) {
           var requestData = res.data
+          this.requestId = requestData.id || this.requestId
           this.requestStatus = requestData.status || 'pending'
           if (this.requestStatus === 'approved') {
             clearInterval(this.requestTimer)
@@ -398,6 +409,59 @@ export default {
         console.log('轮询请求状态失败', e)
       }
     },
+    async checkExistingRequest() {
+      // 回显守卫:仅在已登录、未提交、未成功且不在目标家庭时查询
+      if (!this.inviteInfo || this.requestSubmitted || this.accepted) return
+      if (this.familyStatus && this.familyStatus.inFamily && this.familyStatus.familyId === this.inviteInfo.familyId) return
+      try {
+        var res = await getMyPendingRequest()
+        if (res && res.code === 200 && res.data && res.data.status === 'pending') {
+          var requestData = res.data
+          if (requestData.targetFamilyId && requestData.targetFamilyId !== this.inviteInfo.familyId) return
+          this.requestId = requestData.id || null
+          this.requestSubmitted = true
+          this.requestStatus = 'pending'
+          this.startPolling()
+        }
+      } catch (e) {
+        console.log('回显加入申请状态失败', e)
+      }
+    },
+    onCancelRequest() {
+      var self = this
+      uni.showModal({
+        title: '取消申请',
+        content: '确定取消本次加入申请吗?',
+        success: function(mr) {
+          if (mr.confirm) {
+            self.doCancelRequest()
+          }
+        }
+      })
+    },
+    async doCancelRequest() {
+      if (!this.requestId) {
+        uni.showToast({ title: '未找到申请记录', icon: 'none' })
+        return
+      }
+      try {
+        var res = await cancelMyJoinRequest(this.requestId)
+        if (res && res.code === 200) {
+          if (this.requestTimer) {
+            clearInterval(this.requestTimer)
+            this.requestTimer = null
+          }
+          this.requestSubmitted = false
+          this.requestStatus = ''
+          this.requestId = null
+          uni.showToast({ title: '已取消申请', icon: 'success' })
+        } else {
+          uni.showToast({ title: (res && res.message) || '取消失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: e.message || '取消失败', icon: 'none' })
+      }
+    },
     goHome() {
       uni.reLaunch({ url: '/pages/index-home/index' })
     },

+ 149 - 2
cfc-frontend/pages/profile-extra/family-members.vue

@@ -11,6 +11,25 @@
       </view>
     </view>
 
+    <!-- ===== 加入申请(我分享的邀请,由我确认) ===== -->
+    <view class="join-request-section" v-if="joinRequests.length > 0">
+      <view class="jr-header">
+        <text class="jr-title">📨 加入申请</text>
+        <text class="jr-count">{{ joinRequests.length }} 条待确认</text>
+      </view>
+      <view class="jr-item" v-for="r in joinRequests" :key="getJoinRequestKey(r)">
+        <image class="jr-avatar" :src="r.requesterAvatar || '/static/default-avatar.png'" mode="aspectFill"></image>
+        <view class="jr-info">
+          <text class="jr-name">{{ r.requesterNickname || '微信用户' }}</text>
+          <text class="jr-meta">希望以「{{ r.familyRole || '成员' }}」身份加入</text>
+        </view>
+        <view class="jr-actions">
+          <button class="jr-btn jr-accept" @click="onHandleJoinRequest(r, true)">同意</button>
+          <button class="jr-btn jr-reject" @click="onHandleJoinRequest(r, false)">拒绝</button>
+        </view>
+      </view>
+    </view>
+
     <!-- ===== 成员卡片列表 ===== -->
     <view class="member-list" v-if="members.length > 0">
       <view class="member-card" v-for="m in members" :key="m.id" @click="showMemberDetail(m)">
@@ -106,7 +125,10 @@ import {
   switchToFamilyMember,
   kickFamilyMemberById,
   generateFamilyInvite,
-  updateFamilyMember
+  updateFamilyMember,
+  getPendingJoinRequests,
+  approveJoinRequest,
+  rejectJoinRequest
 } from '../../utils/api.js'
 import store from '../../store/index.js'
 
@@ -129,7 +151,8 @@ export default {
       userNickname: '',
       userAvatar: '',
 
-      inviteToken: ''
+      inviteToken: '',
+      joinRequests: []
     }
   },
   computed: {
@@ -151,6 +174,8 @@ export default {
     this.loadMembers()
     // 预取邀请令牌(后端复用同家庭未用尽的有效令牌),保证点击分享时立即可用
     this.prepareInviteToken()
+    // 拉取待我确认的加入申请(后端按邀请人过滤,无需管理员身份)
+    this.loadJoinRequests()
   },
   // 页面级分享:转发邀请卡片,接收人点击直接进入加入家庭页(信息补充、家庭申请)
   onShareAppMessage() {
@@ -319,6 +344,53 @@ export default {
     },
     closeMemberDetail() {
       this.showMemberDetailModal = false
+    },
+    async loadJoinRequests() {
+      try {
+        var res = await getPendingJoinRequests()
+        if (res && res.code === 200 && res.data instanceof Array) {
+          this.joinRequests = res.data
+        } else {
+          this.joinRequests = []
+        }
+      } catch (e) {
+        this.joinRequests = []
+      }
+    },
+    getJoinRequestKey(r) {
+      return r.id || r.requesterUserId
+    },
+    onHandleJoinRequest(r, approve) {
+      var self = this
+      var nickname = r.requesterNickname || '该用户'
+      uni.showModal({
+        title: approve ? '同意加入' : '拒绝申请',
+        content: approve ? ('确定同意 ' + nickname + ' 加入家庭吗?') : ('确定拒绝 ' + nickname + ' 的加入申请吗?'),
+        success: function(mr) {
+          if (mr.confirm) {
+            self.doHandleJoinRequest(r, approve)
+          }
+        }
+      })
+    },
+    async doHandleJoinRequest(r, approve) {
+      var self = this
+      try {
+        var action = approve ? approveJoinRequest : rejectJoinRequest
+        var res = await action(r.id, '')
+        if (res && res.code === 200) {
+          uni.showToast({ title: approve ? '已同意加入' : '已拒绝', icon: 'success' })
+          self.loadJoinRequests()
+          if (approve) {
+            self.loadMembers()
+          }
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+          self.loadJoinRequests()
+        }
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      }
     }
   }
 }
@@ -381,6 +453,81 @@ export default {
 .member-card:active {
   background: #FFFCF5;
 }
+
+/* ===== 加入申请区块 ===== */
+.join-request-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.jr-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.jr-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.jr-count {
+  font-size: 24rpx;
+  color: #EA580C;
+}
+.jr-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+  border-top: 1rpx solid #F0EAE2;
+}
+.jr-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: #f0f0f0;
+  flex-shrink: 0;
+  margin-right: 18rpx;
+}
+.jr-info {
+  flex: 1;
+  min-width: 0;
+}
+.jr-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  display: block;
+}
+.jr-meta {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.jr-actions {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+}
+.jr-btn {
+  font-size: 24rpx;
+  padding: 10rpx 24rpx;
+  border-radius: 999rpx;
+  line-height: 1.4;
+  border: none;
+  margin-left: 12rpx;
+}
+.jr-accept {
+  background: #F97316;
+  color: #fff;
+}
+.jr-reject {
+  background: #FFF1E8;
+  color: #C2410C;
+}
 .member-avatar {
   width: 80rpx;
   height: 80rpx;

+ 4 - 4
cfc-frontend/utils/api.js

@@ -1220,22 +1220,22 @@ export const transferFamilyAdmin = (newAdminId) => {
   return request('/api/family/invite/transfer-admin', 'POST', { newAdminId })
 }
 
-// 通过邀请码申请加入家庭(需管理员审批)
+// 通过邀请码/令牌申请加入家庭(token 路径绑定邀请人,等待邀请人确认;旧码路径管理员兜底)
 export const requestJoinByCode = (inviteCode, familyRole) => {
   return request('/api/family/invite/request-join-by-code', 'POST', { inviteCode: inviteCode, familyRole: familyRole || '' })
 }
 
-// 查询待审批加入请求(管理员)
+// 查询待我处理的加入请求(邀请人视角;旧数据由家庭管理员兜底)
 export const getPendingJoinRequests = () => {
   return request('/api/family/invite/pending-requests', 'POST', {})
 }
 
-// 审批加入请求(管理员)
+// 审批通过加入请求(邀请人确认;旧数据无邀请人时家庭管理员兜底)
 export const approveJoinRequest = (requestId, comment) => {
   return request('/api/family/invite/approve-request', 'POST', { requestId: requestId, comment: comment || '' })
 }
 
-// 拒绝加入请求(管理员)
+// 拒绝加入请求(邀请人;旧数据无邀请人时家庭管理员兜底)
 export const rejectJoinRequest = (requestId, comment) => {
   return request('/api/family/invite/reject-request', 'POST', { requestId: requestId, comment: comment || '' })
 }

+ 4 - 4
docs/superpowers/api/API_REFERENCE.md

@@ -163,10 +163,10 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/family/member/editable` | 检查是否可编辑 | — |
 | `POST /api/family/member/logs` | 成员变更日志 | — |
 | `POST /api/family/invite/qrcode` | 生成邀请二维码 | — |
-| `POST /api/family/invite/request-join-by-code` | 申请加入 | inviteCode | — |
-| `POST /api/family/invite/approve-request` | 审批加入申请 | — |
-| `POST /api/family/invite/reject-request` | 拒绝加入申请 | — |
-| `POST /api/family/invite/pending-requests` | 待审批列表 | — |
+| `POST /api/family/invite/request-join-by-code` | 申请加入(token 路径绑定邀请人,等待邀请人确认;旧码路径管理员兜底) | inviteCode 或 token + familyRole | — |
+| `POST /api/family/invite/approve-request` | 同意加入申请(审批人=邀请人且仍为目标家庭成员;旧数据无邀请人时家庭管理员兜底) | — |
+| `POST /api/family/invite/reject-request` | 拒绝加入申请(权限规则同上) | — |
+| `POST /api/family/invite/pending-requests` | 待我处理的申请列表(按邀请人查询;旧数据由目标家庭管理员兜底可见) | — |
 | `POST /api/family/invite/my-request` | 我的申请 | — |
 | `POST /api/family/invite/cancel-request` | 取消申请 | — |
 | `POST /api/family/invite/generate` | 生成邀请令牌(复用同家庭未过期未用尽的有效令牌;仅未过期记录计入 10 条额度) | — |