fix(multi-owner): close permission lifecycle gaps

This commit is contained in:
kuaifan 2026-05-09 12:31:54 +00:00
parent 64649b514e
commit e43588c3b2
9 changed files with 1194 additions and 11 deletions

View File

@ -412,6 +412,31 @@ class ProjectController extends AbstractController
$isPrimary = (int)$project->owner === ProjectUser::OWNER_PRIMARY;
$applyDeputy = $isPrimary && $deputy_userid !== null;
//
// 业务闭环:项目必须且只能有一个主负责人,最终成员列表必须包含该负责人
$primaryOwnerIds = ProjectUser::whereProjectId($project->id)
->whereOwner(ProjectUser::OWNER_PRIMARY)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
if (count($primaryOwnerIds) !== 1) {
return Base::retError('项目负责人数据异常,请先修复项目负责人');
}
$primaryOwnerId = $primaryOwnerIds[0];
if (!in_array($primaryOwnerId, $userid, true)) {
return Base::retError('项目成员列表必须包含项目负责人');
}
// 项目管理员可以管理普通成员,但不能借成员列表移除其他项目管理员
if (!$isPrimary) {
$currentDeputyIds = ProjectUser::whereProjectId($project->id)
->whereOwner(ProjectUser::OWNER_DEPUTY)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
if (!empty(array_diff($currentDeputyIds, $userid))) {
return Base::retError('项目管理员不能移除项目负责人或项目管理员');
}
}
//
if ($applyDeputy) {
if (!empty(array_diff($deputy_userid, $userid))) {
return Base::retError('项目管理员必须是项目成员');

View File

@ -118,6 +118,15 @@ class UserDepartment extends AbstractModel
}
$this->updateInstance($data);
//
// 防御:新负责人若残留在 user_department_owners 中(如曾是该部门管理员),清理掉
// 否则后续 delDeputy / 罢免接口会把当前部门负责人误移出部门
if ($this->id && (int)$this->owner_userid > 0) {
\DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', (int)$this->owner_userid)
->delete();
}
//
if ($this->dialog_id > 0) {
// 已有群
$dialog = WebSocketDialog::find($this->dialog_id);
@ -251,12 +260,13 @@ class UserDepartment extends AbstractModel
$user->save();
}
// 加部门管理员入部门群 + 设 role=2
// 加部门管理员入部门群 + 设 role=2 + important=true
if ($this->dialog_id > 0) {
$dialog = WebSocketDialog::find($this->dialog_id);
if ($dialog) {
// joinGroup($userid, $inviter, $important=null, $pushMsg=true)
$dialog->joinGroup($userid, 0, null, true);
// important=true部门管理员成员关系不可被普通群操作打散
$dialog->joinGroup($userid, 0, true, true);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->update(['role' => 2]);
@ -285,6 +295,16 @@ class UserDepartment extends AbstractModel
return;
}
// 防御当前部门负责人不能被罢免saveDepartment 应已清理残留,此处兜底)
// 仅清理 user_department_owners 中的悬挂记录,绝不联动移除其部门成员关系/部门群成员
if ((int)$this->owner_userid === (int)$userid) {
\DB::table('user_department_owners')
->where('department_id', $this->id)
->where('userid', $userid)
->delete();
return;
}
AbstractModel::transaction(function () use ($userid) {
$deleted = \DB::table('user_department_owners')
->where('department_id', $this->id)

View File

@ -90,9 +90,15 @@ class UserTransfer extends AbstractModel
$dialog->owner_id = $this->new_userid;
if ($dialog->save()) {
$dialog->joinGroup($this->new_userid, 0);
// 同步 role=1保证 deputy_ids 与 owner_id 一致
// 若 new_userid 之前是群管理员role=2升为群主后必须从 deputy 列表移出
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $this->new_userid)
->update(['role' => 1]);
$dialog->pushMsg("groupUpdate", [
'id' => $dialog->id,
'owner_id' => $dialog->owner_id,
'deputy_ids' => $dialog->fresh()->deputy_ids,
]);
}
}

View File

@ -262,6 +262,15 @@ class WebSocketDialog extends AbstractModel
$data[$field] = $data[$field] ?? null;
}
}
// DB::table 列表/search/beyond 渠道进入的是 stdClass不会触发 Eloquent $appends。
// 这里统一补齐 deputy_ids保证群管理员入口和标识在所有会话来源中一致。
if (($data['type'] ?? null) === 'group' && !array_key_exists('deputy_ids', $data)) {
$data['deputy_ids'] = WebSocketDialogUser::whereDialogId($data['id'])
->where('role', 2)
->pluck('userid')
->map(fn($v) => (int)$v)
->toArray();
}
$data['avatar'] = Base::fillUrl($data['avatar']);
// 会员必要字段
@ -529,18 +538,31 @@ class WebSocketDialog extends AbstractModel
if ($actor <= 0) {
throw new ApiException('只有群主或邀请人可以移出成员');
}
// 群主、群管理员、邀请人可移出
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
if (!$allowedActor) {
throw new ApiException('只有群主或邀请人可以移出成员');
}
// 群管理员不能移出群主或其他群管理员
if ($this->isDeputyOwner($actor)) {
$targetIsOwner = $this->isPrimaryOwner($item->userid) || $this->isDeputyOwner($item->userid);
if ($targetIsOwner) {
// 目标是群主或群管理员时的保护
$targetIsPrimaryOwner = $this->isPrimaryOwner($item->userid);
$targetIsDeputyOwner = $this->isDeputyOwner($item->userid);
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
// 普通邀请人不能移出群主或群管理员
$actorIsPrimaryOwner = $this->isPrimaryOwner($actor);
$actorIsDeputyOwner = $this->isDeputyOwner($actor);
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
throw new ApiException('普通成员不能移出群主或群管理员');
}
// 群管理员不能移出群主或其他群管理员
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
throw new ApiException('群管理员不能移出群主或其他群管理员');
}
}
// 普通成员:群主、群管理员、邀请人可移出
$allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter;
if (!$allowedActor) {
throw new ApiException('只有群主、群管理员或邀请人可以移出成员');
}
}
}
if ($item->userid == $this->owner_id) {

View File

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class BackfillDialogRoleConsistency extends Migration
{
/**
* Run the migrations.
*
* 兜底修复 role/owner_id 一致性:
* - 部门群 owner_id user_departments.owner_userid 对齐
* - owner_id > 0 的群确保 owner 成员存在且 role=1
* - 同一群中非 owner role=1 降为普通成员(不影响 role=2 管理员)
* - 历史 owner_id=0 的普通用户群按最早的非机器人成员回填群主
* - 清理部门负责人残留的 user_department_owners 记录
*
* 全部语句带幂等条件,可重复运行。
*
* @return void
*/
public function up()
{
$prefix = DB::getTablePrefix();
// 1) 部门群 owner_id 以 user_departments.owner_userid 为准
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}user_departments ud ON ud.dialog_id = d.id\n SET d.owner_id = ud.owner_userid\n WHERE d.type = 'group'\n AND d.group_type = 'department'\n AND d.deleted_at IS NULL\n AND ud.owner_userid > 0\n AND d.owner_id != ud.owner_userid\n ");
// 2) 历史普通用户群 owner_id=0按最早加入的非机器人成员回填群主
DB::statement("\n UPDATE {$prefix}web_socket_dialogs d\n INNER JOIN (\n SELECT du.dialog_id, MIN(du.id) AS min_id\n FROM {$prefix}web_socket_dialog_users du\n WHERE du.userid > 0 AND du.bot = 0\n GROUP BY du.dialog_id\n ) first_du ON first_du.dialog_id = d.id\n INNER JOIN {$prefix}web_socket_dialog_users owner_du ON owner_du.id = first_du.min_id\n SET d.owner_id = owner_du.userid\n WHERE d.type = 'group'\n AND d.group_type = 'user'\n AND d.deleted_at IS NULL\n AND d.owner_id = 0\n ");
// 3) owner_id > 0 但 owner 不在群成员表时,补一条成员记录(仅补真实存在的用户)
DB::statement("\n INSERT INTO {$prefix}web_socket_dialog_users\n (dialog_id, userid, role, bot, important, last_at, created_at, updated_at)\n SELECT\n d.id,\n d.owner_id,\n 1,\n COALESCE(u.bot, 0),\n CASE WHEN d.group_type IN ('user', 'all') THEN 0 ELSE 1 END,\n CASE WHEN d.group_type IN ('user', 'department', 'all') THEN NOW(3) ELSE NULL END,\n NOW(3),\n NOW(3)\n FROM {$prefix}web_socket_dialogs d\n INNER JOIN {$prefix}users u ON u.userid = d.owner_id\n LEFT JOIN {$prefix}web_socket_dialog_users du\n ON du.dialog_id = d.id AND du.userid = d.owner_id\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.id IS NULL\n ");
// 4) owner 成员设为 role=1业务群 owner 同时保持 important=1
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 1,\n du.important = CASE WHEN d.group_type IN ('user', 'all') THEN du.important ELSE 1 END\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.userid = d.owner_id\n AND (du.role != 1 OR (d.group_type NOT IN ('user', 'all') AND du.important != 1))\n ");
// 5) 同一群里非 owner 的 role=1 降为普通成员,避免多个主群主
DB::statement("\n UPDATE {$prefix}web_socket_dialog_users du\n INNER JOIN {$prefix}web_socket_dialogs d ON d.id = du.dialog_id\n SET du.role = 0\n WHERE d.type = 'group'\n AND d.deleted_at IS NULL\n AND d.owner_id > 0\n AND du.role = 1\n AND du.userid != d.owner_id\n ");
// 6) 部门负责人不能同时残留在部门管理员表
DB::statement("\n DELETE udo FROM {$prefix}user_department_owners udo\n INNER JOIN {$prefix}user_departments ud ON ud.id = udo.department_id\n WHERE ud.owner_userid = udo.userid\n ");
}
/**
* Reverse the migrations.
*
* 数据修复类迁移不提供精确回滚,避免破坏已校准的数据。
*
* @return void
*/
public function down()
{
// no-op
}
}

