Pārlūkot izejas kodu

feat(admin): 用户管理新增「推荐团队」查看(上级/直推/间推/团队树)

- 新增 /api/admin/referral/{summary,members,tree},按指定 userId 查询推荐关系(仅后台角色)
- summary:用户基本信息 + 上级推荐人 + 直推/间推人数 + 团队累计佣金(复用 CommissionService 口径)
- members:L1 直推 / L2 间推分页(昵称、注册时间、消费额、贡献佣金)
- tree:按 users.referrer_id 逐层 BFS 构树(1-8 层可选),含消费额与贡献佣金
   · 不用 referral_tree(生产漏写:20 直推仅落 18 条)
   · 不用 MySQL 递归 CTE(当前 RDS 内部临时表不可用)
- 用户管理列表操作列新增「团队」按钮,弹窗展示概览 + L1/L2 成员 + 团队树
Xiaogang Liao 2 dienas atpakaļ
vecāks
revīzija
6c0f05701

+ 131 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminReferralController.java

@@ -0,0 +1,131 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AdminReferralTreeService;
+import com.etotem.cfc.service.CommissionService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 后台 - 用户推荐团队查询(按指定 userId 查看其推荐人 / 下线团队)。
+ *
+ * <p>后台用户管理页「团队」入口使用。数据完全复用 {@link CommissionService}
+ * (users.referrer_id + referral_tree + commission_records 口径),
+ * 仅把 C 端「只能查自己」的接口包装为管理员按 userId 查询。</p>
+ */
+@Tag(name = "管理端-推荐团队")
+@RestController
+@RequestMapping("/api/admin/referral")
+public class AdminReferralController {
+
+    @Resource
+    private CommissionService commissionService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private AdminReferralTreeService adminReferralTreeService;
+
+    /**
+     * 指定用户的推荐关系概览:基本信息 + 上级推荐人 + L1/L2/团队佣金。
+     */
+    @Operation(summary = "推荐关系概览")
+    @PostMapping("/summary")
+    public Result<Map<String, Object>> summary(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute(value = "role", required = false) String role,
+                                               @RequestAttribute(value = "roles", required = false) List<String> roleList) {
+        if (!hasBackendAccess(role, roleList)) {
+            return Result.error("无权限");
+        }
+        Long userId = ParamUtils.getLong(params.get("userId"));
+        if (userId == null) {
+            return Result.error("userId不能为空");
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("userId", user.getId());
+        out.put("nickname", user.getNickname());
+        out.put("phone", user.getPhone());
+        out.put("referralCode", user.getReferralCode());
+        out.put("createdAt", user.getCreatedAt());
+
+        Map<String, Object> upstream = commissionService.getMyReferrer(userId, null);
+        out.put("hasReferrer", upstream.get("hasReferrer"));
+        out.put("referrerId", upstream.get("referrerId"));
+        out.put("referrerName", upstream.get("nickname"));
+
+        Map<String, Object> stats = commissionService.getTeamStats(userId);
+        out.put("l1Count", stats.get("l1Count"));
+        out.put("l2Count", stats.get("l2Count"));
+        out.put("totalCommission", stats.get("totalCommission"));
+        return Result.success(out);
+    }
+
+    /**
+     * 团队成员分页:level=L1(直推)/ L2(间推)/ ALL(直推)。
+     */
+    @Operation(summary = "团队成员列表(L1/L2)")
+    @PostMapping("/members")
+    public Result<Map<String, Object>> members(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute(value = "role", required = false) String role,
+                                               @RequestAttribute(value = "roles", required = false) List<String> roleList) {
+        if (!hasBackendAccess(role, roleList)) {
+            return Result.error("无权限");
+        }
+        Long userId = ParamUtils.getLong(params.get("userId"));
+        if (userId == null) {
+            return Result.error("userId不能为空");
+        }
+        String level = params.get("level") != null ? String.valueOf(params.get("level")) : "ALL";
+        if (!"L1".equals(level) && !"L2".equals(level)) {
+            level = "ALL";
+        }
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        return Result.success(commissionService.getTeamList(userId, level, page, size));
+    }
+
+    /**
+     * 团队树形扁平数据(level 1..maxLevel)。
+     */
+    @Operation(summary = "团队树")
+    @PostMapping("/tree")
+    public Result<Map<String, Object>> tree(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute(value = "role", required = false) String role,
+                                            @RequestAttribute(value = "roles", required = false) List<String> roleList) {
+        if (!hasBackendAccess(role, roleList)) {
+            return Result.error("无权限");
+        }
+        Long userId = ParamUtils.getLong(params.get("userId"));
+        if (userId == null) {
+            return Result.error("userId不能为空");
+        }
+        Integer maxLevel = params.get("maxLevel") != null
+                ? ((Number) params.get("maxLevel")).intValue() : 3;
+        return Result.success(adminReferralTreeService.buildTree(userId, maxLevel));
+    }
+
+    private boolean hasBackendAccess(String role, List<String> roleList) {
+        List<String> roles = roleList != null ? roleList : Collections.emptyList();
+        return AdminRoles.hasAnyBackendRole(roles) || AdminRoles.isBackendRole(role);
+    }
+}

+ 131 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AdminReferralTreeService.java

@@ -0,0 +1,131 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CommissionRecord;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CommissionRecordMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 后台推荐团队树构建(按 users.referrer_id 逐层 BFS)。
+ *
+ * <p>不使用 referral_tree:该表为异步物化数据,生产存在漏写(例如某用户 20 个直推、树里仅 18 条),
+ * 会导致后台团队树比 L1/L2 统计少人。users.referrer_id 是推荐关系的权威源,
+ * 与 {@link CommissionService#getTeamStats}/{@link CommissionService#getTeamList} 口径一致。</p>
+ *
+ * <p>同时不使用 MySQL 递归 CTE:当前 RDS 执行 WITH RECURSIVE 会因内部临时表不可用而报错。</p>
+ */
+@Service
+public class AdminReferralTreeService {
+
+    private static final int DEFAULT_MAX_LEVEL = 3;
+    private static final int HARD_MAX_LEVEL = 8;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private CommissionRecordMapper commissionRecordMapper;
+
+    /**
+     * 构建以 userId 为根的团队树(扁平节点,含 level/parentId)。
+     *
+     * @return { nodes: [...], totalMembers, maxLevel }
+     */
+    public Map<String, Object> buildTree(Long userId, Integer maxLevel) {
+        int cap = maxLevel == null || maxLevel <= 0 ? DEFAULT_MAX_LEVEL : Math.min(maxLevel, HARD_MAX_LEVEL);
+
+        List<Map<String, Object>> nodes = new ArrayList<>();
+        List<Long> memberIds = new ArrayList<>();
+        Set<Long> visited = new HashSet<>();
+        visited.add(userId); // 防自引用/环
+
+        List<Long> parentIds = Collections.singletonList(userId);
+        for (int level = 1; level <= cap && !parentIds.isEmpty(); level++) {
+            List<User> children = userMapper.selectList(
+                    new LambdaQueryWrapper<User>().in(User::getReferrerId, parentIds));
+            List<Long> next = new ArrayList<>();
+            for (User u : children) {
+                if (u.getId() == null || !visited.add(u.getId())) {
+                    continue;
+                }
+                Map<String, Object> n = new LinkedHashMap<>();
+                n.put("userId", u.getId());
+                n.put("parentId", u.getReferrerId());
+                n.put("level", level);
+                n.put("nickname", u.getNickname());
+                n.put("avatar", u.getAvatar());
+                n.put("createdAt", u.getCreatedAt());
+                nodes.add(n);
+                memberIds.add(u.getId());
+                next.add(u.getId());
+            }
+            parentIds = next;
+        }
+
+        Map<Long, Integer> consumptionMap = loadConsumption(memberIds);
+        Map<Long, Integer> commissionMap = loadCommission(userId, memberIds);
+        for (Map<String, Object> n : nodes) {
+            Long uid = (Long) n.get("userId");
+            n.put("memberConsumption", consumptionMap.getOrDefault(uid, 0));
+            n.put("myCfEarnedFromMember", commissionMap.getOrDefault(uid, 0));
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("nodes", nodes);
+        result.put("totalMembers", nodes.size());
+        result.put("maxLevel", cap);
+        return result;
+    }
+
+    private Map<Long, Integer> loadConsumption(List<Long> memberIds) {
+        Map<Long, Integer> out = new HashMap<>();
+        if (memberIds.isEmpty()) {
+            return out;
+        }
+        List<ProductOrder> orders = productOrderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .in(ProductOrder::getBuyerId, memberIds)
+                        .eq(ProductOrder::getFulfillStatus, "completed"));
+        for (ProductOrder o : orders) {
+            if (o.getBuyerId() != null && o.getTotalAmount() != null) {
+                out.merge(o.getBuyerId(), o.getTotalAmount(), Integer::sum);
+            }
+        }
+        return out;
+    }
+
+    private Map<Long, Integer> loadCommission(Long rootUserId, List<Long> memberIds) {
+        Map<Long, Integer> out = new HashMap<>();
+        if (memberIds.isEmpty()) {
+            return out;
+        }
+        List<CommissionRecord> records = commissionRecordMapper.selectList(
+                new LambdaQueryWrapper<CommissionRecord>()
+                        .eq(CommissionRecord::getReferrerId, rootUserId)
+                        .in(CommissionRecord::getBuyerId, memberIds)
+                        .ne(CommissionRecord::getStatus, "cancelled"));
+        for (CommissionRecord r : records) {
+            if (r.getBuyerId() != null && r.getCommissionAmount() != null) {
+                out.merge(r.getBuyerId(), r.getCommissionAmount(), Integer::sum);
+            }
+        }
+        return out;
+    }
+}

+ 25 - 0
cfc-web/src/api/admin.js

@@ -2233,6 +2233,31 @@ export function getCfValueAllBalances(data) {
   })
 }
 
