feat(dialog): 系统设置支持禁止其他人员设置/取消聊天待办

新增系统级开关 todo_set_permission(open=允许默认 / close=禁止)。
开关为禁止时,仅本人、群主/群管理员、项目负责人/项目管理员、任务负责人
可设置或取消聊天消息待办,其他人由后端拦截;默认允许,保持现有行为。

- SystemController::setting 接入开关读写(白名单 + 默认 open)
- WebSocketDialog::checkTodoOwnerPermission 角色判断(复用 isOwner 等)
- WebSocketDialogMsg::toggleTodoMsg 内权限闸门:close 且影响到他人且
  非放行角色时 retError;仅影响自己始终放行;open 时行为零变化
- SystemSetting.vue「消息相关」新增「待办设置权限」开关 UI
- 国际化文案(original-api.txt / original-web.txt)
- TodoSetPermissionTest 覆盖角色判断、闸门决策及真实拦截路径(8 用例)

任务 #124。系统后台 admin 不特殊放行;「完成待办」不在本次范围。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-05-31 15:12:46 +00:00
parent e57736bcc1
commit aa2e0acaba
7 changed files with 300 additions and 0 deletions

View File

@ -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';

View File

@ -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 列表
*

View File

@ -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();

View File

@ -976,3 +976,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
负责人不能任命为项目管理员
普通成员不能移出群主或群管理员
只有群主、群管理员或邀请人可以移出成员
仅群主、项目/任务负责人可设置或取消他人待办

View File

@ -2393,3 +2393,6 @@ AI任务分析
部门负责人视角
开启后,部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务。
部门管理员同步失败
待办设置权限
允许:所有成员可设置/取消他人待办。
禁止:仅本人、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。

View File

@ -207,6 +207,14 @@
<div v-if="formDatum.anon_message == 'open'" class="form-tip">{{$L('允许匿名发送消息给其他成员')}}</div>
<div v-else class="form-tip">{{$L('禁止匿名发送消息。')}}</div>
</FormItem>
<FormItem :label="$L('待办设置权限')" prop="todoSetPermission">
<RadioGroup v-model="formDatum.todo_set_permission">
<Radio label="open">{{$L('允许')}}</Radio>
<Radio label="close">{{$L('禁止')}}</Radio>
</RadioGroup>
<div v-if="formDatum.todo_set_permission == 'open'" class="form-tip">{{$L('允许所有成员可设置/取消他人待办')}}</div>
<div v-else class="form-tip">{{$L('禁止:仅本人、群主(含群管理员)、项目负责人(含项目管理员)、任务负责人可设置/取消待办。')}}</div>
</FormItem>
<FormItem :label="$L('视频转换')" prop="convertVideo">
<RadioGroup v-model="formDatum.convert_video">
<Radio label="open">{{$L('开启')}}</Radio>

View File

@ -0,0 +1,242 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogUser;
use App\Module\Base;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* 待办设置/取消权限测试(系统开关 todo_set_permission
*/
class TodoSetPermissionTest 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;
}
/** 普通群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=projectowner=负责人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());
}
}