View File

@ -0,0 +1,326 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\UserDepartment;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* 部门管理员 important 标记测试
*/
class DepartmentDeputyImportantTest extends TestCase
{
use DatabaseTransactions;
/**
* 是否属于"测试环境无 Swoole runtime / PushTask 串行 HTTP fallback 也不可用"的环境性失败
* 这些失败与业务逻辑无关,遇到时 markTestSkipped 而非 fail
*/
private function isSwooleInfraFailure(\Throwable $e): bool
{
$msg = $e->getMessage();
// "swoole/Swoole/__wakeup" 来自 Task::deliver 的 swoole 容器绑定;
// "Undefined array key" 来自 Ihttp::ihttp_request 的 fallback URL 解析路径
// 两者皆为测试环境基础设施问题,与业务逻辑无关
return str_contains($msg, 'swoole')
|| str_contains($msg, 'Swoole')
|| str_contains($msg, 'AbstractData::__wakeup')
|| str_contains($msg, 'Undefined array key');
}
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
private function makeDepartment(string $name, int $ownerUserid): UserDepartment
{
$dept = UserDepartment::createInstance([
'name' => $name,
'parent_id' => 0,
'owner_userid' => $ownerUserid,
]);
$dept->save();
// 创建部门群
$dialog = WebSocketDialog::createGroup($name, [$ownerUserid], 'department', $ownerUserid);
$dept->dialog_id = $dialog->id;
$dept->save();
// 负责人加入部门
$owner = User::find($ownerUserid);
if ($owner) {
$owner->department = "," . $dept->id . ",";
$owner->save();
}
return $dept->fresh();
}
/**
* 测试:任命部门管理员时,应设置 important=true
*/
public function test_add_deputy_sets_important_flag()
{
$owner = $this->makeUser('owner@test.local');
$deputy = $this->makeUser('deputy@test.local');
$dept = $this->makeDepartment('TestDept', $owner->userid);
// 任命部门管理员
$dept->addDeputy($deputy->userid);
// 验证部门管理员已加入部门群
$dialogUser = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $deputy->userid)
->first();
$this->assertNotNull($dialogUser, '部门管理员应该已加入部门群');
$this->assertEquals(2, (int)$dialogUser->role, '部门管理员 role 应为 2');
$this->assertTrue((bool)$dialogUser->important, '部门管理员 important 应为 true');
}
/**
* 测试:罢免部门管理员后,应从部门群移出
*/
public function test_del_deputy_removes_from_department_group()
{
$owner = $this->makeUser('owner3@test.local');
$deputy = $this->makeUser('deputy3@test.local');
$dept = $this->makeDepartment('TestDept3', $owner->userid);
// 任命部门管理员
$dept->addDeputy($deputy->userid);
// 验证已加入
$this->assertTrue(WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $deputy->userid)->exists());
// 罢免部门管理员
$dept->delDeputy($deputy->userid);
// 验证已移出部门群
$this->assertFalse(WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $deputy->userid)->exists());
}
/**
* 测试:部门负责人也应该有 important 标记
*/
public function test_department_owner_has_important_flag()
{
$owner = $this->makeUser('owner4@test.local');
$dept = $this->makeDepartment('TestDept4', $owner->userid);
$dialogUser = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $owner->userid)
->first();
$this->assertNotNull($dialogUser);
$this->assertEquals(1, (int)$dialogUser->role, '部门负责人 role 应为 1');
$this->assertTrue((bool)$dialogUser->important, '部门负责人 important 应为 true');
}
/**
* 测试:任命部门管理员是幂等的
*/
public function test_add_deputy_is_idempotent()
{
$owner = $this->makeUser('owner5@test.local');
$deputy = $this->makeUser('deputy5@test.local');
$dept = $this->makeDepartment('TestDept5', $owner->userid);
// 第一次任命
$dept->addDeputy($deputy->userid);
// 第二次任命(不应报错)
$dept->addDeputy($deputy->userid);
// 验证只有一条记录
$count = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $deputy->userid)
->count();
$this->assertEquals(1, $count);
}
/**
* 测试:部门管理员自动加入 users.department
*/
public function test_deputy_auto_joins_department_members()
{
$owner = $this->makeUser('owner6@test.local');
$deputy = $this->makeUser('deputy6@test.local');
$dept = $this->makeDepartment('TestDept6', $owner->userid);
// 任命前不在部门
$deputy = $deputy->fresh();
$this->assertNotContains($dept->id, $deputy->department);
// 任命部门管理员
$dept->addDeputy($deputy->userid);
// 任命后应在部门
$deputy = $deputy->fresh();
$this->assertContains($dept->id, $deputy->department);
}
/**
* 测试:罢免部门管理员后,从 users.department 移除
*/
public function test_del_deputy_removes_from_department_members()
{
$owner = $this->makeUser('owner7@test.local');
$deputy = $this->makeUser('deputy7@test.local');
$dept = $this->makeDepartment('TestDept7', $owner->userid);
// 任命部门管理员
$dept->addDeputy($deputy->userid);
$deputy = $deputy->fresh();
$this->assertContains($dept->id, $deputy->department);
// 罢免部门管理员
$dept->delDeputy($deputy->userid);
// 应从部门移除
$deputy = $deputy->fresh();
$this->assertNotContains($dept->id, $deputy->department);
}
/**
* 测试:不能将部门负责人任命为部门管理员
*/
public function test_cannot_add_primary_owner_as_deputy()
{
$owner = $this->makeUser('owner8@test.local');
$dept = $this->makeDepartment('TestDept8', $owner->userid);
$this->expectException(\App\Exceptions\ApiException::class);
$this->expectExceptionMessage('不能将部门负责人任命为部门管理员');
$dept->addDeputy($owner->userid);
}
/**
* P0-AdelDeputy(当前部门负责人) 必须只清理 user_department_owners 残留,
* 绝不能把负责人从 users.department 或部门群移除
*
* 直接通过 DB 构造"残留"场景,避免触发 pushMsg/Swoole测试环境无 swoole runtime
*/
public function test_del_deputy_does_not_remove_current_primary_owner()
{
try {
$owner = $this->makeUser('owner_promo_b@test.local');
$dept = $this->makeDepartment('PromoDeptB', $owner->userid);
} catch (\Throwable $e) {
if ($this->isSwooleInfraFailure($e)) {
$this->markTestSkipped('Swoole/PushTask 运行时不可用:' . $e->getMessage());
}
throw $e;
}
// 模拟"升任后残留"owner 已是部门负责人,但 user_department_owners 仍有他的记录
\DB::table('user_department_owners')->insertOrIgnore([
'department_id' => $dept->id,
'userid' => $owner->userid,
]);
$this->assertTrue(
\DB::table('user_department_owners')
->where('department_id', $dept->id)
->where('userid', $owner->userid)
->exists()
);
// 调用 delDeputy 罢免"当前负责人" → 走防御性早返回路径
$dept->delDeputy($owner->userid);
// 1) user_department_owners 悬挂记录被清理
$this->assertFalse(
\DB::table('user_department_owners')
->where('department_id', $dept->id)
->where('userid', $owner->userid)
->exists(),
'delDeputy(当前负责人) 应清理 user_department_owners 悬挂记录'
);
// 2) 当前负责人仍在 users.department
$owner = $owner->fresh();
$this->assertContains($dept->id, $owner->department,
'当前部门负责人不能被 delDeputy 从 users.department 移除');
// 3) 当前负责人仍在部门群role 不变)
$dialogUser = WebSocketDialogUser::where('dialog_id', $dept->dialog_id)
->where('userid', $owner->userid)
->first();
$this->assertNotNull($dialogUser, '当前部门负责人不能被 delDeputy 移出部门群');
// 4) 群 owner_id 仍指向当前负责人
$dialog = WebSocketDialog::find($dept->dialog_id);
$this->assertEquals($owner->userid, (int)$dialog->owner_id);
}
/**
* P0-AsaveDepartment 必须清理新负责人在 user_department_owners 中的残留
*
* saveDepartment 内部 joinGroup/pushMsg 依赖 Swoole runtime
* 当前 PHPUnit 容器无 Swoole 时该测试将整体异常,
* 我们捕获到 Swoole 缺失则跳过(业务逻辑不变)
*/
public function test_promote_deputy_to_owner_clears_owner_table_record()
{
try {
$owner = $this->makeUser('owner_promo_a@test.local');
$deputy = $this->makeUser('deputy_promo_a@test.local');
$dept = $this->makeDepartment('PromoDeptA', $owner->userid);
// 直接 DB 写入"管理员"记录,无需 addDeputy
\DB::table('user_department_owners')->insertOrIgnore([
'department_id' => $dept->id,
'userid' => $deputy->userid,
]);
// deputy 加入 users.department + 部门群(避免 saveDepartment 路径意外)
$deputy->department = "," . $dept->id . ",";
$deputy->save();
WebSocketDialogUser::updateInsert([
'dialog_id' => $dept->dialog_id,
'userid' => $deputy->userid,
], ['role' => 2]);
$dept->saveDepartment([
'name' => $dept->name,
'parent_id' => $dept->parent_id,
'owner_userid' => $deputy->userid,
]);
} catch (\Throwable $e) {
if ($this->isSwooleInfraFailure($e)) {
$this->markTestSkipped('Swoole/PushTask 运行时不可用saveDepartment 端到端无法验证:' . $e->getMessage());
}
throw $e;
}
if (!isset($dept)) {
$this->markTestSkipped('测试环境基础设施失败');
}
$dept = $dept->fresh();
$this->assertEquals($deputy->userid, (int)$dept->owner_userid);
$this->assertFalse(
\DB::table('user_department_owners')
->where('department_id', $dept->id)
->where('userid', $deputy->userid)
->exists(),
'升任部门负责人后user_department_owners 中的 deputy 残留必须被清理'
);
}
}