+// ===== 推荐团队(后台按 userId 查看推荐人/下线)=====
+export function getReferralSummary(data) {
+  return request({
+    url: '/api/admin/referral/summary',
+    method: 'post',
+    data
+  })
+}
+
+export function getReferralMembers(data) {
+  return request({
+    url: '/api/admin/referral/members',
+    method: 'post',
+    data
+  })
+}
+
+export function getReferralTree(data) {
+  return request({
+    url: '/api/admin/referral/tree',
+    method: 'post',
+    data
+  })
+}
+
 // 获取已入驻的服务商列表(用于商品编辑下拉选择)
 export function getApprovedGuides() {
   return request({

+ 181 - 2
cfc-web/src/views/Users.vue

@@ -116,9 +116,10 @@
             <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
 
           </template></el-table-column>
-        <el-table-column label="操作" width="200" fixed="right">
+        <el-table-column label="操作" width="250" fixed="right">
           <template slot-scope="{ row }">
             <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+            <el-button size="mini" @click="openTeamDialog(row)">团队</el-button>
             <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
               <el-button size="mini">
                 更多<i class="el-icon-arrow-down el-icon--right"></i>
@@ -314,13 +315,110 @@
         <el-button type="primary" @click="confirmGrantCoupon" :loading="grantCouponSubmitting">确认发放</el-button>
       </div>
     </el-dialog>
+
+    <!-- 推荐团队弹窗 -->
+    <el-dialog
+      title="推荐团队"
+      :visible.sync="teamDialogVisible"
+      width="1000px"
+    >
+      <div v-if="teamUser">
+        <el-descriptions :column="3" border class="mb-20">
+          <el-descriptions-item label="用户">{{ teamUser.nickname || teamUser.realName || ('用户' + teamUser.id) }}</el-descriptions-item>
+          <el-descriptions-item label="推荐人(上级)">{{ teamSummary.referrerName || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="邀请码">{{ teamSummary.referralCode || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="直推人数">{{ teamSummary.l1Count || 0 }}</el-descriptions-item>
+          <el-descriptions-item label="间推人数">{{ teamSummary.l2Count || 0 }}</el-descriptions-item>
+          <el-descriptions-item label="团队累计佣金">{{ teamSummary.totalCommission || 0 }} 分</el-descriptions-item>
+        </el-descriptions>
+
+        <el-tabs v-model="teamTab" @tab-click="handleTeamTabChange">
+          <el-tab-pane label="直推(L1)" name="L1">
+            <el-table :data="teamList" border v-loading="teamLoading" max-height="360">
+              <el-table-column prop="userId" label="用户ID" width="90" />
+              <el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
+              <el-table-column label="注册时间" width="170">
+                <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+              </el-table-column>
+              <el-table-column label="消费额(分)" width="120">
+                <template slot-scope="{ row }">{{ row.memberConsumption || 0 }}</template>
+              </el-table-column>
+              <el-table-column label="贡献佣金(分)" width="130">
+                <template slot-scope="{ row }">{{ row.commissionEarned || 0 }}</template>
+              </el-table-column>
+            </el-table>
+            <el-pagination
+              class="pagination-right-mt12"
+              @current-change="loadTeamMembers"
+              :current-page="teamPage"
+              :page-size="teamSize"
+              :total="teamTotal"
+              layout="total, prev, pager, next"
+            />
+            <el-empty v-if="!teamLoading && (!teamList || !teamList.length)" description="暂无直推成员" :image-size="60" />
+          </el-tab-pane>
+
+          <el-tab-pane label="间推(L2)" name="L2">
+            <el-table :data="teamList" border v-loading="teamLoading" max-height="360">
+              <el-table-column prop="userId" label="用户ID" width="90" />
+              <el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
+              <el-table-column label="注册时间" width="170">
+                <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+              </el-table-column>
+              <el-table-column label="消费额(分)" width="120">
+                <template slot-scope="{ row }">{{ row.memberConsumption || 0 }}</template>
+              </el-table-column>
+              <el-table-column label="贡献佣金(分)" width="130">
+                <template slot-scope="{ row }">{{ row.commissionEarned || 0 }}</template>
+              </el-table-column>
+            </el-table>
+            <el-pagination
+              class="pagination-right-mt12"
+              @current-change="loadTeamMembers"
+              :current-page="teamPage"
+              :page-size="teamSize"
+              :total="teamTotal"
+              layout="total, prev, pager, next"
+            />
+            <el-empty v-if="!teamLoading && (!teamList || !teamList.length)" description="暂无间推成员" :image-size="60" />
+          </el-tab-pane>
+
+          <el-tab-pane label="团队树" name="tree">
+            <div class="team-tree-bar">
+              <span class="role-hint">展开层级</span>
+              <el-select v-model="teamTreeMaxLevel" size="small" class="w-120" @change="loadTeamTree">
+                <el-option v-for="n in 8" :key="n" :label="n + ' 层'" :value="n" />
+              </el-select>
+              <el-button size="small" @click="loadTeamTree">刷新</el-button>
+            </div>
+            <el-table :data="teamTree" border v-loading="teamTreeLoading" max-height="360">
+              <el-table-column label="层级" width="90">
+                <template slot-scope="{ row }">
+                  <span :style="{ paddingLeft: ((row.level - 1) * 14) + 'px' }">L{{ row.level }}</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="userId" label="用户ID" width="90" />
+              <el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
+              <el-table-column prop="parentId" label="上级ID" width="90" />
+              <el-table-column label="消费额(分)" width="120">
+                <template slot-scope="{ row }">{{ row.memberConsumption || 0 }}</template>
+              </el-table-column>
+              <el-table-column label="贡献佣金(分)" width="130">
+                <template slot-scope="{ row }">{{ row.myCfEarnedFromMember || 0 }}</template>
+              </el-table-column>
+            </el-table>
+            <el-empty v-if="!teamTreeLoading && (!teamTree || !teamTree.length)" description="暂无下级成员" :image-size="60" />
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
-import { getUserList, updateUser, deleteUser, resetUserPassword, searchUsers, listMembershipLevels } from '@/api/admin'
+import { getUserList, updateUser, deleteUser, resetUserPassword, searchUsers, listMembershipLevels, getReferralSummary, getReferralMembers, getReferralTree } from '@/api/admin'
 import { grantUserCoupon } from '@/api/coupon'
 
 import { getRoleLabel } from '@/utils/permissions'
@@ -396,6 +494,19 @@ export default {
         remark: ''
       },
       grantCouponSubmitting: false,
+      // 推荐团队弹窗
+      teamDialogVisible: false,
+      teamUser: null,
+      teamTab: 'L1',
+      teamSummary: {},
+      teamLoading: false,
+      teamList: [],
+      teamPage: 1,
+      teamSize: 20,
+      teamTotal: 0,
+      teamTree: [],
+      teamTreeLoading: false,
+      teamTreeMaxLevel: 3,
     }
   },
   mounted() {
@@ -698,6 +809,72 @@ const res = await getUserList(params)
           item.referrerName = nameMap[item.referrerId] || ''
         })
       })
+    },
+    // ===== 推荐团队 =====
+    openTeamDialog(row) {
+      this.teamUser = row
+      this.teamDialogVisible = true
+      this.teamTab = 'L1'
+      this.teamPage = 1
+      this.teamList = []
+      this.teamTotal = 0
+      this.teamTree = []
+      this.teamSummary = {}
+      this.loadTeamSummary()
+      this.loadTeamMembers()
+    },
+    async loadTeamSummary() {
+      if (!this.teamUser) return
+      try {
+        const res = await getReferralSummary({ userId: this.teamUser.id })
+        if (res.code === 200) this.teamSummary = res.data || {}
+      } catch (e) {
+        this.$message.error(e.message || '加载推荐概览失败')
+      }
+    },
+    handleTeamTabChange() {
+      if (this.teamTab === 'tree') {
+        this.loadTeamTree()
+      } else {
+        this.teamPage = 1
+        this.loadTeamMembers()
+      }
+    },
+    async loadTeamMembers(page) {
+      if (!this.teamUser) return
+      if (page) this.teamPage = page
+      const level = this.teamTab === 'L2' ? 'L2' : 'L1'
+      this.teamLoading = true
+      try {
+        const res = await getReferralMembers({
+          userId: this.teamUser.id,
+          level,
+          page: this.teamPage,
+          size: this.teamSize
+        })
+        if (res.code === 200) {
+          this.teamList = (res.data && res.data.records) || []
+          this.teamTotal = (res.data && res.data.total) || 0
+        }
+      } catch (e) {
+        this.$message.error(e.message || '加载团队成员失败')
+      } finally {
+        this.teamLoading = false
+      }
+    },
+    async loadTeamTree() {
+      if (!this.teamUser) return
+      this.teamTreeLoading = true
+      try {
+        const res = await getReferralTree({ userId: this.teamUser.id, maxLevel: this.teamTreeMaxLevel })
+        if (res.code === 200) {
+          this.teamTree = (res.data && res.data.nodes) || []
+        }
+      } catch (e) {
+        this.$message.error(e.message || '加载团队树失败')
+      } finally {
+        this.teamTreeLoading = false
+      }
     }
   }
 }
@@ -728,6 +905,8 @@ const res = await getUserList(params)
 .mr-4 { margin-right: 4px; }
 .full-width { width: 100%; }
 .w-200 { width: 200px; }
+.w-120 { width: 120px; }
+.team-tree-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
 .pagination-right { margin-top: 24px; text-align: right; }
 .role-hint { font-size: 12px; color: var(--adm-text-45); margin-top: 4px; }
 </style>