$email, 'userimg' => '', 'nickname' => 'TestUser_' . substr(md5($email), 0, 6), 'profession' => '', 'password' => md5('123456'), ]); $user->save(); return $user; } private function makeProject(int $ownerUserid, array $memberUserids = []): Project { $allMembers = array_unique(array_merge([$ownerUserid], $memberUserids)); $project = Project::createInstance([ 'name' => 'Test_' . substr(md5(uniqid('', true)), 0, 6), 'desc' => '', 'userid' => $ownerUserid, 'personal' => 0, ]); $project->save(); ProjectUser::updateInsert([ 'project_id' => $project->id, 'userid' => $ownerUserid, ], ['owner' => 1]); foreach ($allMembers as $uid) { if ($uid === $ownerUserid) continue; ProjectUser::updateInsert([ 'project_id' => $project->id, 'userid' => $uid, ], ['owner' => 0]); } $dialog = WebSocketDialog::createGroup('Test_dialog', $allMembers, 'project', $ownerUserid); $project->dialog_id = $dialog->id; $project->save(); $project->syncDialogUser(); return $project->fresh(); } /** * 模拟 ProjectController::user() 成员同步接口 */ private function simulateMemberSync(Project $project, int $callerUserid, array $userids, ?array $deputyUserids): array { $callerRow = ProjectUser::where('project_id', $project->id) ->where('userid', $callerUserid)->first(); if (!$callerRow || !in_array((int)$callerRow->owner, [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY], true)) { throw new \RuntimeException('not owner or deputy'); } $isPrimary = (int)$callerRow->owner === ProjectUser::OWNER_PRIMARY; $applyDeputy = $isPrimary && $deputyUserids !== null; if (count($userids) > 100) { throw new \RuntimeException('over 100 members'); } // 业务闭环:项目必须且只能有一个主负责人,最终成员列表必须包含该负责人 $primaryOwnerIds = ProjectUser::whereProjectId($project->id) ->whereOwner(ProjectUser::OWNER_PRIMARY) ->pluck('userid') ->map(fn($v) => (int)$v) ->toArray(); if (count($primaryOwnerIds) !== 1) { throw new \RuntimeException('项目负责人数据异常,请先修复项目负责人'); } $primaryOwnerId = $primaryOwnerIds[0]; if (!in_array($primaryOwnerId, $userids, true)) { throw new \RuntimeException('项目成员列表必须包含项目负责人'); } // 项目管理员可以管理普通成员,但不能借成员列表移除其他项目管理员 if (!$isPrimary) { $currentDeputyIds = ProjectUser::whereProjectId($project->id) ->whereOwner(ProjectUser::OWNER_DEPUTY) ->pluck('userid') ->map(fn($v) => (int)$v) ->toArray(); if (!empty(array_diff($currentDeputyIds, $userids))) { throw new \RuntimeException('项目管理员不能移除项目负责人或项目管理员'); } } if ($applyDeputy) { $deputyUserids = array_values(array_unique(array_map('intval', $deputyUserids))); if (!empty(array_diff($deputyUserids, $userids))) { throw new \RuntimeException('deputy must be member'); } if (in_array($project->owner_userid, $deputyUserids, true)) { throw new \RuntimeException('primary cannot be deputy'); } } return AbstractModel::transaction(function () use ($project, $userids, $applyDeputy, $deputyUserids) { $array = []; foreach ($userids as $uid) { if ($project->joinProject($uid)) { $array[] = $uid; } } $deleteRows = ProjectUser::whereProjectId($project->id)->whereNotIn('userid', $array)->get(); $deleteUserids = $deleteRows->pluck('userid')->toArray(); foreach ($deleteRows as $row) { $row->exitProject(); } if ($applyDeputy) { $current = ProjectUser::whereProjectId($project->id) ->where('owner', ProjectUser::OWNER_DEPUTY) ->pluck('userid')->toArray(); $toPromote = array_values(array_diff($deputyUserids, $current)); $toDemote = array_values(array_diff($current, $deputyUserids)); if (!empty($toPromote)) { ProjectUser::whereProjectId($project->id) ->whereIn('userid', $toPromote) ->where('owner', ProjectUser::OWNER_MEMBER) ->change(['owner' => ProjectUser::OWNER_DEPUTY]); } if (!empty($toDemote)) { ProjectUser::whereProjectId($project->id) ->whereIn('userid', $toDemote) ->where('owner', ProjectUser::OWNER_DEPUTY) ->change(['owner' => ProjectUser::OWNER_MEMBER]); } } $project->fresh()->syncDialogUser(); return $deleteUserids; }); } /** * 测试:项目负责人不能从成员列表中移除自己 */ public function test_primary_owner_cannot_remove_self() { $owner = $this->makeUser('owner@test.local'); $member = $this->makeUser('member@test.local'); $project = $this->makeProject($owner->userid, [$member->userid]); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('项目成员列表必须包含项目负责人'); // 负责人尝试移除自己 $this->simulateMemberSync($project, $owner->userid, [$member->userid], null); } /** * 测试:项目管理员不能通过成员管理接口移除项目负责人 */ public function test_deputy_cannot_remove_primary_owner() { $owner = $this->makeUser('owner2@test.local'); $deputy = $this->makeUser('deputy2@test.local'); $member = $this->makeUser('member2@test.local'); $project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]); // 任命项目管理员 ProjectUser::where('project_id', $project->id) ->where('userid', $deputy->userid) ->update(['owner' => ProjectUser::OWNER_DEPUTY]); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('项目成员列表必须包含项目负责人'); // 项目管理员尝试移除负责人 $this->simulateMemberSync($project, $deputy->userid, [$deputy->userid, $member->userid], null); } /** * 测试:项目负责人可以正常管理普通成员 */ public function test_primary_owner_can_manage_regular_members() { $owner = $this->makeUser('owner3@test.local'); $member1 = $this->makeUser('member3_1@test.local'); $member2 = $this->makeUser('member3_2@test.local'); $project = $this->makeProject($owner->userid, [$member1->userid, $member2->userid]); // 移除 member2 $deleted = $this->simulateMemberSync($project, $owner->userid, [$owner->userid, $member1->userid], []); $this->assertContains($member2->userid, $deleted); $this->assertFalse(ProjectUser::where('project_id', $project->id) ->where('userid', $member2->userid)->exists()); } /** * 测试:项目管理员可以管理普通成员,但不能移除负责人 */ public function test_deputy_can_manage_regular_members_but_not_owner() { $owner = $this->makeUser('owner4@test.local'); $deputy = $this->makeUser('deputy4@test.local'); $member = $this->makeUser('member4@test.local'); $project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]); ProjectUser::where('project_id', $project->id) ->where('userid', $deputy->userid) ->update(['owner' => ProjectUser::OWNER_DEPUTY]); // 项目管理员移除普通成员(应该成功) $deleted = $this->simulateMemberSync($project, $deputy->userid, [$owner->userid, $deputy->userid], null); $this->assertContains($member->userid, $deleted); } /** * 测试:项目管理员不能移除其他项目管理员(不能借成员管理绕过罢免权限) */ public function test_deputy_cannot_remove_other_deputy() { $owner = $this->makeUser('owner4b@test.local'); $deputy1 = $this->makeUser('deputy4b_1@test.local'); $deputy2 = $this->makeUser('deputy4b_2@test.local'); $member = $this->makeUser('member4b@test.local'); $project = $this->makeProject($owner->userid, [$deputy1->userid, $deputy2->userid, $member->userid]); ProjectUser::where('project_id', $project->id) ->whereIn('userid', [$deputy1->userid, $deputy2->userid]) ->update(['owner' => ProjectUser::OWNER_DEPUTY]); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('项目管理员不能移除项目负责人或项目管理员'); // deputy1 尝试移除 deputy2 $this->simulateMemberSync($project, $deputy1->userid, [$owner->userid, $deputy1->userid, $member->userid], null); } /** * 测试:空成员列表被拒绝(因为必须包含负责人) */ public function test_empty_member_list_rejected() { $owner = $this->makeUser('owner5@test.local'); $project = $this->makeProject($owner->userid); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('项目成员列表必须包含项目负责人'); $this->simulateMemberSync($project, $owner->userid, [], []); } }