From 46c5489c67991cd85418a60a6dc04ce84e21ea86 Mon Sep 17 00:00:00 2001 From: chen Date: Sat, 19 Sep 2026 12:08:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(commission):=20=E7=A7=BB=E9=99=A4=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E9=97=B4=E6=8E=A8=E5=B9=B6=E6=94=AF=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E7=BA=A7=E7=9B=B4=E6=8E=A8=E5=A4=9A=E5=B1=82=E7=BA=A7=E4=B8=8B?= =?UTF-8?q?=E9=92=BB=E4=B8=8E=E9=98=B2=E8=B6=8A=E6=9D=83=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/shopro/controller/commission/Agent.php | 103 ++-- tests/verify_team_drilldown_spec.js | 208 +++++++ uniapp/pages/commission/team.vue | 538 ++++++++++++------ 3 files changed, 630 insertions(+), 219 deletions(-) create mode 100644 tests/verify_team_drilldown_spec.js diff --git a/addons/shopro/controller/commission/Agent.php b/addons/shopro/controller/commission/Agent.php index be5d8f6..4a538c1 100644 --- a/addons/shopro/controller/commission/Agent.php +++ b/addons/shopro/controller/commission/Agent.php @@ -123,37 +123,62 @@ class Agent extends Commission $this->success('提交成功'); } - // 我的团队 + // 我的团队(支持直推查看与安全下钻) public function team() { $agentId = $this->service->user->id; - $levelType = $this->request->param('level_type', '1'); // 1=直推, 2=间推, all=全部 + $drillUserId = (int) $this->request->param('drill_user_id', 0); $filterType = $this->request->param('filter_type', 'all'); // all, partner, active, expiring, inactive $keyword = trim((string) $this->request->param('keyword', '')); $sort = $this->request->param('sort', 'createtime'); // createtime, child_count, consume $listRows = (int) $this->request->param('list_rows', 10); - // 1. 获取直推一级成员 ID 列表 - $level1Ids = UserModel::where('parent_user_id', $agentId) + $targetUserId = $agentId; + $drillUser = null; + + // 1. 如果传了下钻目标成员,严格执行树状归属鉴权,防越权防注入 + if ($drillUserId > 0 && $drillUserId !== $agentId) { + $targetUser = UserModel::where('id', $drillUserId)->where('status', 'normal')->find(); + if (!$targetUser) { + $this->error('指定成员不存在或已注销'); + } + + // 向上追溯层级,确保目标成员在当前用户的团队树中 + $isDownline = false; + $currParentId = $targetUser->parent_user_id; + $depth = 0; + while ($currParentId > 0 && $depth < 30) { + if ($currParentId == $agentId) { + $isDownline = true; + break; + } + $currParentId = UserModel::where('id', $currParentId)->value('parent_user_id') ?: 0; + $depth++; + } + + if (!$isDownline) { + $this->error('无权查看非直属团队成员信息'); + } + + $targetUserId = $drillUserId; + $drillUser = [ + 'id' => $targetUser->id, + 'nickname' => $targetUser->nickname ?: '微信用户', + 'avatar' => $targetUser->avatar, + 'parent_user_id' => $targetUser->parent_user_id, + ]; + } + + // 2. 获取目标用户的直推一级成员 ID 列表(全面采用一级直推模型) + $level1Ids = UserModel::where('parent_user_id', $targetUserId) ->where('status', 'normal') ->column('id'); - // 2. 根据 level_type 确定父级范围 + // 3. 构建直推成员查询 $query = UserModel::alias('u')->where('u.status', 'normal'); - if ($levelType == '2') { - if (empty($level1Ids)) { - $query->whereRaw('1=0'); - } else { - $query->whereIn('u.parent_user_id', $level1Ids); - } - } elseif ($levelType == 'all') { - $allParentIds = array_merge([$agentId], $level1Ids); - $query->whereIn('u.parent_user_id', $allParentIds); - } else { - $query->where('u.parent_user_id', $agentId); - } + $query->where('u.parent_user_id', $targetUserId); - // 3. 关键字搜索 (昵称/手机号/ID) + // 4. 关键字搜索 (昵称/手机号/ID) if ($keyword !== '') { $query->where(function ($q) use ($keyword) { if (is_numeric($keyword)) { @@ -166,12 +191,12 @@ class Agent extends Commission }); } - // 4. 计算活跃度时间窗口 (默认30天保活,剩余<=5天为临期) + // 5. 计算活跃度时间窗口 (默认30天保活,剩余<=5天为临期) $windowDays = \addons\shopro\service\partner\PartnerConfig::activeDays(); $windowStart = (new \addons\shopro\service\partner\ActiveService())->windowStart(); $expiringStart = time() - ($windowDays - 5) * 86400; - // 5. 身份与活跃状态过滤 + // 6. 身份与活跃状态过滤 if ($filterType === 'partner') { $partnerUserIds = AgentModel::where('status', 'in', ['normal', 'freeze']) ->where(function ($q) { @@ -207,7 +232,7 @@ class Agent extends Commission } } - // 6. 排序处理 + // 7. 排序处理 if ($sort === 'child_count') { $agentTable = (new AgentModel())->getTable(); $query->join($agentTable . ' a', 'u.id = a.user_id', 'LEFT') @@ -220,7 +245,7 @@ class Agent extends Commission } $query->field('u.*'); - // 7. 分页并加载推荐人与分销商信息 + // 8. 分页并加载推荐人与分销商信息 $data = $query->with([ 'parentUser' => function ($q) { $q->field('id, nickname, avatar, mobile'); @@ -267,14 +292,14 @@ class Agent extends Commission $user->child_user_count_1 = 0; } - $user->team_level = ($user->parent_user_id == $agentId) ? 1 : 2; + $user->team_level = 1; $rawMobile = $user->mobile ? (string) $user->mobile : ''; $maskedMobile = $rawMobile ? (strlen($rawMobile) == 11 ? substr_replace($rawMobile, '****', 3, 4) : $rawMobile) : ''; $user->mobile = $maskedMobile; $user->mobile_hide = $maskedMobile; $user->mobile_raw = $rawMobile; - // 间推所属推荐人手机号脱敏 + // 所属推荐人手机号脱敏 if ($user->parentUser && $user->parentUser->mobile) { $pMobile = (string) $user->parentUser->mobile; $pMasked = strlen($pMobile) == 11 ? substr_replace($pMobile, '****', 3, 4) : $pMobile; @@ -283,28 +308,26 @@ class Agent extends Commission } }); - // 9. 计算整体团队健康度与综合收益业绩统计 - $summary = $this->getTeamHealthSummary($agentId, $level1Ids, $windowStart, $expiringStart); + // 9. 计算整体团队健康度与综合收益业绩统计(基于当前直推用户) + $summary = $this->getTeamHealthSummary($targetUserId, $level1Ids, $windowStart, $expiringStart); $result = $data->toArray(); $result['summary'] = $summary; + $result['drill_user'] = $drillUser; - $this->success("", $result); + $this->success("获取成功", $result); } /** - * 计算团队保活健康度与综合收益统计 + * 计算团队保活健康度与综合收益统计(全面直推口径) */ - protected function getTeamHealthSummary($agentId, $level1Ids, $windowStart, $expiringStart) + protected function getTeamHealthSummary($targetUserId, $level1Ids, $windowStart, $expiringStart) { - $allMembers = array_merge([$agentId], $level1Ids); - $teamUserIds = UserModel::whereIn('parent_user_id', $allMembers) - ->where('status', 'normal') - ->column('id'); + $teamUserIds = !empty($level1Ids) ? $level1Ids : []; if (empty($teamUserIds)) { - $selfAgent = AgentModel::where('user_id', $agentId)->find(); - $selfUser = UserModel::where('id', $agentId)->find(); + $selfAgent = AgentModel::where('user_id', $targetUserId)->find(); + $selfUser = UserModel::where('id', $targetUserId)->find(); return [ 'total_count' => 0, 'active_count' => 0, @@ -351,8 +374,8 @@ class Agent extends Commission } // 1. 团队累计自购总业绩与团队订单总数(统一使用包含本人自购的分销团队全口径) - $selfAgent = AgentModel::where('user_id', $agentId)->find(); - $selfUser = UserModel::where('id', $agentId)->find(); + $selfAgent = AgentModel::where('user_id', $targetUserId)->find(); + $selfUser = UserModel::where('id', $targetUserId)->find(); $teamTotalConsume = isset($selfAgent->child_order_money_all) && floatval($selfAgent->child_order_money_all) > 0 ? floatval($selfAgent->child_order_money_all) @@ -362,13 +385,13 @@ class Agent extends Commission ? intval($selfAgent->child_order_count_all) : ($teamOrderCount + intval($selfAgent->child_order_count_0 ?? 0)); - // 2. 团队成员为当前用户贡献的累计收益与本月收益 + // 2. 团队成员为目标用户贡献的累计收益与本月收益 $monthStart = strtotime(date('Y-m-01 00:00:00')); - $teamTotalReward = RewardModel::where('user_id', $agentId) + $teamTotalReward = RewardModel::where('user_id', $targetUserId) ->whereIn('buyer_id', $teamUserIds) ->where('status', 1) ->sum('amount'); - $teamMonthReward = RewardModel::where('user_id', $agentId) + $teamMonthReward = RewardModel::where('user_id', $targetUserId) ->whereIn('buyer_id', $teamUserIds) ->where('status', 1) ->where('createtime', '>=', $monthStart) diff --git a/tests/verify_team_drilldown_spec.js b/tests/verify_team_drilldown_spec.js new file mode 100644 index 0000000..5b4a4f1 --- /dev/null +++ b/tests/verify_team_drilldown_spec.js @@ -0,0 +1,208 @@ +/** + * 测试用例:验证团队中心移除二级间推并支持一级直推多层级下钻功能 + * 覆盖范围: + * 1. 后端 Agent.php team() 接口:全面移除二级查询分支,默认一级直推 + * 2. 后端防越权链条鉴权机制:追溯 parent_user_id 防止跨团队越权下钻 + * 3. 前端 team.vue 界面:彻底清除“二级间推”、“二级成员”概念 + * 4. 前端下钻状态管理:drillStack 栈驱动、currentDrillUser 计算属性与 API 参数透传 + * 5. 前端导航与交互:面包屑导航栏、返回上一层、回到根团队、卡片下钻按钮、指标下钻与抽屉下钻卡片 + * 6. 完整业务流程模拟:多级下钻、路径回溯与越权拦截测试 + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +console.log('=== 开始执行团队中心一级直推与多层级下钻自动化测试 ===\n'); + +// 1. 验证后端 Agent.php 接口改造 +const agentPhpPath = path.resolve(__dirname, '../addons/shopro/controller/commission/Agent.php'); +assert(fs.existsSync(agentPhpPath), 'Agent.php 文件必须存在'); +const agentPhpContent = fs.readFileSync(agentPhpPath, 'utf-8'); + +// 1.1 检查下钻参数与鉴权 +assert(agentPhpContent.includes("drill_user_id"), 'Agent.php 必须接收 drill_user_id 参数'); +assert(agentPhpContent.includes("isDownline = true"), 'Agent.php 必须实现父级链条归属鉴权'); +assert(agentPhpContent.includes("无权查看非直属团队成员信息"), 'Agent.php 必须对非团队成员下钻进行严格权限拦截'); +assert(agentPhpContent.includes("drill_user"), 'Agent.php 必须在返回结果中包含下钻用户信息 drill_user'); + +// 1.2 检查彻底移除二级间推分支 +assert(!agentPhpContent.includes("$levelType == '2'"), 'Agent.php 必须彻底移除 levelType == 2 的二级间推分支'); +assert(agentPhpContent.includes("where('u.parent_user_id', $targetUserId)"), 'Agent.php 必须采用直推一级关系查询'); + +console.log('✅ 测试点 1 通过: 后端 Agent.php team() 接口直推与下钻链条鉴权逻辑正确'); + +// 2. 模拟后端向上追溯防越权鉴权算法测试 +function verifyDownlinePermission(loginAgentId, targetUserId, userHierarchyMap) { + if (targetUserId === loginAgentId) return true; // 查看自己 + let curr = userHierarchyMap[targetUserId]; + if (!curr) return false; // 用户不存在 + + let depth = 0; + let currParentId = curr.parent_user_id; + while (currParentId > 0 && depth < 30) { + if (currParentId === loginAgentId) { + return true; // 确认是自己的下属团队链条 + } + const parentUser = userHierarchyMap[currParentId]; + if (!parentUser) break; + currParentId = parentUser.parent_user_id || 0; + depth++; + } + return false; +} + +// 构建模拟团队关系树: +// 登录用户: 100 +// 100 -> 101 (直推1级) +// 101 -> 102 (直推下级,相对100是下属) +// 102 -> 103 (继续深层直推下级) +// 外部人员: 200 -> 201 (与100无关联) +const mockUserDb = { + 100: { id: 100, parent_user_id: 0, nickname: '根用户' }, + 101: { id: 101, parent_user_id: 100, nickname: '直推A' }, + 102: { id: 102, parent_user_id: 101, nickname: '直推A的下级B' }, + 103: { id: 103, parent_user_id: 102, nickname: '下级B的下级C' }, + 200: { id: 200, parent_user_id: 0, nickname: '其他团队长' }, + 201: { id: 201, parent_user_id: 200, nickname: '其他团队成员' }, +}; + +// 验证正常直属下钻 +assert.strictEqual(verifyDownlinePermission(100, 101, mockUserDb), true, '登录用户查看自己的一级直推 101 必须鉴权通过'); +assert.strictEqual(verifyDownlinePermission(100, 102, mockUserDb), true, '登录用户下钻二级成员 102 必须鉴权通过'); +assert.strictEqual(verifyDownlinePermission(100, 103, mockUserDb), true, '登录用户多级下钻三级成员 103 必须鉴权通过'); + +// 验证越权拦截 +assert.strictEqual(verifyDownlinePermission(100, 200, mockUserDb), false, '尝试越权下钻其他团队长 200 必须被拦截'); +assert.strictEqual(verifyDownlinePermission(100, 201, mockUserDb), false, '尝试越权下钻其他团队成员 201 必须被拦截'); +assert.strictEqual(verifyDownlinePermission(100, 9999, mockUserDb), false, '下钻不存在的用户必须被拦截'); + +console.log('✅ 测试点 2 通过: 防越权追溯鉴权算法通过(正常下钻放行、越权调用均被准确拦截)'); + +// 3. 验证前端 team.vue 页面 +const teamVuePath = path.resolve(__dirname, '../uniapp/pages/commission/team.vue'); +assert(fs.existsSync(teamVuePath), 'team.vue 文件必须存在'); +const teamVueContent = fs.readFileSync(teamVuePath, 'utf-8'); + +// 3.1 验证彻底移除“二级”文本 +const hasSecondaryText = teamVueContent.includes('二级间推') || teamVueContent.includes('二级成员'); +assert.strictEqual(hasSecondaryText, false, 'team.vue 页面中严禁存在任何“二级间推”或“二级成员”字样'); + +// 3.2 验证移除 levelTabs +assert(!teamVueContent.includes('currentLevelTab'), 'team.vue 必须移除 currentLevelTab 状态'); +assert(!teamVueContent.includes('levelTabs'), 'team.vue 必须移除 levelTabs 变量'); + +// 3.3 验证下钻状态管理 +assert(teamVueContent.includes('drillStack: []'), 'team.vue 必须声明 drillStack 状态'); +assert(teamVueContent.includes('currentDrillUser = computed'), 'team.vue 必须包含 currentDrillUser 计算属性'); +assert(teamVueContent.includes('onDrillDown'), 'team.vue 必须包含 onDrillDown 下钻方法'); +assert(teamVueContent.includes('onDrillBack'), 'team.vue 必须包含 onDrillBack 返回上一层方法'); +assert(teamVueContent.includes('onDrillRoot'), 'team.vue 必须包含 onDrillRoot 回到根团队方法'); +assert(teamVueContent.includes('onJumpDrillIndex'), 'team.vue 必须包含 onJumpDrillIndex 面包屑跳跃方法'); +assert(teamVueContent.includes('onDrillFromDrawer'), 'team.vue 必须包含从抽屉中下钻的方法'); +assert(teamVueContent.includes('drill_user_id: currentDrillUser.value'), 'team.vue getTeamList 必须传递 drill_user_id'); + +// 3.4 验证下钻 UI 元素渲染 +assert(teamVueContent.includes('drill-nav-bar'), 'team.vue 必须渲染 drill-nav-bar 下钻导航栏'); +assert(teamVueContent.includes('drill-back-btn'), 'team.vue 必须包含返回上一层按钮'); +assert(teamVueContent.includes('drill-breadcrumb'), 'team.vue 必须包含层级面包屑路径'); +assert(teamVueContent.includes('btn-drill'), 'team.vue 成员卡片必须包含下钻团队按钮'); +assert(teamVueContent.includes('drawer-drill-card'), 'team.vue 详情抽屉必须包含下钻团队卡片'); + +console.log('✅ 测试点 3 通过: 前端 team.vue 状态管理、下钻面包屑导航与交互按钮规范'); + +// 4. 模拟前端下钻堆栈业务状态机流转测试 +class MockTeamDrillController { + constructor() { + this.drillStack = []; + this.currentFilter = 'all'; + this.keyword = ''; + this.lastParams = null; + } + + get currentDrillUser() { + if (this.drillStack.length === 0) return null; + return this.drillStack[this.drillStack.length - 1]; + } + + getTeamList(page = 1) { + this.lastParams = { + page, + drill_user_id: this.currentDrillUser ? this.currentDrillUser.id : 0, + filter_type: this.currentFilter, + keyword: this.keyword, + }; + return this.lastParams; + } + + onDrillDown(member) { + if (!member || !member.id) return; + if (this.drillStack.some((item) => item.id === member.id)) return; + this.drillStack.push({ + id: member.id, + nickname: member.nickname || '直推成员', + child_user_count_1: member.child_user_count_1 || 0, + }); + this.keyword = ''; + return this.getTeamList(1); + } + + onDrillBack() { + if (this.drillStack.length === 0) return; + this.drillStack.pop(); + this.keyword = ''; + return this.getTeamList(1); + } + + onDrillRoot() { + if (this.drillStack.length === 0) return; + this.drillStack = []; + this.keyword = ''; + return this.getTeamList(1); + } + + onJumpDrillIndex(index) { + if (index >= this.drillStack.length - 1) return; + this.drillStack = this.drillStack.slice(0, index + 1); + this.keyword = ''; + return this.getTeamList(1); + } +} + +const controller = new MockTeamDrillController(); + +// 步骤 1: 初始化根列表 +const initReq = controller.getTeamList(1); +assert.strictEqual(initReq.drill_user_id, 0, '根列表请求 drill_user_id 必须为 0'); +assert.strictEqual(controller.currentDrillUser, null, '根目录下 currentDrillUser 必须为 null'); + +// 步骤 2: 点击成员 101 下钻 +const drill1Req = controller.onDrillDown({ id: 101, nickname: '张三', child_user_count_1: 5 }); +assert.strictEqual(drill1Req.drill_user_id, 101, '一级下钻请求 drill_user_id 必须为 101'); +assert.strictEqual(controller.drillStack.length, 1, '下钻栈深度应为 1'); +assert.strictEqual(controller.currentDrillUser.nickname, '张三', '当前下钻用户应为张三'); + +// 步骤 3: 在 101 名下继续点击成员 102 下钻 +const drill2Req = controller.onDrillDown({ id: 102, nickname: '李四', child_user_count_1: 2 }); +assert.strictEqual(drill2Req.drill_user_id, 102, '二级下钻请求 drill_user_id 必须为 102'); +assert.strictEqual(controller.drillStack.length, 2, '下钻栈深度应为 2'); +assert.strictEqual(controller.currentDrillUser.nickname, '李四', '当前下钻用户应为李四'); + +// 步骤 4: 点击“返回上一层” +const backReq = controller.onDrillBack(); +assert.strictEqual(backReq.drill_user_id, 101, '返回上一层后请求 drill_user_id 必须回到 101'); +assert.strictEqual(controller.drillStack.length, 1, '返回后下钻栈深度应为 1'); +assert.strictEqual(controller.currentDrillUser.nickname, '张三', '当前下钻用户应恢复为张三'); + +// 步骤 5: 再次深层下钻并一键“回到根团队” +controller.onDrillDown({ id: 102, nickname: '李四' }); +controller.onDrillDown({ id: 103, nickname: '王五' }); +assert.strictEqual(controller.drillStack.length, 3); +const rootReq = controller.onDrillRoot(); +assert.strictEqual(rootReq.drill_user_id, 0, '一键回到根团队后 drill_user_id 必须为 0'); +assert.strictEqual(controller.drillStack.length, 0, '下钻栈必须被清空'); +assert.strictEqual(controller.currentDrillUser, null, '当前下钻用户必须为 null'); + +console.log('✅ 测试点 4 通过: 前端下钻堆栈与状态流转业务逻辑全部验证通过'); + +console.log('\n🎉 所有测试均通过!团队中心一级直推与多层级下钻功能验证完毕!'); diff --git a/uniapp/pages/commission/team.vue b/uniapp/pages/commission/team.vue index af04f7d..ed40d73 100644 --- a/uniapp/pages/commission/team.vue +++ b/uniapp/pages/commission/team.vue @@ -41,65 +41,47 @@ - + - + - 团队总人数(人) - {{ headerNum('child_user_count_all') }} + + {{ currentDrillUser ? (currentDrillUser.nickname + '的直推(人)') : '直推成员总数(人)' }} + + + {{ currentDrillUser ? (state.summary.total_count || currentDrillUser.child_user_count_1 || 0) : headerNum('child_user_count_1') }} + - - - 一级直推 - {{ headerNum('child_user_count_1') }} - - - 二级间推 - {{ headerNum('child_user_count_2') }} - + + 活跃保活: + {{ state.summary.active_count || 0 }} 人 - + - 分销成员总数(人) - {{ headerNum('child_agent_count_all') }} + + {{ currentDrillUser ? (currentDrillUser.nickname + '的合伙人') : '直推合伙股东(人)' }} + + + {{ currentDrillUser ? (state.summary.partner_count || 0) : headerNum('child_agent_count_1') }} + - - - 一级成员 - {{ headerNum('child_agent_count_1') }} - - - 二级成员 - {{ headerNum('child_agent_count_2') }} - + + 临期/暂停: + + {{ Number(state.summary.expiring_count || 0) + Number(state.summary.inactive_count || 0) }} 人 + @@ -173,17 +155,44 @@ - - - - {{ tab.name }} + + + + + + + 我的直推团队 + ({{ state.pagination.total || 0 }}人) + + 点击成员可下钻其直推 + + + + + + + + 返回上一层 + + + + + 我的直推 + + + + + @@ -232,11 +241,8 @@ {{ item.nickname || '微信用户' }} - - {{ item.team_level === 1 ? '一级直推' : '二级间推' }} + + {{ state.drillStack.length > 0 ? '直推成员' : '一级直推' }} @@ -293,25 +299,20 @@ - - - 所属推荐人: - - {{ item.parent_user.nickname }}(直推一级) - - - - {{ item.child_user_count_1 || 0 }} - 推荐好友(人) + + + + {{ item.child_user_count_1 || 0 }} + + 下钻 + + 直推好友(人) @@ -338,31 +339,39 @@ 注册时间:{{ formatTime(item.createtime) }} +