diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index 586e1f006..177788b60 100755 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -95,6 +95,7 @@ class SystemController extends AbstractController 'unclaimed_task_reminder_time', 'task_ai_auto_analyze', 'department_owner_project_view', + 'todo_set_permission', ])) { unset($all[$key]); } @@ -142,6 +143,7 @@ class SystemController extends AbstractController $setting['archived_day'] = floatval($setting['archived_day']) ?: 7; $setting['task_visible'] = $setting['task_visible'] ?: 'close'; $setting['all_group_mute'] = $setting['all_group_mute'] ?: 'open'; + $setting['todo_set_permission'] = $setting['todo_set_permission'] ?: 'open'; $setting['all_group_autoin'] = $setting['all_group_autoin'] ?: 'yes'; $setting['user_private_chat_mute'] = $setting['user_private_chat_mute'] ?: 'open'; $setting['user_group_chat_mute'] = $setting['user_group_chat_mute'] ?: 'open'; diff --git a/app/Models/WebSocketDialog.php b/app/Models/WebSocketDialog.php index 65714c733..ce546037e 100644 --- a/app/Models/WebSocketDialog.php +++ b/app/Models/WebSocketDialog.php @@ -710,6 +710,42 @@ class WebSocketDialog extends AbstractModel return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid); } + /** + * 是否有权限设置/取消本会话内「他人」的待办 + * 放行:群主/群管理员、关联项目负责人/项目管理员、关联任务负责人(及任务所属项目负责人/管理员) + * + * @param int $userid + * @return bool + */ + public function checkTodoOwnerPermission($userid): bool + { + $userid = intval($userid); + if ($userid <= 0) { + return false; + } + // 群主 / 群管理员 + if ($this->isOwner($userid)) { + return true; + } + // 关联项目(项目群)负责人 / 项目管理员 + $project = Project::whereDialogId($this->id)->first(); + if ($project && $project->isOwner($userid)) { + return true; + } + // 关联任务(任务群)负责人,及任务所属项目负责人 / 管理员 + $task = ProjectTask::whereDialogId($this->id)->first(); + if ($task) { + if (ProjectTaskUser::whereTaskId($task->id)->whereUserid($userid)->whereOwner(1)->exists()) { + return true; + } + $taskProject = Project::find($task->project_id); + if ($taskProject && $taskProject->isOwner($userid)) { + return true; + } + } + return false; + } + /** * 群管理员 userid 列表 * diff --git a/app/Models/WebSocketDialogMsg.php b/app/Models/WebSocketDialogMsg.php index 954c9de6c..4308d80cc 100644 --- a/app/Models/WebSocketDialogMsg.php +++ b/app/Models/WebSocketDialogMsg.php @@ -423,6 +423,14 @@ class WebSocketDialogMsg extends AbstractModel $current = WebSocketDialogMsgTodo::whereMsgId($this->id)->pluck('userid')->toArray(); $cancel = array_diff($current, $userids); $setup = array_diff($userids, $current); + // 待办操作权限管控(系统开关:禁止其他人员设置/取消待办) + if (Base::settingFind('system', 'todo_set_permission') === 'close') { + $affected = array_unique(array_merge($cancel, $setup)); // 本次真正影响到的用户 + $others = array_diff($affected, [$sender]); // 排除"自己" + if ($others && !$dialog->checkTodoOwnerPermission($sender)) { + return Base::retError('仅群主、项目/任务负责人可设置或取消他人待办'); + } + } // $this->todo = $setup || count($current) > count($cancel) ? $sender : 0; $this->save(); diff --git a/language/original-api.txt b/language/original-api.txt index e936a9a1c..e17e0512e 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -976,3 +976,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置 负责人不能任命为项目管理员 普通成员不能移出群主或群管理员 只有群主、群管理员或邀请人可以移出成员 +仅群主、项目/任务负责人可设置或取消他人待办 diff --git a/language/original-web.txt b/language/original-web.txt index e06418ce2..c51c1a071 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2393,3 +2393,6 @@ AI任务分析 部门负责人视角 开启后,部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务。 部门管理员同步失败 +待办设置权限 +允许:所有成员可设置/取消他人待办。 +禁止:仅本人、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。 diff --git a/resources/assets/js/pages/manage/setting/components/SystemSetting.vue b/resources/assets/js/pages/manage/setting/components/SystemSetting.vue index cbe75596f..54556385d 100644 --- a/resources/assets/js/pages/manage/setting/components/SystemSetting.vue +++ b/resources/assets/js/pages/manage/setting/components/SystemSetting.vue @@ -207,6 +207,14 @@
{{$L('允许匿名发送消息给其他成员。')}}
{{$L('禁止匿名发送消息。')}}
+ + + {{$L('允许')}} + {{$L('禁止')}} + +
{{$L('允许:所有成员可设置/取消他人待办。')}}
+
{{$L('禁止:仅本人、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。')}}
+
{{$L('开启')}} diff --git a/tests/Feature/TodoSetPermissionTest.php b/tests/Feature/TodoSetPermissionTest.php new file mode 100644 index 000000000..29b66aebf --- /dev/null +++ b/tests/Feature/TodoSetPermissionTest.php @@ -0,0 +1,242 @@ + $email, + 'userimg' => '', + 'nickname' => 'TestUser_' . substr(md5($email), 0, 6), + 'profession' => '', + 'password' => md5('123456'), + ]); + $user->save(); + return $user; + } + + /** 普通群(group_type=user),设置群主与群管理员 */ + private function makeUserGroup(int $ownerUserid, array $members, array $deputyUserids = []): WebSocketDialog + { + $all = array_values(array_unique(array_merge([$ownerUserid], $members))); + $dialog = WebSocketDialog::createGroup('Test_group', $all, 'user', $ownerUserid); + if ($deputyUserids) { + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->whereIn('userid', $deputyUserids) + ->update(['role' => 2]); + } + return $dialog->fresh(); + } + + /** 项目 + 项目群(group_type=project),owner=负责人,deputy=项目管理员 */ + private function makeProjectWithDialog(int $ownerUserid, array $members = [], array $deputyUserids = []): Project + { + $all = array_values(array_unique(array_merge([$ownerUserid], $members))); + $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 ($all as $uid) { + if ($uid === $ownerUserid) continue; + ProjectUser::updateInsert(['project_id' => $project->id, 'userid' => $uid], ['owner' => 0]); + } + if ($deputyUserids) { + ProjectUser::where('project_id', $project->id) + ->whereIn('userid', $deputyUserids) + ->update(['owner' => 2]); + } + $dialog = WebSocketDialog::createGroup('Test_pdialog', $all, 'project', $ownerUserid); + $project->dialog_id = $dialog->id; + $project->save(); + $project->syncDialogUser(); + return $project->fresh(); + } + + public function test_group_owner_and_deputy_allowed_others_not() + { + $owner = $this->makeUser('t_g_o@test.local'); + $deputy = $this->makeUser('t_g_d@test.local'); + $member = $this->makeUser('t_g_m@test.local'); + $dialog = $this->makeUserGroup($owner->userid, [$deputy->userid, $member->userid], [$deputy->userid]); + + $this->assertTrue($dialog->checkTodoOwnerPermission($owner->userid)); + $this->assertTrue($dialog->checkTodoOwnerPermission($deputy->userid)); + $this->assertFalse($dialog->checkTodoOwnerPermission($member->userid)); + $this->assertFalse($dialog->checkTodoOwnerPermission(0)); + } + + public function test_project_owner_and_deputy_allowed_member_not() + { + $owner = $this->makeUser('t_p_o@test.local'); + $deputy = $this->makeUser('t_p_d@test.local'); + $member = $this->makeUser('t_p_m@test.local'); + $project = $this->makeProjectWithDialog($owner->userid, [$deputy->userid, $member->userid], [$deputy->userid]); + $dialog = WebSocketDialog::find($project->dialog_id); + + $this->assertTrue($dialog->checkTodoOwnerPermission($owner->userid)); + $this->assertTrue($dialog->checkTodoOwnerPermission($deputy->userid)); + $this->assertFalse($dialog->checkTodoOwnerPermission($member->userid)); + } + + public function test_task_owner_and_task_project_owner_allowed_member_not() + { + $projectOwner = $this->makeUser('t_t_po@test.local'); + $taskOwner = $this->makeUser('t_t_to@test.local'); + $member = $this->makeUser('t_t_m@test.local'); + $project = $this->makeProjectWithDialog($projectOwner->userid, [$taskOwner->userid, $member->userid]); + + // 任务群(group_type=task) + $taskDialog = WebSocketDialog::createGroup( + 'Test_tdialog', + [$projectOwner->userid, $taskOwner->userid, $member->userid], + 'task', + $projectOwner->userid + ); + $task = ProjectTask::createInstance([ + 'project_id' => $project->id, + 'parent_id' => 0, + 'name' => 'Test task', + 'dialog_id' => $taskDialog->id, + 'userid' => $projectOwner->userid, + ]); + $task->save(); + ProjectTaskUser::createInstance([ + 'project_id' => $project->id, + 'task_id' => $task->id, + 'userid' => $taskOwner->userid, + 'owner' => 1, + ])->save(); + + $taskDialog = $taskDialog->fresh(); + $this->assertTrue($taskDialog->checkTodoOwnerPermission($taskOwner->userid), '任务负责人应放行'); + $this->assertTrue($taskDialog->checkTodoOwnerPermission($projectOwner->userid), '任务所属项目负责人应放行'); + $this->assertFalse($taskDialog->checkTodoOwnerPermission($member->userid), '普通成员应拒绝'); + } + + /** + * 镜像 WebSocketDialogMsg::toggleTodoMsg 内的权限闸门决策。 + * @param string $switch 开关值 open|close + * @param int $sender 操作者 + * @param array $cancel 本次取消待办的用户 + * @param array $setup 本次新增待办的用户 + * @return bool true=放行 + */ + private function gateAllow(WebSocketDialog $dialog, string $switch, int $sender, array $cancel, array $setup): bool + { + if ($switch !== 'close') { + return true; // 开关非 close:行为不变,全部放行 + } + $affected = array_unique(array_merge($cancel, $setup)); + $others = array_diff($affected, [$sender]); + if (!$others) { + return true; // 仅影响自己 → 放行 + } + return $dialog->checkTodoOwnerPermission($sender); + } + + public function test_gate_open_switch_allows_anyone() + { + $owner = $this->makeUser('t_gate_o@test.local'); + $member = $this->makeUser('t_gate_m@test.local'); + $dialog = $this->makeUserGroup($owner->userid, [$member->userid]); + + // 普通成员给他人设待办,开关 open → 放行 + $this->assertTrue($this->gateAllow($dialog, 'open', $member->userid, [], [$owner->userid, $member->userid])); + } + + public function test_gate_close_self_only_allowed() + { + $owner = $this->makeUser('t_gate2_o@test.local'); + $member = $this->makeUser('t_gate2_m@test.local'); + $dialog = $this->makeUserGroup($owner->userid, [$member->userid]); + + // 普通成员仅给/取消自己的待办 → 放行 + $this->assertTrue($this->gateAllow($dialog, 'close', $member->userid, [], [$member->userid])); + $this->assertTrue($this->gateAllow($dialog, 'close', $member->userid, [$member->userid], [])); + } + + public function test_gate_close_member_to_others_blocked() + { + $owner = $this->makeUser('t_gate3_o@test.local'); + $member = $this->makeUser('t_gate3_m@test.local'); + $dialog = $this->makeUserGroup($owner->userid, [$member->userid]); + + // 普通成员给他人设待办,开关 close → 拒绝 + $this->assertFalse($this->gateAllow($dialog, 'close', $member->userid, [], [$owner->userid])); + // 普通成员取消他人待办 → 拒绝 + $this->assertFalse($this->gateAllow($dialog, 'close', $member->userid, [$owner->userid], [])); + } + + public function test_gate_close_owner_to_others_allowed() + { + $owner = $this->makeUser('t_gate4_o@test.local'); + $member = $this->makeUser('t_gate4_m@test.local'); + $dialog = $this->makeUserGroup($owner->userid, [$member->userid]); + + // 群主给他人设待办,开关 close → 放行 + $this->assertTrue($this->gateAllow($dialog, 'close', $owner->userid, [], [$member->userid])); + } + + /** + * 真实调用 toggleTodoMsg,覆盖闸门「被拦截路径」: + * 开关 close 下,普通成员给他人设待办应被提前 retError 拦截, + * 不会执行后续的 sendMsg(无 WebSocket 副作用)。 + */ + public function test_toggle_todo_msg_blocks_member_to_others_when_close() + { + $owner = $this->makeUser('t_toggle_o@test.local'); + $member = $this->makeUser('t_toggle_m@test.local'); + $dialog = $this->makeUserGroup($owner->userid, [$member->userid]); + + // 系统开关设为 close(合并写入,避免清空其它 system 设置) + $setting = Base::setting('system', ['todo_set_permission' => 'close'], true); + $this->assertSame('close', $setting['todo_set_permission']); + $this->assertSame('close', Base::settingFind('system', 'todo_set_permission')); + + // 群主发一条真实文本消息(type 必须是 text,否则被 in_array 提前拒绝) + $msg = WebSocketDialogMsg::createInstance([ + 'dialog_id' => $dialog->id, + 'dialog_type' => $dialog->type, + 'userid' => $owner->userid, + 'type' => 'text', + 'msg' => ['text' => 'hello todo'], + 'read' => 0, + 'send' => 1, + ]); + $msg->save(); + + // 普通成员给群主(他人)设待办 → 被闸门拦截 + $res = $msg->toggleTodoMsg($member->userid, [$owner->userid]); + + $this->assertTrue(Base::isError($res), '被拦截路径应返回错误响应'); + $this->assertSame(0, $res['ret']); + $this->assertStringContainsString('仅群主、项目/任务负责人可设置或取消他人待办', $res['msg']); + + // 被拦截后不应写入任何待办记录 + $this->assertSame(0, WebSocketDialogMsgTodo::whereMsgId($msg->id)->count()); + } +}