View File

@ -0,0 +1,286 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* 群成员移出权限保护测试
*/
class DialogRemovePermissionTest extends TestCase
{
use DatabaseTransactions;
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
/**
* 检查移出权限(模拟 exitGroup 中的权限检查逻辑)
*/
private function checkRemovePermission(WebSocketDialog $dialog, int $actorUserid, int $targetUserid): void
{
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $targetUserid)
->first();
if (!$member) {
throw new \RuntimeException('target not member');
}
$actor = $actorUserid;
if ($actor <= 0) {
throw new \RuntimeException('只有群主或邀请人可以移出成员');
}
// 目标是群主或群管理员时的保护
$targetIsPrimaryOwner = $dialog->isPrimaryOwner($targetUserid);
$targetIsDeputyOwner = $dialog->isDeputyOwner($targetUserid);
if ($targetIsPrimaryOwner || $targetIsDeputyOwner) {
// 普通邀请人不能移出群主或群管理员
$actorIsPrimaryOwner = $dialog->isPrimaryOwner($actor);
$actorIsDeputyOwner = $dialog->isDeputyOwner($actor);
if (!$actorIsPrimaryOwner && !$actorIsDeputyOwner) {
throw new \RuntimeException('普通成员不能移出群主或群管理员');
}
// 群管理员不能移出群主或其他群管理员
if ($actorIsDeputyOwner && !$actorIsPrimaryOwner) {
throw new \RuntimeException('群管理员不能移出群主或其他群管理员');
}
}
// 普通成员:群主、群管理员、邀请人可移出
$allowedActor = $dialog->isOwner($actor) || $actor === (int)$member->inviter;
if (!$allowedActor) {
throw new \RuntimeException('只有群主、群管理员或邀请人可以移出成员');
}
}
/**
* 执行移出(权限检查通过后)
*/
private function simulateRemove(WebSocketDialog $dialog, int $actorUserid, int $targetUserid): void
{
$this->checkRemovePermission($dialog, $actorUserid, $targetUserid);
// 执行移出
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $targetUserid)
->delete();
}
/**
* 测试:普通邀请人不能移出群主
*/
public function test_inviter_cannot_remove_primary_owner()
{
$owner = $this->makeUser('owner@test.local');
$inviter = $this->makeUser('inviter@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup', [$owner->userid, $inviter->userid], 'user', $owner->userid);
// inviter 邀请了 owner模拟场景
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $owner->userid)
->update(['inviter' => $inviter->userid]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('普通成员不能移出群主或群管理员');
$this->simulateRemove($dialog, $inviter->userid, $owner->userid);
}
/**
* 测试:普通邀请人不能移出群管理员
*/
public function test_inviter_cannot_remove_deputy_owner()
{
$owner = $this->makeUser('owner2@test.local');
$deputy = $this->makeUser('deputy2@test.local');
$inviter = $this->makeUser('inviter2@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup2', [$owner->userid, $deputy->userid, $inviter->userid], 'user', $owner->userid);
// 设置群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
// inviter 邀请了 deputy模拟场景
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['inviter' => $inviter->userid]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('普通成员不能移出群主或群管理员');
$this->simulateRemove($dialog, $inviter->userid, $deputy->userid);
}
/**
* 测试:群管理员不能移出群主
*/
public function test_deputy_cannot_remove_primary_owner()
{
$owner = $this->makeUser('owner3@test.local');
$deputy = $this->makeUser('deputy3@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup3', [$owner->userid, $deputy->userid], 'user', $owner->userid);
// 设置群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('群管理员不能移出群主或其他群管理员');
$this->simulateRemove($dialog, $deputy->userid, $owner->userid);
}
/**
* 测试:群管理员不能移出其他群管理员
*/
public function test_deputy_cannot_remove_other_deputy()
{
$owner = $this->makeUser('owner4@test.local');
$deputy1 = $this->makeUser('deputy4_1@test.local');
$deputy2 = $this->makeUser('deputy4_2@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup4', [$owner->userid, $deputy1->userid, $deputy2->userid], 'user', $owner->userid);
// 设置两个群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->whereIn('userid', [$deputy1->userid, $deputy2->userid])
->update(['role' => 2]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('群管理员不能移出群主或其他群管理员');
$this->simulateRemove($dialog, $deputy1->userid, $deputy2->userid);
}
/**
* 测试:群管理员可以移出普通成员
*/
public function test_deputy_can_remove_regular_member()
{
$owner = $this->makeUser('owner5@test.local');
$deputy = $this->makeUser('deputy5@test.local');
$member = $this->makeUser('member5@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup5', [$owner->userid, $deputy->userid, $member->userid], 'user', $owner->userid);
// 设置群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
// 应该成功
$this->simulateRemove($dialog, $deputy->userid, $member->userid);
$this->assertFalse(WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $member->userid)->exists());
}
/**
* 测试:群主可以移出群管理员
*/
public function test_primary_owner_can_remove_deputy()
{
$owner = $this->makeUser('owner6@test.local');
$deputy = $this->makeUser('deputy6@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup6', [$owner->userid, $deputy->userid], 'user', $owner->userid);
// 设置群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
// 应该成功
$this->simulateRemove($dialog, $owner->userid, $deputy->userid);
$this->assertFalse(WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)->exists());
}
/**
* 测试:普通邀请人可以移出自己邀请的普通成员
*/
public function test_inviter_can_remove_invited_regular_member()
{
$owner = $this->makeUser('owner7@test.local');
$inviter = $this->makeUser('inviter7@test.local');
$invited = $this->makeUser('invited7@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup7', [$owner->userid, $inviter->userid, $invited->userid], 'user', $owner->userid);
// inviter 邀请了 invited
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $invited->userid)
->update(['inviter' => $inviter->userid]);
// 应该成功
$this->simulateRemove($dialog, $inviter->userid, $invited->userid);
$this->assertFalse(WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $invited->userid)->exists());
}
/**
* 测试:非邀请人的普通成员不能移出其他普通成员
*/
public function test_regular_member_cannot_remove_other_member()
{
$owner = $this->makeUser('owner8@test.local');
$member1 = $this->makeUser('member8_1@test.local');
$member2 = $this->makeUser('member8_2@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup8', [$owner->userid, $member1->userid, $member2->userid], 'user', $owner->userid);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('只有群主、群管理员或邀请人可以移出成员');
$this->simulateRemove($dialog, $member1->userid, $member2->userid);
}
/**
* P2DB::table/stdClass 渠道的会话数据也应稳定包含 deputy_ids
*/
public function test_synthesize_data_from_db_row_includes_deputy_ids()
{
$owner = $this->makeUser('owner9@test.local');
$deputy = $this->makeUser('deputy9@test.local');
$member = $this->makeUser('member9@test.local');
$dialog = WebSocketDialog::createGroup('TestGroup9', [$owner->userid, $deputy->userid, $member->userid], 'user', $owner->userid);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
// 模拟 getDialogList/searchDialog/getDialogBeyond 的 DB::table stdClass 数据源
$row = \DB::table('web_socket_dialog_users as u')
->select(['d.*', 'u.top_at', 'u.last_at', 'u.mark_unread', 'u.silence', 'u.hide', 'u.color', 'u.updated_at as user_at'])
->join('web_socket_dialogs as d', 'u.dialog_id', '=', 'd.id')
->where('u.dialog_id', $dialog->id)
->where('u.userid', $owner->userid)
->first();
$data = WebSocketDialog::synthesizeData($row, $owner->userid);
$this->assertArrayHasKey('deputy_ids', $data);
$this->assertContains((int)$deputy->userid, $data['deputy_ids']);
$this->assertNotContains((int)$owner->userid, $data['deputy_ids']);
$this->assertNotContains((int)$member->userid, $data['deputy_ids']);
}
}

View File

@ -0,0 +1,262 @@
<?php
namespace Tests\Feature;
use App\Models\AbstractModel;
use App\Models\Project;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* 项目成员管理接口保护测试
*/
class ProjectMemberProtectionTest extends TestCase
{
use DatabaseTransactions;
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $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, [], []);
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\UserTransfer;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* P0-B离职转移普通群群主时 role/deputy_ids 必须同步
*/
class UserTransferOwnerRoleTest extends TestCase
{
use DatabaseTransactions;
private function isSwooleInfraFailure(\Throwable $e): bool
{
$msg = $e->getMessage();
return str_contains($msg, 'swoole')
|| str_contains($msg, 'Swoole')
|| str_contains($msg, 'AbstractData::__wakeup')
|| str_contains($msg, 'Undefined array key');
}
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
/**
* 离职移交后:接收人 role=1,且 deputy_ids 不再包含他(哪怕之前是群管理员)
*/
public function test_transfer_promotes_deputy_receiver_to_owner_role_one()
{
$original = $this->makeUser('orig_owner@test.local');
$receiver = $this->makeUser('xfer_receiver@test.local');
// original 为群主、receiver 为群管理员role=2的普通 user 群
$dialog = WebSocketDialog::createGroup(
'XferUserGroup',
[$original->userid, $receiver->userid],
'user',
$original->userid
);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $receiver->userid)
->update(['role' => 2]);
// 移交前断言
$this->assertContains((int)$receiver->userid, $dialog->fresh()->deputy_ids);
// 触发离职移交exitDialog 路径)
$transfer = new UserTransfer();
$transfer->original_userid = $original->userid;
$transfer->new_userid = $receiver->userid;
try {
$transfer->exitDialog();
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'swoole')) {
$this->markTestSkipped('Swoole runtime 不可用UserTransfer::exitDialog 无法在当前环境中端到端验证:' . $e->getMessage());
}
throw $e;
}
// 群主已切换
$dialog = WebSocketDialog::find($dialog->id);
$this->assertEquals($receiver->userid, (int)$dialog->owner_id, '群主应转移为接收人');
// original 已退群
$this->assertFalse(
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $original->userid)
->exists(),
'原群主应已退出群组'
);
// receiver role=1从 deputy 升级为 primary owner
$receiverDu = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $receiver->userid)
->first();
$this->assertNotNull($receiverDu);
$this->assertEquals(1, (int)$receiverDu->role, '接收人 role 应升级为 1primary owner');
// deputy_ids 不再包含 receiver
$this->assertNotContains((int)$receiver->userid, $dialog->fresh()->deputy_ids,
'接收人升为群主后 deputy_ids 不应再包含他');
}
/**
* 离职移交接收人不在群中exitDialog 把他作为新成员加入role 也应被设为 1
*/
public function test_transfer_adds_new_receiver_with_owner_role_one()
{
$original = $this->makeUser('orig2_owner@test.local');
$receiver = $this->makeUser('xfer2_receiver@test.local');
// 群里只有 original
$dialog = WebSocketDialog::createGroup(
'XferUserGroup2',
[$original->userid],
'user',
$original->userid
);
$transfer = new UserTransfer();
$transfer->original_userid = $original->userid;
$transfer->new_userid = $receiver->userid;
try {
$transfer->exitDialog();
} catch (\Throwable $e) {
if ($this->isSwooleInfraFailure($e)) {
$this->markTestSkipped('Swoole/PushTask 运行时不可用:' . $e->getMessage());
}
throw $e;
}
$dialog = WebSocketDialog::find($dialog->id);
$this->assertEquals($receiver->userid, (int)$dialog->owner_id);
$receiverDu = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $receiver->userid)
->first();
$this->assertNotNull($receiverDu, '接收人应被 joinGroup 加入群组');
$this->assertEquals(1, (int)$receiverDu->role, '接收人 role 应为 1');
}
/**
* 离职移交original 不是群主时(普通成员),不触发 owner 转移逻辑
*/
public function test_transfer_does_not_promote_when_original_not_owner()
{
$owner = $this->makeUser('grp_owner@test.local');
$original = $this->makeUser('orig3_member@test.local');
$receiver = $this->makeUser('xfer3_receiver@test.local');
$dialog = WebSocketDialog::createGroup(
'XferUserGroup3',
[$owner->userid, $original->userid, $receiver->userid],
'user',
$owner->userid
);
// receiver 是 role=2 的群管理员
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $receiver->userid)
->update(['role' => 2]);
$transfer = new UserTransfer();
$transfer->original_userid = $original->userid;
$transfer->new_userid = $receiver->userid;
try {
$transfer->exitDialog();
} catch (\Throwable $e) {
if ($this->isSwooleInfraFailure($e)) {
$this->markTestSkipped('Swoole/PushTask 运行时不可用:' . $e->getMessage());
}
throw $e;
}
// 原群主未变
$dialog = WebSocketDialog::find($dialog->id);
$this->assertEquals($owner->userid, (int)$dialog->owner_id);
// receiver 仍然是群管理员role=2
$receiverDu = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $receiver->userid)
->first();
$this->assertEquals(2, (int)$receiverDu->role);
}
}